Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
// Agent Workspace script supports OpenClaw repository automation.
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,13 @@
// Common helper supports OpenClaw script workflows.
export * from "./filesystem.ts";
export * from "./env-limits.ts";
export * from "./host-command.ts";
export * from "./host-server.ts";
export * from "./lane-runner.ts";
export * from "./macos-users.ts";
export * from "./package-artifact.ts";
export * from "./parallels-vm.ts";
export * from "./plugin-isolation.ts";
export * from "./provider-auth.ts";
export * from "./snapshots.ts";
export * from "./types.ts";

View File

@@ -0,0 +1,32 @@
// Env Limits script supports OpenClaw repository automation.
import { die } from "./host-command.ts";
const positiveIntPattern = /^[1-9]\d*$/u;
export function parsePositiveInt(value: string, label: string): number {
const trimmed = value.trim();
if (!positiveIntPattern.test(trimmed)) {
die(`invalid ${label}: ${value}`);
}
const parsed = Number(trimmed);
if (!Number.isSafeInteger(parsed)) {
die(`invalid ${label}: ${value}`);
}
return parsed;
}
export function parseTcpPort(value: string, label: string): number {
const parsed = parsePositiveInt(value, label);
if (parsed > 65_535) {
die(`invalid ${label}: ${value}`);
}
return parsed;
}
export function readPositiveIntEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (raw == null || raw.trim() === "") {
return fallback;
}
return parsePositiveInt(raw, name);
}

View File

@@ -0,0 +1,92 @@
// Filesystem script supports OpenClaw repository automation.
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { access, mkdir, open, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { repoRoot } from "./host-command.ts";
const DEFAULT_TEXT_FILE_TAIL_BYTES = 4 * 1024 * 1024;
const OPENCLAW_VERSION_PATTERN = /OpenClaw\s+([0-9][^\s]*)/gi;
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 readTextFileTail(
filePath: string,
maxBytes = DEFAULT_TEXT_FILE_TAIL_BYTES,
): Promise<string> {
const file = await open(filePath, "r").catch(() => null);
if (!file) {
return "";
}
try {
const stats = await file.stat();
const start = Math.max(0, stats.size - maxBytes);
const length = stats.size - start;
if (length <= 0) {
return "";
}
const buffer = Buffer.alloc(length);
const { bytesRead } = await file.read(buffer, 0, length, start);
return buffer.subarray(0, bytesRead).toString("utf8");
} finally {
await file.close();
}
}
export async function extractLastOpenClawVersionFromLog(
logPath: string,
pattern = OPENCLAW_VERSION_PATTERN,
maxBytes = DEFAULT_TEXT_FILE_TAIL_BYTES,
): Promise<string> {
const text = await readTextFileTail(logPath, maxBytes);
const flags = pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`;
const globalPattern = new RegExp(pattern.source, flags);
return [...text.matchAll(globalPattern)].at(-1)?.[1] ?? "";
}
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> {
const root =
process.env.OPENCLAW_PARALLELS_ARTIFACT_ROOT || path.join(repoRoot, ".artifacts", "parallels");
mkdirSync(root, { recursive: true });
return mkdtempSync(path.join(root, prefix));
}
export async function writeSummaryMarkdown(input: {
summaryPath: string;
title: string;
lines: string[];
}): Promise<string> {
const markdownPath = path.join(path.dirname(input.summaryPath), "summary.md");
await writeFile(
markdownPath,
[
`# ${input.title}`,
"",
...input.lines,
"",
`JSON: ${path.basename(input.summaryPath)}`,
"",
].join("\n"),
"utf8",
);
return markdownPath;
}
export function writeExecutable(filePath: string, content: string): void {
writeFileSync(filePath, content, { encoding: "utf8", mode: 0o755 });
}

View File

@@ -0,0 +1,614 @@
// Guest Transports script supports OpenClaw repository automation.
import { randomUUID } from "node:crypto";
import { sleep } from "../../lib/sleep.mjs";
import { run } from "./host-command.ts";
import type { PhaseRunner } from "./phase-runner.ts";
import { encodePowerShell, psSingleQuote } from "./powershell.ts";
import type { CommandResult } from "./types.ts";
export interface GuestExecOptions {
check?: boolean;
input?: string;
timeoutMs?: number;
}
export interface WindowsBackgroundPowerShellOptions {
append?: (chunk: string | Uint8Array) => void;
beforeLaunchAttempt?: () => void;
completedLogDrainGraceMs?: number;
label: string;
onLaunchRetry?: (message: string) => void;
pollIntervalMs?: number;
runCommand?: typeof run;
script: string;
timeoutMs: number;
vmName: string;
}
function guestScriptName(extension: string): string {
return `openclaw-parallels-${randomUUID()}.${extension}`;
}
function appendOutput(
append: ((chunk: string | Uint8Array) => void) | undefined,
result: CommandResult,
): void {
if (result.stdout) {
append?.(result.stdout);
}
if (result.stderr) {
append?.(result.stderr);
}
}
function timeoutBefore(deadline: number, fallbackMs: number): number {
return Math.min(fallbackMs, Math.max(1_000, deadline - Date.now()));
}
function throwIfFailed(label: string, result: CommandResult, check: boolean | undefined): void {
if (check === false || result.status === 0) {
return;
}
throw new Error(`${label} failed with exit code ${result.status}`);
}
const POSIX_GUEST_SCRIPT_CLEANUP_TIMEOUT_MS = 30_000;
const WINDOWS_BACKGROUND_LOG_MAX_BYTES = 8 * 1024 * 1024;
function appendCommandResult(phases: PhaseRunner, result: CommandResult): void {
phases.append(result.stdout);
phases.append(result.stderr);
}
function cleanupPosixGuestScript(phases: PhaseRunner, transportArgs: string[]): void {
try {
appendCommandResult(
phases,
run("prlctl", transportArgs, {
check: false,
quiet: true,
timeoutMs: POSIX_GUEST_SCRIPT_CLEANUP_TIMEOUT_MS,
}),
);
} catch {
// Cleanup must not hide the command failure that made the phase useful.
}
}
export async function runWindowsBackgroundPowerShell(
options: WindowsBackgroundPowerShellOptions,
): Promise<void> {
const append = options.append;
const completedLogDrainGraceMs = Math.max(
1,
Math.floor(options.completedLogDrainGraceMs ?? 30_000),
);
const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 5_000));
const runCommand = options.runCommand ?? run;
const safeLabel = options.label.replaceAll(/[^A-Za-z0-9_-]/g, "-");
const nonce = `${safeLabel}-${randomUUID()}`;
const guestRunDir = `openclaw-parallels\\${nonce}`;
const windowsDonePath = `%WINDIR%\\Temp\\${guestRunDir}\\done`;
const windowsLogPath = `%WINDIR%\\Temp\\${guestRunDir}\\run.log`;
const backgroundExitPrefix = `__OPENCLAW_BACKGROUND_EXIT__:${nonce}:`;
const backgroundDoneMarker = `__OPENCLAW_BACKGROUND_DONE__:${nonce}`;
const deadline = Date.now() + options.timeoutMs;
const pathsScript = `$runDir = Join-Path (Join-Path $env:WINDIR 'Temp\\openclaw-parallels') ${psSingleQuote(nonce)}
$scriptPath = Join-Path $runDir 'run.ps1'
$logPath = Join-Path $runDir 'run.log'
$donePath = Join-Path $runDir 'done'
$exitPath = Join-Path $runDir 'exit'
$pidPath = Join-Path $runDir 'pid'
function Write-OpenClawUtf8File([string]$Path, [string]$Value) {
[System.IO.File]::WriteAllText($Path, $Value, [System.Text.UTF8Encoding]::new($false))
}`;
const payload = `$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
${pathsScript}
Write-OpenClawUtf8File $pidPath ([string]$PID)
$script:OpenClawBackgroundLogBytes = 0
function Add-OpenClawBackgroundLog {
param([Parameter(ValueFromPipeline=$true)]$InputObject)
process {
$text = $InputObject | Out-String
$bytes = [System.Text.Encoding]::UTF8.GetBytes($text)
$remaining = [int64]${WINDOWS_BACKGROUND_LOG_MAX_BYTES} - $script:OpenClawBackgroundLogBytes
if ($remaining -le 0) {
return
}
$count = [int][Math]::Min($remaining, $bytes.Length)
$needsBoundaryNewline = $count -eq $remaining -and $count -gt 0 -and $bytes[$count - 1] -ne 10
if ($needsBoundaryNewline) {
$count--
}
$stream = [System.IO.File]::Open($logPath, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::ReadWrite)
try {
if ($count -gt 0) {
$stream.Write($bytes, 0, $count)
$script:OpenClawBackgroundLogBytes += $count
}
if ($needsBoundaryNewline) {
$stream.WriteByte(10)
$script:OpenClawBackgroundLogBytes++
}
} finally {
$stream.Dispose()
}
}
}
try {
& {
${options.script}
} *>&1 | Add-OpenClawBackgroundLog
Write-OpenClawUtf8File $exitPath '0'
} catch {
$_ | Add-OpenClawBackgroundLog
Write-OpenClawUtf8File $exitPath '1'
} finally {
Write-OpenClawUtf8File $donePath 'done'
}`;
const writeArgs = [
"exec",
options.vmName,
"--current-user",
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
New-Item -ItemType Directory -Path $runDir -Force | Out-Null
& icacls.exe $runDir /inheritance:r /grant:r "\${env:USERNAME}:(OI)(CI)(F)" "SYSTEM:(OI)(CI)(F)" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "${safeLabel} background directory ACL setup failed" }
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue
[System.IO.File]::WriteAllText($scriptPath, [Console]::In.ReadToEnd(), [System.Text.UTF8Encoding]::new($false))
if (!(Test-Path $scriptPath)) { throw "${safeLabel} background script was not written" }`),
];
let writeScript = runCommand("prlctl", writeArgs, {
check: false,
input: payload,
timeoutMs: timeoutBefore(deadline, 120_000),
});
appendOutput(append, writeScript);
if (writeScript.status === 255) {
options.onLaunchRetry?.(
`${options.label} background script write retry after guest transport rc255`,
);
options.beforeLaunchAttempt?.();
writeScript = runCommand("prlctl", writeArgs, {
check: false,
input: payload,
timeoutMs: timeoutBefore(deadline, 120_000),
});
appendOutput(append, writeScript);
}
if (writeScript.status !== 0) {
throw new Error(
`${options.label} background script write failed with exit code ${writeScript.status}`,
);
}
let doneSeen = false;
try {
let launched = false;
let lastLaunchStatus = 0;
for (let attempt = 1; attempt <= 5 && Date.now() < deadline; attempt++) {
options.beforeLaunchAttempt?.();
const launch = runCommand(
"prlctl",
[
"exec",
options.vmName,
"--current-user",
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
cmd.exe /d /s /c start "" /b powershell.exe -NoProfile -ExecutionPolicy Bypass -File "$scriptPath" | Out-Null
'started'`),
],
// A busy Windows guest can leave one Parallels Tools session wedged.
// Keep polls short so a single transport cancellation cannot consume
// the entire install timeout while the detached process continues.
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 8_000) },
);
appendOutput(append, launch);
if (launch.status === 0 && launch.stdout.includes("started")) {
launched = true;
break;
}
lastLaunchStatus = launch.status;
if (launch.status === 0 || launch.status === 124) {
const materialized = await waitForWindowsBackgroundMaterialized({
append,
deadline,
pathsScript,
pollIntervalMs,
runCommand,
vmName: options.vmName,
});
if (materialized) {
launched = true;
break;
}
options.onLaunchRetry?.(
`${options.label} launch retry ${attempt}: background log/done file did not materialize`,
);
continue;
}
if (launch.stdout.includes("restoring") || launch.stderr.includes("restoring")) {
options.onLaunchRetry?.(`${options.label} launch retry ${attempt}: VM is still restoring`);
await sleep(5_000);
continue;
}
throw new Error(`${options.label} background launch failed with exit code ${launch.status}`);
}
if (!launched) {
throw new Error(
`${options.label} background launch failed with exit code ${lastLaunchStatus}`,
);
}
let completedLogDrainDeadline = 0;
let doneFileSeen = false;
const activeDeadline = () => (doneFileSeen ? completedLogDrainDeadline : deadline);
while (Date.now() < activeDeadline()) {
const doneProbe = runCommand(
"prlctl",
[
"exec",
options.vmName,
"cmd.exe",
"/d",
"/s",
"/c",
`if exist "${windowsDonePath}" (echo done) else (echo wait)`,
],
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 5_000) },
);
appendOutput(append, doneProbe);
if (doneProbe.stdout.split(/\r?\n/u).some((line) => line.trim() === "done")) {
doneFileSeen = true;
completedLogDrainDeadline ||= Date.now() + completedLogDrainGraceMs;
} else {
await sleep(pollIntervalMs);
continue;
}
const poll = runCommand(
"prlctl",
[
"exec",
options.vmName,
"cmd.exe",
"/d",
"/s",
"/c",
`if exist "${windowsDonePath}" (type "%WINDIR%\\Temp\\${guestRunDir}\\run.log" & for /f "usebackq delims=" %A in ("%WINDIR%\\Temp\\${guestRunDir}\\exit") do @echo ${backgroundExitPrefix}%A & echo ${backgroundDoneMarker}) else (echo wait)`,
],
{ check: false, quiet: true, timeoutMs: timeoutBefore(activeDeadline(), 30_000) },
);
appendOutput(append, poll);
if (hasControlLine(poll.stdout, backgroundDoneMarker)) {
doneSeen = true;
const backgroundExit = findControlValue(poll.stdout, backgroundExitPrefix) ?? "0";
if (backgroundExit !== "0" || (poll.status !== 0 && poll.status !== 124)) {
throw new Error(`${options.label} failed`);
}
return;
}
await sleep(Math.min(pollIntervalMs, 100));
}
if (doneSeen) {
throw new Error(`${options.label} completed but log drain timed out`);
}
throw new Error(`${options.label} timed out`);
} finally {
cleanupWindowsBackground(options.vmName, pathsScript, windowsLogPath, runCommand, {
append,
captureLog: !doneSeen,
stopProcessTree: !doneSeen,
});
}
}
function findControlValue(output: string, prefix: string): string | undefined {
const line = output.split(/\r?\n/u).find((entry) => entry.startsWith(prefix));
return line?.slice(prefix.length).trim();
}
function hasControlLine(output: string, marker: string): boolean {
return output.split(/\r?\n/u).some((entry) => entry.trimEnd() === marker);
}
async function waitForWindowsBackgroundMaterialized(params: {
append?: (chunk: string | Uint8Array) => void;
deadline: number;
pathsScript: string;
pollIntervalMs: number;
runCommand: typeof run;
vmName: string;
}): Promise<boolean> {
const materializeDeadline = Math.min(Date.now() + 45_000, params.deadline);
while (Date.now() < materializeDeadline) {
const result = params.runCommand(
"prlctl",
[
"exec",
params.vmName,
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${params.pathsScript}
if ((Test-Path $pidPath) -or (Test-Path $donePath)) {
'materialized'
}`),
],
{ check: false, quiet: true, timeoutMs: timeoutBefore(materializeDeadline, 15_000) },
);
appendOutput(params.append, result);
if (result.stdout.includes("materialized")) {
return true;
}
await sleep(Math.min(params.pollIntervalMs, Math.max(1, materializeDeadline - Date.now())));
}
return false;
}
function cleanupWindowsBackground(
vmName: string,
pathsScript: string,
windowsLogPath: string,
runCommand: typeof run,
options: {
append?: (chunk: string | Uint8Array) => void;
captureLog: boolean;
stopProcessTree: boolean;
},
): void {
const stopProcessTree = options.stopProcessTree
? `function Stop-OpenClawBackgroundProcessTree([int]$ProcessId) {
Get-CimInstance Win32_Process -Filter "ParentProcessId=$ProcessId" -ErrorAction SilentlyContinue | ForEach-Object {
Stop-OpenClawBackgroundProcessTree ([int]$_.ProcessId)
}
Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
}
if (Test-Path $pidPath) {
$backgroundPid = (Get-Content -Path $pidPath -Raw).Trim()
if ($backgroundPid) {
Stop-OpenClawBackgroundProcessTree ([int]$backgroundPid)
}
}
`
: "";
runCommand(
"prlctl",
[
"exec",
vmName,
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
${stopProcessTree}`),
],
{ check: false, quiet: true, timeoutMs: 30_000 },
);
if (options.captureLog) {
const log = runCommand(
"prlctl",
[
"exec",
vmName,
"cmd.exe",
"/d",
"/s",
"/c",
`if exist "${windowsLogPath}" type "${windowsLogPath}"`,
],
{ check: false, quiet: true, timeoutMs: 30_000 },
);
appendOutput(options.append, log);
}
runCommand(
"prlctl",
[
"exec",
vmName,
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue
Remove-Item -Path $runDir -Recurse -Force -ErrorAction SilentlyContinue`),
],
{ check: false, quiet: true, timeoutMs: 30_000 },
);
}
export class LinuxGuest {
constructor(
private vmName: string,
private phases: PhaseRunner,
) {}
exec(args: string[], options: GuestExecOptions = {}): string {
const result = run("prlctl", this.transportArgs(args), {
check: false,
input: options.input,
quiet: true,
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
});
this.phases.append(result.stdout);
this.phases.append(result.stderr);
throwIfFailed("Linux guest command", result, options.check);
return result.stdout.trim();
}
private transportArgs(args: string[]): string[] {
return ["exec", this.vmName, "/usr/bin/env", "HOME=/root", "OPENCLAW_ALLOW_ROOT=1", ...args];
}
bash(script: string): string {
const scriptPath = `/tmp/${guestScriptName("sh")}`;
try {
const write = run("prlctl", this.transportArgs(["dd", `of=${scriptPath}`, "bs=1048576"]), {
check: false,
input: `umask 022\n${script}`,
quiet: true,
timeoutMs: this.phases.remainingTimeoutMs(),
});
appendCommandResult(this.phases, write);
throwIfFailed("Linux guest script write", write, undefined);
return this.exec(["bash", scriptPath]);
} finally {
cleanupPosixGuestScript(this.phases, this.transportArgs(["/bin/rm", "-f", scriptPath]));
}
}
}
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 {
return this.run(args, options).stdout.trim();
}
private transportArgs(args: string[], env: Record<string, string> = {}): string[] {
const envArgs = Object.entries({ PATH: this.input.path, ...env }).map(
([key, value]) => `${key}=${value}`,
);
const user = this.input.getUser();
return 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];
}
run(args: string[], options: MacosGuestOptions = {}): CommandResult {
const result = run("prlctl", this.transportArgs(args, options.env), {
check: false,
input: options.input,
quiet: true,
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
});
this.phases.append(result.stdout);
this.phases.append(result.stderr);
throwIfFailed("macOS guest command", result, options.check);
return result;
}
sh(script: string, env: Record<string, string> = {}): string {
const scriptPath = `/tmp/${guestScriptName("sh")}`;
try {
this.exec(["/bin/dd", `of=${scriptPath}`, "bs=1048576"], {
input: `umask 022\n${script}`,
});
return this.exec(["/bin/bash", scriptPath], { env });
} finally {
cleanupPosixGuestScript(this.phases, this.transportArgs(["/bin/rm", "-f", scriptPath]));
}
}
}
export class WindowsGuest {
constructor(
private vmName: string,
private phases: PhaseRunner,
) {}
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: false,
input: options.input,
quiet: true,
timeoutMs: this.phases.remainingTimeoutMs(options.timeoutMs),
});
this.phases.append(result.stdout);
this.phases.append(result.stderr);
throwIfFailed("Windows guest command", result, options.check);
return result;
}
powershell(script: string, options: GuestExecOptions = {}): string {
const scriptName = guestScriptName("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(writeScript),
],
{
input: script,
quiet: true,
timeoutMs: this.phases.remainingTimeoutMs(120_000),
},
);
this.phases.append(write.stdout);
this.phases.append(write.stderr);
const scriptPath = `%TEMP%\\${scriptName}`;
try {
return this.exec(
[
"cmd.exe",
"/d",
"/s",
"/c",
`powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${scriptPath}"`,
],
options,
);
} finally {
this.exec(["cmd.exe", "/d", "/s", "/c", `del /F /Q "${scriptPath}"`], {
check: false,
timeoutMs: 30_000,
});
}
}
}

View File

@@ -0,0 +1,783 @@
// Host Command script supports OpenClaw repository automation.
import { spawn, spawnSync, type SpawnOptions, type SpawnSyncReturns } from "node:child_process";
import { createWriteStream } from "node:fs";
import path from "node:path";
import { finished } from "node:stream/promises";
import { fileURLToPath } from "node:url";
import {
addTimerTimeoutGraceMs,
clampTimerTimeoutMs,
} from "@openclaw/normalization-core/number-coercion";
import { resolveNpmRunner } from "../../npm-runner.mjs";
import { resolvePnpmRunner } from "../../pnpm-runner.mjs";
import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "../../windows-cmd-helpers.mjs";
import type { CommandResult, RunOptions } from "./types.ts";
export const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../..");
const HOST_COMMAND_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
const HOST_COMMAND_WRAPPER_EXTRA_BUFFER_BYTES = 1024 * 1024;
const HOST_COMMAND_WRAPPER_BACKSTOP_MS = 5_000;
const HOST_COMMAND_TIMEOUT_KILL_GRACE_MS = 100;
const HOST_COMMAND_STREAMING_TIMEOUT_KILL_GRACE_MS = 2_000;
const HOST_COMMAND_PROCESS_GROUP_EXIT_POLL_MS = 25;
const HOST_COMMAND_POST_FORCE_KILL_WAIT_MS = 100;
const HOST_COMMAND_CHILD_PID_PREFIX = "__OPENCLAW_HOST_COMMAND_CHILD_PID__";
const HOST_COMMAND_SPAWN_ERROR_PREFIX = "__OPENCLAW_HOST_COMMAND_SPAWN_ERROR__";
const HOST_COMMAND_TIMEOUT_PREFIX = "__OPENCLAW_HOST_COMMAND_TIMEOUT__";
let progressStderrDepth = 0;
type HostCommandInvocation = {
args: string[];
command: string;
env?: NodeJS.ProcessEnv;
shell?: boolean;
windowsVerbatimArguments?: boolean;
};
type ResolveHostCommandOptions = {
comSpec?: string;
env?: NodeJS.ProcessEnv;
execPath?: string;
existsSync?: (path: string) => boolean;
platform?: NodeJS.Platform;
};
function hostInvocationFromRunner(runner: HostCommandInvocation): HostCommandInvocation {
if (runner.env === undefined) {
const invocation = { ...runner };
delete invocation.env;
return invocation;
}
return runner;
}
export function say(message: string): void {
const stream = progressStderrDepth > 0 ? process.stderr : process.stdout;
stream.write(`==> ${message}\n`);
}
export function warn(message: string): void {
process.stderr.write(`warn: ${message}\n`);
}
export async function withProgressOnStderr<T>(fn: () => Promise<T>): Promise<T> {
progressStderrDepth++;
try {
return await fn();
} finally {
progressStderrDepth--;
}
}
export function die(message: string): never {
process.stderr.write(`error: ${message}\n`);
process.exit(1);
}
function signalHostCommandProcess(pid: number | undefined, signal: NodeJS.Signals): void {
if (!pid) {
return;
}
try {
if (process.platform === "win32") {
process.kill(pid, signal);
} else {
process.kill(-pid, signal);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ESRCH") {
warn(
`failed to send ${signal} to timed host command process ${pid}: ${code ?? String(error)}`,
);
}
}
}
const POSIX_TIMEOUT_WRAPPER = String.raw`
const { spawn } = require("node:child_process");
const { readFileSync, writeSync } = require("node:fs");
const payload = JSON.parse(readFileSync(0, "utf8"));
const child = spawn(payload.command, payload.args, {
cwd: payload.cwd,
detached: true,
env: payload.env,
shell: payload.shell,
stdio: ["pipe", "pipe", "pipe"],
});
writeSync(
3,
${JSON.stringify(HOST_COMMAND_CHILD_PID_PREFIX)} + JSON.stringify({
pid: child.pid || null,
}) + "\n",
);
let timedOut = false;
let killTimer;
let killDeadlineAt = 0;
let outputExceeded = false;
let forwardedSignal;
let forwardedSignalKillTimer;
let forwardedSignalPostForceTimer;
let stderrBytes = 0;
let stdoutBytes = 0;
function writeAllSync(fd, chunk) {
let offset = 0;
while (offset < chunk.byteLength) {
offset += writeSync(fd, chunk, offset, chunk.byteLength - offset);
}
}
function signalGroup(signal) {
if (!child.pid) {
return;
}
try {
process.kill(-child.pid, signal);
} catch (error) {
if (error && error.code !== "ESRCH") {
process.stderr.write("failed to send " + signal + " to timed host command process " + child.pid + ": " + (error.code || String(error)) + "\n");
}
}
}
function groupAlive() {
if (!child.pid) {
return false;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
return Boolean(error && error.code === "EPERM");
}
}
function finishTimedOut() {
if (killTimer) {
clearTimeout(killTimer);
}
writeSync(3, ${JSON.stringify(HOST_COMMAND_TIMEOUT_PREFIX)} + "{}\n");
process.exit(124);
}
function finishTimedOutAfterCleanup() {
if (!groupAlive()) {
finishTimedOut();
return;
}
const pollMs = Math.max(1, Math.min(25, payload.timeoutKillGraceMs));
let pollTimer;
let forceFinishTimer;
let postForceFinishTimer;
const finish = () => {
if (pollTimer) {
clearInterval(pollTimer);
}
if (forceFinishTimer) {
clearTimeout(forceFinishTimer);
}
if (postForceFinishTimer) {
clearTimeout(postForceFinishTimer);
}
finishTimedOut();
};
pollTimer = setInterval(() => {
if (!groupAlive()) {
finish();
}
}, pollMs);
forceFinishTimer = setTimeout(() => {
signalGroup("SIGKILL");
postForceFinishTimer = setTimeout(finish, pollMs);
}, Math.max(0, killDeadlineAt - Date.now()));
}
function finishForwardedSignal() {
if (!forwardedSignal) {
return;
}
if (forwardedSignalKillTimer) {
clearTimeout(forwardedSignalKillTimer);
}
if (forwardedSignalPostForceTimer) {
clearTimeout(forwardedSignalPostForceTimer);
}
process.kill(process.pid, forwardedSignal);
}
function finishForwardedSignalAfterCleanup() {
if (!forwardedSignal) {
return;
}
if (!groupAlive()) {
finishForwardedSignal();
return;
}
if (forwardedSignalKillTimer) {
return;
}
forwardedSignalKillTimer = setTimeout(() => {
if (groupAlive()) {
signalGroup("SIGKILL");
forwardedSignalPostForceTimer = setTimeout(
finishForwardedSignal,
Math.max(1, Math.min(25, payload.timeoutKillGraceMs)),
);
} else {
finishForwardedSignal();
}
}, payload.timeoutKillGraceMs);
}
function forwardBounded(stream, chunk) {
const currentBytes = stream === "stdout" ? stdoutBytes : stderrBytes;
const nextBytes = currentBytes + chunk.byteLength;
const limit = payload.maxBufferBytes;
if (stream === "stdout") {
stdoutBytes = nextBytes;
} else {
stderrBytes = nextBytes;
}
if (outputExceeded) {
return;
}
if (nextBytes <= limit) {
writeAllSync(stream === "stdout" ? 1 : 2, chunk);
return;
}
outputExceeded = true;
const allowedBytes = Math.max(0, limit - currentBytes);
if (allowedBytes > 0) {
writeAllSync(stream === "stdout" ? 1 : 2, chunk.subarray(0, allowedBytes));
}
writeAllSync(
2,
Buffer.from("host command output exceeded " + limit + " bytes; terminating process group\n"),
);
signalGroup("SIGKILL");
}
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
process.once(signal, () => {
forwardedSignal ||= signal;
signalGroup(signal);
finishForwardedSignalAfterCleanup();
});
}
const timeout = setTimeout(() => {
timedOut = true;
signalGroup("SIGTERM");
killDeadlineAt = Date.now() + payload.timeoutKillGraceMs;
killTimer = setTimeout(() => signalGroup("SIGKILL"), payload.timeoutKillGraceMs);
killTimer.unref();
}, payload.timeoutMs);
timeout.unref();
child.stdout.on("data", (chunk) => forwardBounded("stdout", chunk));
child.stderr.on("data", (chunk) => forwardBounded("stderr", chunk));
child.stdin.on("error", (error) => {
if (error && error.code !== "EPIPE" && error.code !== "ECONNRESET") {
writeAllSync(2, Buffer.from("host command stdin write failed: " + (error.code || String(error)) + "\n"));
}
});
child.on("error", (error) => {
clearTimeout(timeout);
if (killTimer) {
clearTimeout(killTimer);
}
writeSync(
3,
${JSON.stringify(HOST_COMMAND_SPAWN_ERROR_PREFIX)} + JSON.stringify({
code: error.code || null,
message: error.message,
}) + "\n",
);
process.stderr.write(error.message + "\n");
process.exit(127);
});
child.on("close", (code, signal) => {
clearTimeout(timeout);
if (forwardedSignal) {
finishForwardedSignalAfterCleanup();
return;
}
if (timedOut) {
finishTimedOutAfterCleanup();
return;
}
if (killTimer) {
clearTimeout(killTimer);
}
if (outputExceeded) {
process.exit(1);
}
process.exit(code ?? (signal ? 128 : 1));
});
if (payload.input != null) {
child.stdin.end(payload.input);
} else {
child.stdin.end();
}
`;
export function shellQuote(value: string): string {
return `'${value.replaceAll("'", `'"'"'`)}'`;
}
function portableBasename(value: string): string {
return value.split(/[/\\]/u).at(-1) ?? value;
}
function portableExtension(value: string): string {
return path.posix.extname(portableBasename(value)).toLowerCase();
}
function isBareCommand(command: string, name: "npm" | "pnpm"): boolean {
return portableBasename(command) === command && command.toLowerCase() === name;
}
function resolveHostCommandTimeoutMs(timeoutMs: number): number {
return clampTimerTimeoutMs(timeoutMs) ?? 1;
}
function resolveOptionalHostCommandTimeoutMs(timeoutMs: number | undefined): number | undefined {
return timeoutMs === undefined ? undefined : resolveHostCommandTimeoutMs(timeoutMs);
}
export function resolveHostCommandInvocation(
command: string,
args: string[],
options: ResolveHostCommandOptions = {},
): HostCommandInvocation {
const env = options.env ?? process.env;
const platform = options.platform ?? process.platform;
const comSpec = options.comSpec ?? resolveWindowsCmdExePath(env);
if (isBareCommand(command, "pnpm")) {
const runner = resolvePnpmRunner({
comSpec,
env,
npmExecPath: env.npm_execpath,
nodeExecPath: options.execPath ?? process.execPath,
platform,
pnpmArgs: args,
});
return hostInvocationFromRunner(runner);
}
if (isBareCommand(command, "npm")) {
const runner = resolveNpmRunner({
comSpec,
env,
execPath: options.execPath ?? process.execPath,
existsSync: options.existsSync,
npmArgs: args,
platform,
});
return hostInvocationFromRunner(runner);
}
const extension = portableExtension(command);
if (platform === "win32" && (extension === ".cmd" || extension === ".bat")) {
return {
args: ["/d", "/s", "/c", buildCmdExeCommandLine(command, args)],
command: comSpec,
shell: false,
windowsVerbatimArguments: true,
};
}
return { args, command, shell: false };
}
export function run(command: string, args: string[], options: RunOptions = {}): CommandResult {
const env = { ...process.env, ...options.env };
const invocation = resolveHostCommandInvocation(command, args, { env });
const timeoutMs = resolveOptionalHostCommandTimeoutMs(options.timeoutMs);
const usesPosixTimedWrapper = process.platform !== "win32" && timeoutMs !== undefined;
const result = usesPosixTimedWrapper
? runPosixTimedCommandSync(invocation, env, options, timeoutMs)
: spawnSync(invocation.command, invocation.args, {
cwd: options.cwd ?? repoRoot,
encoding: "utf8",
env: invocation.env ?? env,
input: options.input,
killSignal: "SIGKILL",
maxBuffer: HOST_COMMAND_MAX_BUFFER_BYTES,
stdio: options.quiet ? ["pipe", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
shell: invocation.shell,
timeout: timeoutMs,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
let wrapperTimedOut = false;
if (usesPosixTimedWrapper) {
const wrapperControl = typeof result.output[3] === "string" ? result.output[3] : "";
const outerWrapperTimedOut =
(result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";
if (outerWrapperTimedOut) {
signalHostCommandProcess(parsePosixTimedWrapperChildPid(wrapperControl), "SIGKILL");
}
wrapperTimedOut = outerWrapperTimedOut || hasPosixTimedWrapperTimeout(wrapperControl);
const spawnError = parsePosixTimedWrapperSpawnError(wrapperControl);
if (spawnError) {
throw spawnError;
}
}
const timedOut =
wrapperTimedOut || (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT";
if (wrapperTimedOut && options.check !== false) {
const error = new Error(
`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`,
) as NodeJS.ErrnoException;
error.code = "ETIMEDOUT";
throw error;
}
if (result.error && !(timedOut && options.check === false)) {
throw result.error;
}
const status = timedOut ? 124 : (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;
}
function hasPosixTimedWrapperTimeout(controlOutput: string): boolean {
return controlOutput.split("\n").some((entry) => entry.startsWith(HOST_COMMAND_TIMEOUT_PREFIX));
}
function parsePosixTimedWrapperChildPid(controlOutput: string): number | undefined {
const line = controlOutput
.split("\n")
.find((entry) => entry.startsWith(HOST_COMMAND_CHILD_PID_PREFIX));
if (!line) {
return undefined;
}
try {
const parsed = JSON.parse(line.slice(HOST_COMMAND_CHILD_PID_PREFIX.length)) as {
pid?: unknown;
};
return typeof parsed.pid === "number" ? parsed.pid : undefined;
} catch {
return undefined;
}
}
function parsePosixTimedWrapperSpawnError(stderr: string): NodeJS.ErrnoException | null {
const line = stderr
.split("\n")
.find((entry) => entry.startsWith(HOST_COMMAND_SPAWN_ERROR_PREFIX));
if (!line) {
return null;
}
const raw = line.slice(HOST_COMMAND_SPAWN_ERROR_PREFIX.length);
try {
const parsed = JSON.parse(raw) as { code?: unknown; message?: unknown };
const error = new Error(
typeof parsed.message === "string" ? parsed.message : "host command spawn failed",
) as NodeJS.ErrnoException;
if (typeof parsed.code === "string") {
error.code = parsed.code;
}
return error;
} catch {
return new Error("host command spawn failed") as NodeJS.ErrnoException;
}
}
function runPosixTimedCommandSync(
invocation: HostCommandInvocation,
env: NodeJS.ProcessEnv,
options: RunOptions,
timeoutMs: number,
): SpawnSyncReturns<string> {
const wrapperTimeoutMs = addTimerTimeoutGraceMs(timeoutMs, HOST_COMMAND_WRAPPER_BACKSTOP_MS) ?? 1;
const payload = JSON.stringify({
args: invocation.args,
command: invocation.command,
cwd: options.cwd ?? repoRoot,
env: invocation.env ?? env,
input: options.input,
maxBufferBytes: HOST_COMMAND_MAX_BUFFER_BYTES,
shell: invocation.shell,
timeoutKillGraceMs: HOST_COMMAND_TIMEOUT_KILL_GRACE_MS,
timeoutMs,
});
return spawnSync(process.execPath, ["-e", POSIX_TIMEOUT_WRAPPER], {
cwd: options.cwd ?? repoRoot,
encoding: "utf8",
env,
input: payload,
killSignal: "SIGKILL",
maxBuffer: HOST_COMMAND_MAX_BUFFER_BYTES * 2 + HOST_COMMAND_WRAPPER_EXTRA_BUFFER_BYTES,
stdio: ["pipe", "pipe", "pipe", "pipe"],
timeout: wrapperTimeoutMs,
});
}
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 env = { ...process.env, ...options.env };
const invocation = resolveHostCommandInvocation(command, args, { env });
const timeoutMs = resolveOptionalHostCommandTimeoutMs(options.timeoutMs);
const logStream = options.logPath
? createWriteStream(options.logPath, { encoding: "utf8", flags: "w" })
: undefined;
let logStreamError: Error | undefined;
const detached = process.platform !== "win32" && timeoutMs !== undefined;
const child = spawn(invocation.command, invocation.args, {
cwd: options.cwd ?? repoRoot,
detached,
env: invocation.env ?? env,
shell: invocation.shell,
stdio: ["pipe", "pipe", "pipe"],
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
} satisfies SpawnOptions);
const childPid = child.pid;
const signalStreamingChild = (signal: NodeJS.Signals): void => {
if (detached) {
signalHostCommandProcess(childPid, signal);
return;
}
try {
child.kill(signal);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ESRCH") {
warn(`failed to send ${signal} to host command process: ${code ?? String(error)}`);
}
}
};
const streamingProcessGroupAlive = (): boolean => {
if (!detached || !childPid) {
return false;
}
try {
process.kill(-childPid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
};
const waitForStreamingProcessGroupExit = async (timeoutBudgetMs: number): Promise<boolean> => {
const deadlineAt = Date.now() + timeoutBudgetMs;
while (Date.now() < deadlineAt) {
if (!streamingProcessGroupAlive()) {
return true;
}
await new Promise((resolvePoll) => {
setTimeout(resolvePoll, HOST_COMMAND_PROCESS_GROUP_EXIT_POLL_MS);
});
}
return !streamingProcessGroupAlive();
};
logStream?.on("error", (error) => {
logStreamError = error;
signalStreamingChild("SIGTERM");
});
const parentSignalHandlers = new Map<NodeJS.Signals, () => void>();
let forwardedParentSignal: NodeJS.Signals | undefined;
let parentSignalKillTimer: NodeJS.Timeout | undefined;
let parentSignalPostForceTimer: NodeJS.Timeout | undefined;
const removeParentSignalHandlers = (): void => {
for (const [signal, handler] of parentSignalHandlers) {
process.off(signal, handler);
}
parentSignalHandlers.clear();
};
const clearParentSignalTimers = (): void => {
if (parentSignalKillTimer) {
clearTimeout(parentSignalKillTimer);
parentSignalKillTimer = undefined;
}
if (parentSignalPostForceTimer) {
clearTimeout(parentSignalPostForceTimer);
parentSignalPostForceTimer = undefined;
}
};
const finishParentSignal = (): void => {
if (!forwardedParentSignal) {
return;
}
clearParentSignalTimers();
removeParentSignalHandlers();
process.kill(process.pid, forwardedParentSignal);
};
const finishParentSignalAfterCleanup = (): void => {
if (!forwardedParentSignal) {
return;
}
if (!streamingProcessGroupAlive()) {
finishParentSignal();
return;
}
if (parentSignalKillTimer) {
return;
}
parentSignalKillTimer = setTimeout(() => {
if (streamingProcessGroupAlive()) {
signalHostCommandProcess(childPid, "SIGKILL");
parentSignalPostForceTimer = setTimeout(
finishParentSignal,
HOST_COMMAND_POST_FORCE_KILL_WAIT_MS,
);
} else {
finishParentSignal();
}
}, HOST_COMMAND_TIMEOUT_KILL_GRACE_MS);
};
if (process.platform !== "win32" && timeoutMs !== undefined) {
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) {
const handler = (): void => {
forwardedParentSignal ??= signal;
signalHostCommandProcess(childPid, signal);
removeParentSignalHandlers();
finishParentSignalAfterCleanup();
};
parentSignalHandlers.set(signal, handler);
process.once(signal, handler);
}
}
const writeLogChunk = (chunk: Buffer): void => {
if (!logStream || logStream.destroyed) {
return;
}
if (!logStream.write(chunk)) {
child.stdout?.pause();
child.stderr?.pause();
logStream.once("drain", () => {
child.stdout?.resume();
child.stderr?.resume();
});
}
};
const append = (chunk: Buffer): void => {
const text = chunk.toString("utf8");
writeLogChunk(chunk);
if (!options.quiet) {
process.stdout.write(text);
}
};
child.stdout?.on("data", append);
child.stderr?.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8");
writeLogChunk(chunk);
if (!options.quiet) {
process.stderr.write(text);
}
});
if (options.input != null) {
child.stdin?.end(options.input);
} else {
child.stdin?.end();
}
let timedOut = false;
let killTimer: NodeJS.Timeout | undefined;
let killDeadlineAt = 0;
const waitForStreamingTimeoutCleanup = async (): Promise<void> => {
if (!detached) {
signalStreamingChild("SIGKILL");
return;
}
const remainingGraceMs = Math.max(0, killDeadlineAt - Date.now());
if (remainingGraceMs > 0) {
await waitForStreamingProcessGroupExit(remainingGraceMs);
}
if (streamingProcessGroupAlive()) {
signalStreamingChild("SIGKILL");
await waitForStreamingProcessGroupExit(HOST_COMMAND_POST_FORCE_KILL_WAIT_MS);
}
};
const timer =
timeoutMs === undefined
? undefined
: setTimeout(() => {
timedOut = true;
signalHostCommandProcess(childPid, "SIGTERM");
killDeadlineAt = Date.now() + HOST_COMMAND_STREAMING_TIMEOUT_KILL_GRACE_MS;
killTimer = setTimeout(
() => signalHostCommandProcess(childPid, "SIGKILL"),
HOST_COMMAND_STREAMING_TIMEOUT_KILL_GRACE_MS,
);
killTimer.unref();
}, timeoutMs);
child.on("error", (error) => {
if (timer) {
clearTimeout(timer);
}
if (killTimer) {
clearTimeout(killTimer);
}
clearParentSignalTimers();
removeParentSignalHandlers();
logStream?.destroy();
reject(error);
});
child.on("close", (code, signal) => {
void (async () => {
if (timer) {
clearTimeout(timer);
}
if (forwardedParentSignal) {
finishParentSignalAfterCleanup();
return;
}
removeParentSignalHandlers();
if (timedOut) {
await waitForStreamingTimeoutCleanup();
}
if (killTimer) {
clearTimeout(killTimer);
}
clearParentSignalTimers();
if (logStream) {
logStream.end();
await finished(logStream);
}
if (logStreamError) {
throw logStreamError;
}
if (timedOut) {
resolve(124);
} else {
resolve(code ?? (signal ? 128 : 1));
}
})().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
reject(
new Error(`failed to write Parallels host command log: ${message}`, { cause: error }),
);
});
});
});
}

View File

@@ -0,0 +1,202 @@
// Host Server script supports OpenClaw repository automation.
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { createServer } from "node:http";
import { createConnection } from "node:net";
import path from "node:path";
import { sleep as delay } from "../../lib/sleep.mjs";
import { die, run, say, sh, warn } from "./host-command.ts";
import type { HostServer } from "./types.ts";
const HOST_SERVER_STDERR_LIMIT_BYTES = 64 * 1024;
const HOST_SERVER_STDERR_DRAIN_MS = 5_000;
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 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 stopHostServerChild(child);
},
urlFor: (filePath) =>
`http://${input.hostIp}:${actualPort}/${encodeURIComponent(path.basename(filePath))}`,
};
}
async function stopHostServerChild(
child: ChildProcessWithoutNullStreams,
terminateTimeoutMs = 2_000,
killTimeoutMs = 1_500,
): Promise<boolean> {
if (hasHostServerChildExited(child)) {
return true;
}
child.kill("SIGTERM");
if (await waitForChildExit(child, terminateTimeoutMs)) {
return true;
}
child.kill("SIGKILL");
return await waitForChildExit(child, killTimeoutMs);
}
async function waitForChildExit(
child: ChildProcessWithoutNullStreams,
timeoutMs: number,
): Promise<boolean> {
if (hasHostServerChildExited(child)) {
return true;
}
return await new Promise<boolean>((resolve) => {
let settled = false;
const onExit = () => settle(true);
const timeout = setTimeout(() => settle(hasHostServerChildExited(child)), timeoutMs);
timeout.unref();
function settle(exited: boolean): void {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
child.off("exit", onExit);
resolve(exited);
}
child.once("exit", onExit);
});
}
function hasHostServerChildExited(child: ChildProcessWithoutNullStreams): boolean {
return child.exitCode != null || child.signalCode != null;
}
async function waitForHostServer(
child: ChildProcessWithoutNullStreams,
port: number,
): Promise<void> {
let stderr = "";
child.stderr.on("data", (chunk: Buffer) => {
stderr = appendBoundedOutput(stderr, chunk, HOST_SERVER_STDERR_LIMIT_BYTES);
});
let childClosed = false;
const childClose = new Promise<void>((resolve) => {
child.once("close", () => {
childClosed = true;
resolve();
});
});
const startedAt = Date.now();
while (Date.now() - startedAt < 10_000) {
if (hasHostServerChildExited(child)) {
if (!childClosed) {
await Promise.race([childClose, delay(HOST_SERVER_STDERR_DRAIN_MS)]);
}
die(`host artifact server exited early: ${stderr.trim() || formatHostServerExit(child)}`);
}
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()}`);
}
function appendBoundedOutput(previous: string, chunk: Buffer, limitBytes: number): string {
const combined = Buffer.concat([Buffer.from(previous, "utf8"), chunk]);
if (combined.byteLength <= limitBytes) {
return combined.toString("utf8");
}
return combined.subarray(combined.byteLength - limitBytes).toString("utf8");
}
function formatHostServerExit(child: ChildProcessWithoutNullStreams): string {
return child.signalCode ? `signal ${child.signalCode}` : `exit ${child.exitCode ?? "unknown"}`;
}
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);
});
});
}
export const testing = {
appendBoundedOutput,
stopHostServerChild,
};

View File

@@ -0,0 +1,19 @@
// Lane Runner script supports OpenClaw repository automation.
import { warn } from "./host-command.ts";
export type SmokeLane = "fresh" | "upgrade";
export type SmokeLaneStatus = "pass" | "fail";
export async function runSmokeLane(
name: SmokeLane,
fn: () => Promise<void>,
setStatus: (name: SmokeLane, status: SmokeLaneStatus) => void,
): Promise<void> {
try {
await fn();
setStatus(name, "pass");
} catch (error) {
setStatus(name, "fail");
warn(`${name} lane failed: ${error instanceof Error ? error.message : String(error)}`);
}
}

View File

@@ -0,0 +1,887 @@
#!/usr/bin/env -S pnpm tsx
// Linux Smoke script supports OpenClaw repository automation.
import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { posixAgentWorkspaceScript } from "./agent-workspace.ts";
import {
die,
ensureValue,
currentRunningSnapshotInfo,
makeTempDir,
parseBoolEnv,
parseMode,
parseTcpPort,
parseProvider,
readPositiveIntEnv,
modelProviderConfigBatchJson,
posixCodexPlatformPackageRepairFunction,
posixProviderOnlyPluginIsolationScript,
repoRoot,
resolveParallelsModelTimeoutSeconds,
resolveLatestVersion,
resolveProviderAuth,
resolveSnapshot,
run,
say,
shouldSkipSnapshotRestore,
shellQuote,
validateSnapshotRestoreMode,
warn,
withProgressOnStderr,
writeJson,
writeSummaryMarkdown,
type Mode,
type PackageArtifact,
type Provider,
type ProviderAuth,
type SnapshotInfo,
} from "./common.ts";
import { LinuxGuest } from "./guest-transports.ts";
import { ensureVmRunning, resolveUbuntuVmName } from "./parallels-vm.ts";
import { PhaseRunner } from "./phase-runner.ts";
import {
buildCommonSmokeSummary,
expectedPackageBuildCommit,
expectedPackageTargetVersion,
extractLastOpenClawVersion,
packAndServeSmokeArtifact,
printSmokeTargetSummary,
SmokeRunController,
type SmokeHostOptions,
type SmokeRunOptions,
} from "./smoke-common.ts";
// Older published baselines predate this warning, but still need update coverage.
const BAD_PLUGIN_DIAGNOSTIC_MIN_VERSION = "2026.5.7";
// Restored Ubuntu snapshots may immediately run package maintenance for hours.
// Reuse an existing downloader before touching apt, then bound the fallback.
const APT_LOCK_RETRY_SECONDS = 900;
const BOOTSTRAP_TIMEOUT_SECONDS = 1200;
function parseOpenClawPackageVersion(value: string): string | null {
return value.match(/\b(\d{4}\.\d{1,2}\.\d{1,2}(?:-[A-Za-z0-9.]+)?)\b/u)?.[1] ?? null;
}
function compareOpenClawPackageVersions(left: string, right: string): number {
const parse = (value: string): [number, number, number] => {
const match = parseOpenClawPackageVersion(value)?.match(/^(\d{4})\.(\d+)\.(\d+)/u);
if (!match) {
return [0, 0, 0];
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
};
const leftParts = parse(left);
const rightParts = parse(right);
for (let index = 0; index < leftParts.length; index++) {
const delta = leftParts[index] - rightParts[index];
if (delta !== 0) {
return delta;
}
}
return 0;
}
interface LinuxOptions extends SmokeHostOptions, SmokeRunOptions {
vmName: string;
vmNameExplicit: boolean;
apiKeyEnv?: string;
modelId?: string;
installUrl: string;
latestVersion?: string;
}
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 26.04",
vmNameExplicit: false,
});
function usage(): string {
return `Usage: bash scripts/e2e/parallels-linux-smoke.sh [options]
Options:
--vm <name> Parallels VM name. Default: "Ubuntu 26.04"
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.
`;
}
export function parseArgs(argv: string[]): LinuxOptions {
const args = stripLeadingPackageManagerSeparator(argv);
const options = defaultOptions();
parseArgv: for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
case "--":
break parseArgv;
case "--vm":
options.vmName = ensureValue(args, i, arg);
options.vmNameExplicit = true;
i++;
break;
case "--snapshot-hint":
options.snapshotHint = ensureValue(args, i, arg);
i++;
break;
case "--mode":
options.mode = parseMode(ensureValue(args, i, arg));
i++;
break;
case "--provider":
options.provider = parseProvider(ensureValue(args, i, arg));
i++;
break;
case "--model":
options.modelId = ensureValue(args, i, arg);
i++;
break;
case "--api-key-env":
case "--openai-api-key-env":
options.apiKeyEnv = ensureValue(args, i, arg);
i++;
break;
case "--install-url":
options.installUrl = ensureValue(args, i, arg);
i++;
break;
case "--host-port":
options.hostPort = parseTcpPort(ensureValue(args, i, arg), arg);
options.hostPortExplicit = true;
i++;
break;
case "--host-ip":
options.hostIp = ensureValue(args, i, arg);
i++;
break;
case "--latest-version":
options.latestVersion = ensureValue(args, i, arg);
i++;
break;
case "--install-version":
options.installVersion = ensureValue(args, i, arg);
i++;
break;
case "--target-package-spec":
options.targetPackageSpec = ensureValue(args, 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;
}
function stripLeadingPackageManagerSeparator(argv: string[]): string[] {
return argv[0] === "--" ? argv.slice(1) : argv;
}
class LinuxSmoke extends SmokeRunController<LinuxOptions> {
private auth: ProviderAuth;
private disableBonjour = parseBoolEnv(process.env.OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR);
private agentTimeoutSeconds = readPositiveIntEnv(
"OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S",
1500,
);
private artifact: PackageArtifact | null = null;
private latestVersion = "";
private snapshot!: SnapshotInfo;
private phases!: PhaseRunner;
private guest!: LinuxGuest;
protected status = {
daemon: "systemd-user-unavailable",
freshAgent: "skip",
freshGateway: "skip",
freshMain: "skip",
freshVersion: "skip",
latestInstalledVersion: "skip",
upgrade: "skip",
upgradeAgent: "skip",
upgradeGateway: "skip",
upgradeVersion: "skip",
};
constructor(options: LinuxOptions) {
super(options);
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();
validateSnapshotRestoreMode(this.options.mode, "Linux smoke");
this.snapshot = shouldSkipSnapshotRestore()
? currentRunningSnapshotInfo(this.options.vmName)
: resolveSnapshot(this.options.vmName, this.options.snapshotHint);
this.guest = new LinuxGuest(this.options.vmName, this.phases);
this.latestVersion = resolveLatestVersion(this.options.latestVersion);
await this.prepareHost(
defaultOptions().hostPort,
this.latestVersion,
this.snapshot,
this.options.vmName,
);
[this.artifact, this.server, this.hostPort] = await packAndServeSmokeArtifact(
this.tgzDir,
this.options.targetPackageSpec,
this.hostIp,
this.hostPort,
this.artifactLabel(),
);
await this.runLanesAndFinish();
} finally {
await this.cleanupArtifacts();
}
}
private artifactLabel(): string {
return this.options.targetPackageSpec ? "target package tgz" : "current main tgz";
}
private resolveVmName(): string {
return resolveUbuntuVmName(this.options.vmName, this.options.vmNameExplicit);
}
protected async runFreshLane(): Promise<void> {
await this.phase("fresh.restore-snapshot", 180, () => this.restoreSnapshot());
await this.phase("fresh.bootstrap-guest", BOOTSTRAP_TIMEOUT_SECONDS, () =>
this.bootstrapGuest(),
);
await this.phase("fresh.preflight", 90, () => this.logGuestPreflight());
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.maybeInjectBadPluginFixture("fresh"),
);
await this.phase("fresh.gateway-start", 240, () => this.startGatewayBackground());
await this.phase("fresh.bad-plugin-diagnostic", 90, () =>
this.maybeVerifyBadPluginDiagnostic("fresh"),
);
await this.phase("fresh.gateway-status", 240, () => this.verifyGatewayStatus());
this.status.freshGateway = "pass";
await this.phase("fresh.first-local-agent-turn", this.agentTimeoutSeconds, () =>
this.verifyLocalTurn(),
);
this.status.freshAgent = "pass";
}
protected async runUpgradeLane(): Promise<void> {
await this.phase("upgrade.restore-snapshot", 180, () => this.restoreSnapshot());
await this.phase("upgrade.bootstrap-guest", BOOTSTRAP_TIMEOUT_SECONDS, () =>
this.bootstrapGuest(),
);
await this.phase("upgrade.preflight", 90, () => this.logGuestPreflight());
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.maybeInjectBadPluginFixture("upgrade"),
);
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.maybeVerifyBadPluginDiagnostic("upgrade"),
);
await this.phase("upgrade.gateway-status", 240, () => this.verifyGatewayStatus());
this.status.upgradeGateway = "pass";
await this.phase("upgrade.first-local-agent-turn", this.agentTimeoutSeconds, () =>
this.verifyLocalTurn(),
);
this.status.upgradeAgent = "pass";
}
private phase = async (name: string, timeoutSeconds: number, fn: () => Promise<void> | void) =>
await this.phases.phase(name, timeoutSeconds, fn);
private remainingPhaseTimeoutMs = (fallbackMs?: number): number | undefined =>
this.phases.remainingTimeoutMs(fallbackMs);
private logGuestPreflight(): void {
this.guestBash(String.raw`set -euo pipefail
printf 'preflight.user=%s\n' "$(whoami)"
printf 'preflight.home=%s\n' "$HOME"
printf 'preflight.path=%s\n' "$PATH"
printf 'preflight.umask=%s\n' "$(umask)"
printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
}
private log = (text: string): void => this.phases.append(text);
private guestExec = (
args: string[],
options: { check?: boolean; timeoutMs?: number } = {},
): string => this.guest.exec(args, options);
private guestBash(script: string): string {
return this.guest.bash(script);
}
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 {
if (shouldSkipSnapshotRestore()) {
say(`Skip snapshot restore; using current running VM ${this.options.vmName}`);
this.waitForGuestReady();
return;
}
say(`Restore snapshot ${this.options.snapshotHint} (${this.snapshot.id})`);
run("prlctl", ["snapshot-switch", this.options.vmName, "--id", this.snapshot.id], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
});
ensureVmRunning(this.options.vmName, 180, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
transitionTimeoutMs: () => this.remainingPhaseTimeoutMs(120_000),
});
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.guest.bash(`
set -e
if command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; then
exit 0
fi
deadline=$((SECONDS + ${APT_LOCK_RETRY_SECONDS}))
run_apt_with_lock_retry() {
local output status
while true; do
if output="$("$@" 2>&1)"; then
status=0
else
status=$?
fi
printf '%s\n' "$output"
if [ "$status" -eq 0 ]; then
return 0
fi
case "$output" in
*"Could not get lock"*|*"Unable to acquire the dpkg frontend lock"*|*"Unable to lock directory"*)
if [ "$SECONDS" -ge "$deadline" ]; then
printf 'Timed out waiting for Ubuntu package maintenance locks\n' >&2
return "$status"
fi
sleep 5
;;
*)
return "$status"
;;
esac
done
}
run_apt_with_lock_retry apt-get -o Acquire::Check-Date=false -o DPkg::Lock::Timeout=30 update
run_apt_with_lock_retry apt-get -o DPkg::Lock::Timeout=30 install -y curl ca-certificates`);
}
private installLatestRelease(): void {
this.downloadGuestFile(this.options.installUrl, "/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 downloadGuestFile(url: string, outputPath: string): void {
this.guest.bash(`
set -e
if command -v curl >/dev/null 2>&1; then
curl -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${shellQuote(
url,
)} -o ${shellQuote(outputPath)}
else
wget -q --timeout=10 --read-timeout=120 --tries=3 -O ${shellQuote(outputPath)} ${shellQuote(
url,
)}
fi`);
}
private installMainTgz(tempName: string): void {
if (!this.artifact || !this.server) {
die("package artifact/server missing");
}
const tgzUrl = this.server.urlFor(this.artifact.path);
this.downloadGuestFile(tgzUrl, `/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) {
this.verifyVersionContains(await expectedPackageTargetVersion(this.artifact));
return;
}
this.verifyVersionContains(await expectedPackageBuildCommit(this.artifact));
}
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 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 versionForLane(lane: "fresh" | "upgrade"): string {
return lane === "fresh" ? this.status.freshVersion : this.status.upgradeVersion;
}
private shouldExpectBadPluginDiagnostic(lane: "fresh" | "upgrade"): boolean {
const version = parseOpenClawPackageVersion(this.versionForLane(lane));
if (!version) {
return true;
}
return compareOpenClawPackageVersions(version, BAD_PLUGIN_DIAGNOSTIC_MIN_VERSION) >= 0;
}
private maybeInjectBadPluginFixture(lane: "fresh" | "upgrade"): void {
if (!this.shouldExpectBadPluginDiagnostic(lane)) {
this.log(
`Skipping bad plugin diagnostic fixture for ${lane}: installed ${this.versionForLane(lane)} predates ${BAD_PLUGIN_DIAGNOSTIC_MIN_VERSION}\n`,
);
return;
}
this.injectBadPluginFixture();
}
private startGatewayBackground(): void {
const bonjourEnv = this.disableBonjour ? " OPENCLAW_DISABLE_BONJOUR=1" : "";
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 ` +
shellQuote(
`exec env OPENCLAW_HOME=/root OPENCLAW_STATE_DIR=/root/.openclaw OPENCLAW_CONFIG_PATH=/root/.openclaw/openclaw.json OPENCLAW_ALLOW_ROOT=1${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", "OPENCLAW_ALLOW_ROOT=1", ...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_ALLOW_ROOT=1",
"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 async maybeVerifyBadPluginDiagnostic(lane: "fresh" | "upgrade"): Promise<void> {
if (!this.shouldExpectBadPluginDiagnostic(lane)) {
this.log(
`Skipping bad plugin diagnostic assertion for ${lane}: installed ${this.versionForLane(lane)} predates ${BAD_PLUGIN_DIAGNOSTIC_MIN_VERSION}\n`,
);
return;
}
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 restrictAgentTurnPlugins(): void {
this.guestBash(
posixProviderOnlyPluginIsolationScript({
fallbackPluginId: this.options.provider,
modelId: this.auth.modelId,
}),
);
}
private verifyLocalTurn(): void {
this.guestExec(["openclaw", "models", "set", this.auth.modelId]);
const modelProviderConfigBatch = modelProviderConfigBatchJson(this.auth.modelId, "linux");
if (modelProviderConfigBatch) {
this.guestBash(`provider_config_batch="$(mktemp)"
cat >"$provider_config_batch" <<'JSON'
${modelProviderConfigBatch}
JSON
openclaw config set --batch-file "$provider_config_batch" --strict-json
rm -f "$provider_config_batch"`);
}
this.guestExec([
"openclaw",
"config",
"set",
"agents.defaults.skipBootstrap",
"true",
"--strict-json",
]);
this.guestExec(["openclaw", "config", "set", "tools.profile", "minimal"]);
this.restrictAgentTurnPlugins();
this.prepareAgentWorkspace();
this.guestBash(
`${posixCodexPlatformPackageRepairFunction()}
agent_ok=false
for attempt in 1 2; do
session_id="parallels-linux-smoke"
if [ "$attempt" -gt 1 ]; then session_id="parallels-linux-smoke-retry-$attempt"; fi
rm -f "$HOME/.openclaw/agents/main/sessions/$session_id.jsonl"
output_file="$(mktemp)"
set +e
/usr/bin/env OPENCLAW_ALLOW_ROOT=1 ${shellQuote(`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`)} openclaw agent --local --agent main --session-id "$session_id" --message ${shellQuote(
"Reply with exact ASCII text OK only.",
)} --thinking off --timeout ${resolveParallelsModelTimeoutSeconds("linux")} --json >"$output_file" 2>&1
rc=$?
set -e
cat "$output_file"
if [ "$rc" -ne 0 ]; then
if [ "$attempt" -lt 2 ] && repair_missing_codex_platform_package "$output_file"; then
rm -f "$output_file"
echo "agent turn attempt $attempt hit a missing Codex platform package; retrying"
continue
fi
rm -f "$output_file"
exit "$rc"
fi
if grep -Eq '"finalAssistant(Raw|Visible)Text"[[:space:]]*:[[:space:]]*"OK"' "$output_file"; then
agent_ok=true
rm -f "$output_file"
break
fi
rm -f "$output_file"
if [ "$attempt" -lt 2 ]; then
echo "agent turn attempt $attempt finished without OK response; retrying"
sleep 3
fi
done
if [ "$agent_ok" != true ]; then
echo "openclaw agent finished without OK response" >&2
exit 1
fi`,
);
}
private prepareAgentWorkspace(): void {
this.guestBash(posixAgentWorkspaceScript("Parallels Linux smoke test assistant."));
}
private async extractLastVersion(phaseId: string): Promise<string> {
return await extractLastOpenClawVersion(
this.runDir,
phaseId,
/(OpenClaw [^\r\n]+ \([0-9a-f]{7,}\))/g,
);
}
protected async writeSummary(): Promise<string> {
const summaryPath = path.join(this.runDir, "summary.json");
const summary: LinuxSummary = {
daemon: this.status.daemon,
...buildCommonSmokeSummary({
artifact: this.artifact,
latestVersion: this.latestVersion,
options: this.options,
runDir: this.runDir,
snapshot: this.snapshot,
status: this.status,
vmName: this.options.vmName,
}),
};
await writeJson(summaryPath, summary);
await writeSummaryMarkdown({
lines: [
`- vm: ${summary.vm}`,
`- target: ${summary.targetPackageSpec || "current main"}`,
`- daemon: ${summary.daemon}`,
`- fresh: ${summary.freshMain.status} ${summary.freshMain.version}`,
`- fresh gateway/agent: ${summary.freshMain.gateway}/${summary.freshMain.agent}`,
`- upgrade: ${summary.upgrade.status} ${summary.upgrade.mainVersion}`,
`- logs: ${summary.runDir}`,
],
summaryPath,
title: "Linux Parallels Smoke",
});
return summaryPath;
}
protected printSummary(summaryPath: string): void {
process.stdout.write("\nSummary:\n");
printSmokeTargetSummary(this.options);
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`);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const options = parseArgs(process.argv.slice(2));
await mkdir(repoRoot, { recursive: true });
const runSmoke = () => new LinuxSmoke(options).run();
await (options.json ? withProgressOnStderr(runSmoke) : runSmoke());
}

View File

@@ -0,0 +1,214 @@
// Macos Discord script supports OpenClaw repository automation.
import { randomUUID } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import type { MacosGuest } from "./guest-transports.ts";
import { run, say, shellQuote, warn } from "./host-command.ts";
export type DiscordSmokePhase = "fresh" | "upgrade";
export interface MacosDiscordConfig {
channelId: string;
guildId: string;
token: string;
}
export class MacosDiscordSmoke {
constructor(
private input: {
config: MacosDiscordConfig;
guest: MacosGuest;
guestNode: string;
guestOpenClaw: string;
guestOpenClawEntry: string;
runDir: string;
vmName: string;
},
) {}
configure(): void {
const guilds = JSON.stringify({
[this.input.config.guildId]: {
channels: {
[this.input.config.channelId]: {
enabled: true,
requireMention: false,
},
},
},
});
this.input.guest.sh(`set -eu
${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.token ${shellQuote(this.input.config.token)}
${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.enabled true
${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.groupPolicy allowlist
${this.input.guestNode} ${this.input.guestOpenClawEntry} config set channels.discord.guilds ${shellQuote(guilds)} --strict-json
${this.input.guestNode} ${this.input.guestOpenClawEntry} doctor --fix --yes --non-interactive
${this.input.guestNode} - <<'JS'
const fs = require("node:fs");
const path = require("node:path");
const configPath = path.join(process.env.HOME || "", ".openclaw", "openclaw.json");
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
config.plugins = config.plugins && typeof config.plugins === "object" ? config.plugins : {};
const allow = Array.isArray(config.plugins.allow) ? config.plugins.allow : [];
config.plugins.allow = Array.from(new Set([...allow, "discord"]));
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\\n");
JS
${this.input.guestNode} ${this.input.guestOpenClawEntry} plugins enable discord
${this.input.guestNode} ${this.input.guestOpenClawEntry} gateway restart
${this.input.guestNode} ${this.input.guestOpenClawEntry} channels status --probe --json`);
}
async runRoundtrip(phase: DiscordSmokePhase): Promise<void> {
const nonce = randomUUID();
const outboundNonce = `${phase}-out-${nonce}`;
const inboundNonce = `${phase}-in-${nonce}`;
const outboundLog = path.join(this.input.runDir, `${phase}.discord-send.json`);
const sentIdFile = path.join(this.input.runDir, `${phase}.discord-sent-message-id`);
const hostIdFile = path.join(this.input.runDir, `${phase}.discord-host-message-id`);
const outbound = this.input.guest.exec([
this.input.guestOpenClaw,
"message",
"send",
"--channel",
"discord",
"--target",
`channel:${this.input.config.channelId}`,
"--message",
`parallels-macos-smoke-outbound-${outboundNonce}`,
"--silent",
"--json",
]);
await writeFile(outboundLog, `${outbound}\n`, "utf8");
const sentId = this.discordMessageId(outbound);
await writeFile(sentIdFile, `${sentId}\n`, "utf8");
await this.waitForHostVisibility(outboundNonce, sentId);
const hostId = await this.postDiscordMessage(`parallels-macos-smoke-inbound-${inboundNonce}`);
await writeFile(hostIdFile, `${hostId}\n`, "utf8");
this.waitForGuestReadback(inboundNonce);
}
async cleanupMessages(): Promise<void> {
for (const name of [
"fresh.discord-sent-message-id",
"fresh.discord-host-message-id",
"upgrade.discord-sent-message-id",
"upgrade.discord-host-message-id",
]) {
const filePath = path.join(this.input.runDir, name);
const id = await readFile(filePath, "utf8").catch(() => "");
if (id.trim()) {
await this.discordApi(
"DELETE",
`/channels/${this.input.config.channelId}/messages/${id.trim()}`,
).catch(() => "");
}
}
}
stopVmAfterSuccessfulSmoke(freshDiscord: string, upgradeDiscord: string): void {
if (freshDiscord !== "pass" && upgradeDiscord !== "pass") {
return;
}
say(`Stop ${this.input.vmName} after successful Discord smoke`);
const result = run("prlctl", ["stop", this.input.vmName], {
check: false,
quiet: true,
timeoutMs: 120_000,
});
if (result.status !== 0) {
warn(
`failed to stop ${this.input.vmName} after successful Discord smoke (rc=${result.status})`,
);
}
}
private discordMessageId(payloadText: string): string {
const payload = JSON.parse(payloadText) as {
payload?: { messageId?: string; result?: { messageId?: string } };
};
const id = payload.payload?.messageId || payload.payload?.result?.messageId;
if (!id) {
throw new Error("messageId missing from send output");
}
return id;
}
private async discordApi(method: string, apiPath: string, payload?: unknown): Promise<string> {
const args = [
"-fsS",
"-X",
method,
"-H",
`Authorization: Bot ${this.input.config.token}`,
...(payload == null
? []
: ["-H", "Content-Type: application/json", "--data", JSON.stringify(payload)]),
`https://discord.com/api/v10${apiPath}`,
];
return run("curl", args, { quiet: true }).stdout;
}
private async waitForHostVisibility(nonce: string, messageId: string): Promise<void> {
const deadline = Date.now() + 180_000;
while (Date.now() < deadline) {
const direct = await this.discordApi(
"GET",
`/channels/${this.input.config.channelId}/messages/${messageId}`,
).catch(() => "");
if (direct.includes(nonce)) {
return;
}
const recent = await this.discordApi(
"GET",
`/channels/${this.input.config.channelId}/messages?limit=20`,
).catch(() => "");
if (recent.includes(nonce)) {
return;
}
run("sleep", ["2"], { quiet: true });
}
throw new Error("Discord host visibility timed out");
}
private async postDiscordMessage(content: string): Promise<string> {
const response = await this.discordApi(
"POST",
`/channels/${this.input.config.channelId}/messages`,
{
content,
flags: 4096,
},
);
const id = (JSON.parse(response) as { id?: string }).id;
if (!id) {
throw new Error("host Discord post missing message id");
}
return id;
}
private waitForGuestReadback(nonce: string): void {
const deadline = Date.now() + 180_000;
while (Date.now() < deadline) {
const result = this.input.guest.run(
[
this.input.guestOpenClaw,
"message",
"read",
"--channel",
"discord",
"--target",
`channel:${this.input.config.channelId}`,
"--limit",
"20",
"--json",
],
{ check: false },
);
if (result.status === 0 && result.stdout.includes(nonce)) {
return;
}
run("sleep", ["3"], { quiet: true });
}
throw new Error("Discord guest readback timed out");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
// macOS user helpers support Parallels guest fallback discovery.
export function parseMacosDsclUserHomeLine(line: string): { user: string; home: string } | null {
const match = /^(\S+)\s+(.+?)\s*$/u.exec(line.replaceAll("\r", ""));
if (!match) {
return null;
}
return { user: match[1], home: match[2] };
}
export function isLikelyMacosDesktopHome(home: string | undefined): boolean {
const normalized = home?.trim();
return Boolean(normalized) && /(?:^|\/)Users\/[^/]+$/u.test(normalized);
}

View File

@@ -0,0 +1,480 @@
// Npm Update Scripts script supports OpenClaw repository automation.
import { posixAgentWorkspaceScript, windowsAgentWorkspaceScript } from "./agent-workspace.ts";
import { shellQuote } from "./host-command.ts";
import {
posixCodexPlatformPackageRepairFunction,
posixProviderOnlyPluginIsolationScript,
windowsCodexPlatformPackageRepairFunction,
} from "./plugin-isolation.ts";
import {
psSingleQuote,
windowsAgentTurnConfigPatchScript,
windowsOpenClawResolver,
windowsScopedEnvFunction,
} from "./powershell.ts";
import {
modelProviderConfigBatchJson,
resolveParallelsModelTimeoutSeconds,
} from "./provider-auth.ts";
import type { Platform, ProviderAuth } from "./types.ts";
export interface NpmUpdateScriptInput {
auth: ProviderAuth;
expectedNeedle: string;
updateTarget: string;
}
const windowsStalePostSwapImportRegex = String.raw`node_modules\\openclaw\\dist\\[^\\]+-[A-Za-z0-9_-]+\.js`;
const macosGuestPath =
"/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/usr/local/bin:/usr/local/sbin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin";
const macosOpenClawCommand = '"$OPENCLAW_BIN"';
function posixModelProviderConfigCommands(
command: string,
modelId: string,
platform: Platform,
): string {
const batchJson = modelProviderConfigBatchJson(modelId, platform);
if (!batchJson) {
return "";
}
return `provider_config_batch="$(mktemp)"
cat >"$provider_config_batch" <<'JSON'
${batchJson}
JSON
set +e
${command} config set --batch-file "$provider_config_batch" --strict-json
provider_config_exit=$?
set -e
rm -f "$provider_config_batch"
if [ "$provider_config_exit" -ne 0 ]; then exit "$provider_config_exit"; fi`;
}
function posixPrintLogTailFunction(): string {
return `print_log_tail() {
log_file="$1"
max_bytes="\${OPENCLAW_PARALLELS_NPM_UPDATE_LOG_TAIL_BYTES:-262144}"
case "$max_bytes" in
''|*[!0-9]*) max_bytes=262144 ;;
*) [ "$max_bytes" -gt 0 ] || max_bytes=262144 ;;
esac
[ -f "$log_file" ] || return 0
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
log_bytes="\${log_bytes//[[:space:]]/}"
case "$log_bytes" in
''|*[!0-9]*) log_bytes=0 ;;
esac
if [ "$log_bytes" -gt "$max_bytes" ]; then
echo "--- $log_file truncated: showing last $max_bytes of $log_bytes bytes ---"
fi
tail -c "$max_bytes" "$log_file" 2>/dev/null || true
}`;
}
function posixAssertAgentOkScript(
command: string,
input: NpmUpdateScriptInput,
platform: Extract<Platform, "linux" | "macos">,
sessionId: string,
) {
return `${posixProviderOnlyPluginIsolationScript({
fallbackPluginId: input.auth.modelId.split("/", 1)[0] || "openai",
modelId: input.auth.modelId,
})}
${posixCodexPlatformPackageRepairFunction()}
agent_ok=false
for attempt in 1 2; do
session_id=${shellQuote(sessionId)}
if [ "$attempt" -gt 1 ]; then session_id=${shellQuote(`${sessionId}-retry`)}"-$attempt"; fi
rm -f "$HOME/.openclaw/agents/main/sessions/$session_id.jsonl"
output_file="$(mktemp)"
set +e
OPENCLAW_ALLOW_ROOT="\${OPENCLAW_ALLOW_ROOT:-}" ${input.auth.apiKeyEnv}=${shellQuote(input.auth.apiKeyValue)} ${command} agent --local --agent main --session-id "$session_id" --message 'Reply with exact ASCII text OK only.' --thinking off --timeout ${resolveParallelsModelTimeoutSeconds(platform)} --json >"$output_file" 2>&1
rc=$?
set -e
print_log_tail "$output_file"
if [ "$rc" -ne 0 ]; then
if [ "$attempt" -lt 2 ] && repair_missing_codex_platform_package "$output_file"; then
rm -f "$output_file"
echo "agent turn attempt $attempt hit a missing Codex platform package; retrying"
continue
fi
rm -f "$output_file"
exit "$rc"
fi
if grep -Eq '"finalAssistant(Raw|Visible)Text"[[:space:]]*:[[:space:]]*"OK"' "$output_file"; then
agent_ok=true
rm -f "$output_file"
break
fi
rm -f "$output_file"
if [ "$attempt" -lt 2 ]; then
echo "agent turn attempt $attempt finished without OK response; retrying"
sleep 3
fi
done
if [ "$agent_ok" != true ]; then
echo "openclaw agent finished without OK response" >&2
exit 1
fi`;
}
function windowsUpdateWithBundledPluginsDisabled(input: NpmUpdateScriptInput): string {
return `$script:OpenClawUpdateExit = 0
$updateOutput = Invoke-WithScopedEnv @{ OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1'; OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS = '1' } {
Invoke-OpenClaw update --tag ${psSingleQuote(input.updateTarget)} --yes --json --no-restart 2>&1
$script:OpenClawUpdateExit = $LASTEXITCODE
}
$updateExit = $script:OpenClawUpdateExit
$updateOutput`;
}
function windowsGatewayReadyScript(): string {
return `function Wait-OpenClawGateway {
$deadline = (Get-Date).AddSeconds(180)
$attempt = 0
while ((Get-Date) -lt $deadline) {
Invoke-OpenClaw gateway status --deep --require-rpc --timeout 15000
if ($LASTEXITCODE -eq 0) { return }
$attempt += 1
if ($attempt -eq 4) {
Invoke-OpenClaw gateway start *>&1 | Out-Host
}
Start-Sleep -Seconds 5
}
throw "gateway did not become ready after update"
}
Invoke-OpenClaw gateway restart *>&1 | Out-Host
if ($LASTEXITCODE -ne 0) {
"gateway restart exited with code $LASTEXITCODE; probing readiness before failing" | Out-Host
}
Wait-OpenClawGateway`;
}
function windowsAssertAgentOkScript(input: NpmUpdateScriptInput): string {
return `${windowsAgentTurnConfigPatchScript(input.auth.modelId)}
${windowsCodexPlatformPackageRepairFunction()}
$sessionPath = Join-Path $env:USERPROFILE '.openclaw\\agents\\main\\sessions\\parallels-npm-update-windows.jsonl'
Remove-Item $sessionPath -Force -ErrorAction SilentlyContinue
${windowsAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
Set-Item -Path ('Env:' + ${psSingleQuote(input.auth.apiKeyEnv)}) -Value ${psSingleQuote(input.auth.apiKeyValue)}
$agentOk = $false
for ($attempt = 1; $attempt -le 2; $attempt++) {
$sessionId = if ($attempt -eq 1) { 'parallels-npm-update-windows' } else { "parallels-npm-update-windows-retry-$attempt" }
$sessionsDir = Join-Path $env:USERPROFILE '.openclaw\\agents\\main\\sessions'
$sessionPath = Join-Path $sessionsDir "$sessionId.jsonl"
Remove-Item $sessionPath -Force -ErrorAction SilentlyContinue
$output = Invoke-OpenClaw agent --local --agent main --session-id $sessionId --model ${psSingleQuote(input.auth.modelId)} --message 'Reply with exact ASCII text OK only.' --thinking off --timeout ${resolveParallelsModelTimeoutSeconds("windows")} --json 2>&1
$agentExitCode = $LASTEXITCODE
if ($null -ne $output) { $output | ForEach-Object { $_ } }
if ($agentExitCode -eq 0 -and ($output | Out-String) -match '"finalAssistant(Raw|Visible)Text":\\s*"OK"') {
$agentOk = $true
break
}
if ($agentExitCode -ne 0 -and $attempt -lt 2 -and (Repair-MissingCodexPlatformPackage -Output $output)) {
Write-Host "agent turn attempt $attempt hit a missing Codex platform package; retrying"
continue
}
if ($attempt -lt 2) {
Write-Host "agent turn attempt $attempt finished without OK response; retrying"
Start-Sleep -Seconds 3
}
if ($agentExitCode -ne 0) { throw "agent failed with exit code $agentExitCode" }
}
if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`;
}
export function macosUpdateScript(input: NpmUpdateScriptInput): string {
return String.raw`set -euo pipefail
export PATH=${macosGuestPath}
${posixPrintLogTailFunction()}
resolve_required_command() {
command -v "$1" || {
echo "required command not found on PATH: $1" >&2
exit 127
}
}
OPENCLAW_BIN="$(resolve_required_command openclaw)"
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)
entries.pop("openai", None)
allow = plugins.get("allow")
if isinstance(allow, list):
plugins["allow"] = [item for item in allow if item not in {"feishu", "whatsapp", "openai"}]
path.write_text(json.dumps(config, indent=2) + "\n")
PY
}
stop_openclaw_gateway_processes() {
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 "$OPENCLAW_BIN" gateway stop || true
pkill -f 'openclaw.*gateway' >/dev/null 2>&1 || true
if command -v lsof >/dev/null 2>&1; then
pids="$(lsof -tiTCP:18789 -sTCP:LISTEN 2>/dev/null || true)"
if [ -n "$pids" ]; then
kill $pids >/dev/null 2>&1 || true
sleep 2
kill -9 $pids >/dev/null 2>&1 || true
fi
fi
}
start_openclaw_gateway() {
stop_openclaw_gateway_processes
rm -f /tmp/openclaw-parallels-macos-gateway.log
trap '' HUP
/usr/bin/env OPENCLAW_HOME="$HOME" OPENCLAW_STATE_DIR="$HOME/.openclaw" OPENCLAW_CONFIG_PATH="$HOME/.openclaw/openclaw.json" ${input.auth.apiKeyEnv}=${shellQuote(
input.auth.apiKeyValue,
)} "$OPENCLAW_BIN" gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-macos-gateway.log 2>&1 </dev/null &
sleep 1
}
wait_for_gateway() {
deadline=$((SECONDS + 240))
while [ "$SECONDS" -lt "$deadline" ]; do
if "$OPENCLAW_BIN" gateway status --deep --require-rpc --timeout 15000; then
return
fi
sleep 2
done
print_log_tail /tmp/openclaw-parallels-macos-gateway.log >&2
echo "gateway did not become ready after update" >&2
exit 1
}
scrub_future_plugin_entries
stop_openclaw_gateway_processes
OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 "$OPENCLAW_BIN" update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart
${posixVersionCheck(macosOpenClawCommand, input.expectedNeedle)}
start_openclaw_gateway
wait_for_gateway
"$OPENCLAW_BIN" models set ${shellQuote(input.auth.modelId)}
${posixModelProviderConfigCommands(macosOpenClawCommand, input.auth.modelId, "macos")}
"$OPENCLAW_BIN" config set agents.defaults.skipBootstrap true --strict-json
"$OPENCLAW_BIN" config set tools.profile minimal
${posixAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
${posixAssertAgentOkScript(macosOpenClawCommand, input, "macos", "parallels-npm-update-macos")}`;
}
export function windowsUpdateScript(input: NpmUpdateScriptInput): string {
return `$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
${windowsOpenClawResolver}
${windowsScopedEnvFunction}
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 } catch { return }
$plugins = Get-OpenClawJsonProperty $config 'plugins'
if ($null -eq $plugins) { return }
$entries = Get-OpenClawJsonProperty $plugins 'entries'
if ($null -ne $entries) {
foreach ($pluginId in @('feishu', 'whatsapp', 'openai')) {
Remove-OpenClawJsonProperty $entries $pluginId
}
}
$allow = Get-OpenClawJsonProperty $plugins 'allow'
if ($allow -is [array]) {
Set-OpenClawJsonProperty $plugins 'allow' @($allow | Where-Object { $_ -notin @('feishu', 'whatsapp', 'openai') })
}
$config | ConvertTo-Json -Depth 100 | Set-Content -Path $configPath -Encoding UTF8
}
function Get-OpenClawJsonProperty {
param([object]$Object, [string]$Name)
if ($null -eq $Object) { return $null }
if ($Object -is [System.Collections.IDictionary]) { return $Object[$Name] }
$property = $Object.PSObject.Properties[$Name]
if ($null -eq $property) { return $null }
return $property.Value
}
function Set-OpenClawJsonProperty {
param([object]$Object, [string]$Name, [object]$Value)
if ($Object -is [System.Collections.IDictionary]) {
$Object[$Name] = $Value
return
}
$property = $Object.PSObject.Properties[$Name]
if ($null -ne $property) {
$property.Value = $Value
return
}
$Object | Add-Member -NotePropertyName $Name -NotePropertyValue $Value
}
function Remove-OpenClawJsonProperty {
param([object]$Object, [string]$Name)
if ($null -eq $Object) { return }
if ($Object -is [System.Collections.IDictionary]) {
if ($Object.Contains($Name)) { $Object.Remove($Name) }
return
}
if ($null -ne $Object.PSObject.Properties[$Name]) {
$Object.PSObject.Properties.Remove($Name)
}
}
function Stop-OpenClawGatewayProcesses {
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 }
Get-NetTCPConnection -LocalPort 18789 -State Listen -ErrorAction SilentlyContinue |
Select-Object -ExpandProperty OwningProcess -Unique |
ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }
Start-Sleep -Seconds 2
}
Remove-FuturePluginEntries
Stop-OpenClawGatewayProcesses
${windowsUpdateWithBundledPluginsDisabled(input)}
if ($updateExit -ne 0) {
$updateText = $updateOutput | Out-String
$stalePostSwapImport = $updateText -match 'ERR_MODULE_NOT_FOUND' -and $updateText -match ${psSingleQuote(windowsStalePostSwapImportRegex)}
if (-not $stalePostSwapImport) { throw "openclaw update failed with exit code $updateExit" }
Write-Host "openclaw update returned a stale post-swap module import; continuing to post-update health checks"
}
${windowsVersionCheck(input.expectedNeedle)}
${windowsGatewayReadyScript()}
${windowsAssertAgentOkScript(input)}`;
}
export function linuxUpdateScript(input: NpmUpdateScriptInput): string {
return String.raw`set -euo pipefail
export PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/snap/bin
export OPENCLAW_ALLOW_ROOT=1
${posixPrintLogTailFunction()}
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;
delete plugins.entries.openai;
}
if (Array.isArray(plugins.allow)) {
plugins.allow = plugins.allow.filter((id) => id !== "feishu" && id !== "whatsapp" && id !== "openai");
}
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
JS
}
stop_openclaw_gateway_processes() {
OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 OPENCLAW_ALLOW_ROOT=1 openclaw gateway stop || true
pkill -f 'openclaw.*gateway' >/dev/null 2>&1 || true
}
start_openclaw_gateway() {
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 OPENCLAW_DISABLE_BONJOUR=1 OPENCLAW_ALLOW_ROOT=1 ${input.auth.apiKeyEnv}=${shellQuote(
input.auth.apiKeyValue,
)} openclaw gateway run --bind loopback --port 18789 --force >/tmp/openclaw-parallels-linux-gateway.log 2>&1`,
)} >/dev/null 2>&1 < /dev/null &
}
wait_for_gateway() {
deadline=$((SECONDS + 240))
while [ "$SECONDS" -lt "$deadline" ]; do
if openclaw gateway status --deep --require-rpc --timeout 15000; then
return
fi
sleep 2
done
print_log_tail /tmp/openclaw-parallels-linux-gateway.log >&2
echo "gateway did not become ready after update" >&2
exit 1
}
scrub_future_plugin_entries
stop_openclaw_gateway_processes
OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 openclaw update --tag ${shellQuote(input.updateTarget)} --yes --json --no-restart
${posixVersionCheck("openclaw", input.expectedNeedle)}
start_openclaw_gateway
wait_for_gateway
openclaw models set ${shellQuote(input.auth.modelId)}
${posixModelProviderConfigCommands("openclaw", input.auth.modelId, "linux")}
openclaw config set agents.defaults.skipBootstrap true --strict-json
openclaw config set tools.profile minimal
${posixAgentWorkspaceScript("Parallels npm update smoke test assistant.")}
${posixAssertAgentOkScript("openclaw", input, "linux", "parallels-npm-update-linux")}`;
}
function posixVersionCheck(command: string, expectedNeedle: string): string {
const quotedNeedle = shellQuote(expectedNeedle);
if (!expectedNeedle) {
return `hash -r || true
version_deadline=$((SECONDS + 60))
while true; do
if version="$(${command} --version 2>&1)"; then
version_status=0
printf '%s\\n' "$version"
break
else
version_status=$?
printf '%s\\n' "$version"
fi
if [ "$SECONDS" -ge "$version_deadline" ]; then
exit "$version_status"
fi
sleep 2
done`;
}
return `hash -r || true
version_deadline=$((SECONDS + 60))
while true; do
if version="$(${command} --version 2>&1)"; then
version_status=0
printf '%s\\n' "$version"
case "$version" in *${quotedNeedle}*) break ;; esac
else
version_status=$?
printf '%s\\n' "$version"
fi
if [ "$SECONDS" -ge "$version_deadline" ]; then
if [ "$version_status" -ne 0 ]; then
exit "$version_status"
fi
echo "version mismatch: expected ${expectedNeedle}" >&2
exit 1
fi
sleep 2
done`;
}
function windowsVersionCheck(expectedNeedle: string): string {
if (!expectedNeedle) {
return `$versionDeadline = (Get-Date).AddSeconds(60)
while ($true) {
$version = Invoke-OpenClaw --version
$version
if ($LASTEXITCODE -eq 0) { break }
if ((Get-Date) -ge $versionDeadline) { throw "openclaw --version failed with exit code $LASTEXITCODE" }
Start-Sleep -Seconds 2
}`;
}
const expectedPattern = psSingleQuote(`*${expectedNeedle}*`);
const mismatch = psSingleQuote(`version mismatch: expected ${expectedNeedle}`);
return `$versionDeadline = (Get-Date).AddSeconds(60)
while ($true) {
$version = Invoke-OpenClaw --version
$version
if ($LASTEXITCODE -eq 0 -and (($version | Out-String) -like ${expectedPattern})) { break }
if ((Get-Date) -ge $versionDeadline) {
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }
throw ${mismatch}
}
Start-Sleep -Seconds 2
}`;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,302 @@
// Package Artifact script supports OpenClaw repository automation.
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 { sleep as delay } from "../../lib/sleep.mjs";
import { readPositiveIntEnv } from "./env-limits.ts";
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 ?? "";
}
function resolveNpmPackTarballFilename(value: unknown): string {
const filename = typeof value === "string" ? value.trim() : "";
if (
!filename.endsWith(".tgz") ||
filename.includes("\0") ||
filename !== path.basename(filename) ||
filename !== path.win32.basename(filename)
) {
die("npm pack did not report a safe tarball filename");
}
return filename;
}
export function resolveOpenClawRegistryVersion(specOrAlias: string): string {
const rawValue = specOrAlias.trim();
const value = rawValue.startsWith("openclaw@") ? rawValue.slice("openclaw@".length) : rawValue;
if (!value) {
return "";
}
if (value === "latest" || value === "beta" || /^\d/.test(value)) {
return npmViewVersion(`openclaw@${value}`);
}
const betaMatch = /^beta(\d+)$/u.exec(value);
if (betaMatch) {
const betaSuffix = `-beta.${betaMatch[1]}`;
const versions = JSON.parse(
run("npm", ["view", "openclaw", "versions", "--json"], { quiet: true }).stdout,
) as string[];
const match = versions
.filter((version) => version.endsWith(betaSuffix))
.toSorted((a, b) => a.localeCompare(b, undefined, { numeric: true }))
.at(-1);
if (!match) {
die(`no openclaw registry version found for alias ${value}`);
}
return match;
}
return "";
}
function npmViewVersion(spec: string): string {
return run("npm", ["view", spec, "version"], { quiet: true }).stdout.trim();
}
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 = "";
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", "--", ":(glob)extensions/*/src/host/**/.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;
}): 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 = resolveNpmPackTarballFilename(JSON.parse(output).at(-1)?.filename);
const tgzPath = path.join(input.destination, packed);
const version = await packageVersionFromTgz(tgzPath);
say(`Packed ${tgzPath}`);
say(`Target package version: ${version}`);
return { path: tgzPath, version };
}
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());",
]);
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 = resolveNpmPackTarballFilename(JSON.parse(output).at(-1)?.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,
params: { writeOwner?: (lockDir: string, ownerToken: string) => Promise<void> } = {},
): Promise<void> {
const timeoutMs = readPositiveIntEnv("OPENCLAW_PARALLELS_PACKAGE_LOCK_TIMEOUT_MS", 30 * 60_000);
const staleMs = readPositiveIntEnv("OPENCLAW_PARALLELS_PACKAGE_LOCK_STALE_MS", 2 * 60 * 60_000);
const startedAt = Date.now();
let waitAnnouncementBudget = 1;
const consumeWaitAnnouncement = () => waitAnnouncementBudget-- > 0;
while (Date.now() - startedAt < timeoutMs) {
let createdLockDir = false;
try {
await mkdir(lockDir);
createdLockDir = true;
await (params.writeOwner ?? writeLockOwner)(lockDir, ownerToken);
return;
} catch (error) {
if (createdLockDir) {
await rm(lockDir, { force: true, recursive: true }).catch(() => undefined);
}
if (!isErrorCode(error, "EEXIST")) {
throw error;
}
}
await removeStalePackageLock(lockDir, staleMs);
if (consumeWaitAnnouncement()) {
say(`Wait for Parallels package lock: ${lockDir}`);
}
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?.pid !== undefined || staleMs <= 0 || 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" && Number.isSafeInteger(parsed.pid) && parsed.pid > 0
? 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);
}
export const testing = {
acquirePackageLock,
removeStalePackageLock,
readLockOwner,
};

View File

@@ -0,0 +1,154 @@
// Parallels Vm script supports OpenClaw repository automation.
import { die, run, say, warn } from "./host-command.ts";
const PRLCTL_STATUS_TIMEOUT_MS = 30_000;
const PRLCTL_TRANSITION_TIMEOUT_MS = 120_000;
interface PrlctlVmListItem {
name?: string;
status?: string;
}
export interface WaitForVmStatusOptions {
probeTimeoutMs?: () => number | undefined;
}
export interface EnsureVmRunningOptions extends WaitForVmStatusOptions {
transitionTimeoutMs?: () => number | undefined;
}
export function listVmNames(): string[] {
return listVms()
.map((item) => (item.name ?? "").trim())
.filter(Boolean);
}
export function vmStatus(vmName: string, timeoutMs?: number): string {
return listVms(timeoutMs).find((vm) => vm.name === vmName)?.status || "missing";
}
export function waitForVmStatus(
vmName: string,
expected: string,
timeoutSeconds: number,
options: WaitForVmStatusOptions = {},
): void {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const status = run("prlctl", ["status", vmName], {
check: false,
quiet: true,
timeoutMs: options.probeTimeoutMs?.() ?? PRLCTL_STATUS_TIMEOUT_MS,
}).stdout;
if (status.includes(` ${expected}`)) {
return;
}
run("sleep", ["1"], { quiet: true });
}
throw new Error(`VM ${vmName} did not reach ${expected}`);
}
export function ensureVmRunning(
vmName: string,
timeoutSeconds = 180,
options: EnsureVmRunningOptions = {},
): void {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const status = vmStatus(vmName, options.probeTimeoutMs?.());
if (status === "running") {
return;
}
if (status === "stopped") {
say(`Start ${vmName} before update phase`);
run("prlctl", ["start", vmName], {
quiet: true,
timeoutMs: options.transitionTimeoutMs?.() ?? PRLCTL_TRANSITION_TIMEOUT_MS,
});
} else if (status === "suspended" || status === "paused") {
say(`Resume ${vmName} before update phase`);
run("prlctl", ["resume", vmName], {
quiet: true,
timeoutMs: options.transitionTimeoutMs?.() ?? PRLCTL_TRANSITION_TIMEOUT_MS,
});
} 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}`);
}
export function resolveUbuntuVmName(requested: string, explicit = false): string {
const names = listVmNames();
if (names.includes(requested)) {
return requested;
}
if (explicit) {
die(`VM not found: ${requested}`);
}
const fallback =
names
.map((name) => ({ name, parts: parseUbuntuVersionParts(name) }))
.filter((item): item is { name: string; parts: number[] } => Boolean(item.parts))
.filter((item) => item.parts[0] >= 24)
.toSorted((a, b) => compareVersions(b.parts, a.parts))[0]?.name ??
names.find(isSafeUbuntuFallbackName);
if (!fallback) {
die(`VM not found: ${requested}`);
}
warn(`requested VM ${requested} not found; using ${fallback}`);
return fallback;
}
export function resolveMacosVmName(requested: string, explicit = false): string {
const names = listVmNames();
if (names.includes(requested)) {
return requested;
}
if (explicit) {
die(`VM not found: ${requested}`);
}
const fallback = names.find((name) => name === "macOS");
if (!fallback) {
die(`VM not found: ${requested}; select a macOS VM explicitly`);
}
warn(`requested VM ${requested} not found; using ${fallback}`);
return fallback;
}
function listVms(timeoutMs = PRLCTL_STATUS_TIMEOUT_MS): PrlctlVmListItem[] {
return JSON.parse(
run("prlctl", ["list", "--all", "--json"], {
quiet: true,
timeoutMs,
}).stdout,
) as PrlctlVmListItem[];
}
function parseUbuntuVersionParts(name: string): number[] | undefined {
const version = /ubuntu\s+(\d+(?:\.\d+)*)/i.exec(name)?.[1];
const parts = version?.split(".").map((part) => Number(part));
if (!parts?.every((part) => Number.isSafeInteger(part))) {
return undefined;
}
return parts;
}
function isSafeUbuntuFallbackName(name: string): boolean {
if (!/ubuntu/i.test(name)) {
return false;
}
const hasVersion = /ubuntu\s+\d+(?:\.\d+)*/i.test(name);
return !hasVersion || Boolean(parseUbuntuVersionParts(name));
}
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;
}

View File

@@ -0,0 +1,129 @@
// Phase Runner script supports OpenClaw repository automation.
import { appendFileSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { clampTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
import { say, warn } from "./host-command.ts";
export const PHASE_LOG_TAIL_MAX_BYTES = 512 * 1024;
function appendTextTail(current: string, chunk: string, maxBytes: number): string {
const text = chunk.endsWith("\n") ? chunk : `${chunk}\n`;
const combined = `${current}${text}`;
if (Buffer.byteLength(combined) <= maxBytes) {
return combined;
}
const marker = `[phase log tail truncated to last ${maxBytes} bytes]\n`;
const tailBytes = Math.max(0, maxBytes - Buffer.byteLength(marker));
const tail = Buffer.from(combined).subarray(-tailBytes).toString("utf8");
return `${marker}${tail}`;
}
function resolvePhaseTimeoutMs(timeoutSeconds: number): number {
return clampTimerTimeoutMs(timeoutSeconds * 1000) ?? 1;
}
export class PhaseRunner {
private logTail = "";
private currentLogPath: string | undefined;
private deadlineMs = 0;
private timings: Array<{
durationMs: number;
logPath: string;
name: string;
status: "pass" | "fail";
timeoutSeconds: number;
}> = [];
constructor(
private runDir: string,
private logTailMaxBytes = PHASE_LOG_TAIL_MAX_BYTES,
) {}
async phase(name: string, timeoutSeconds: number, fn: () => Promise<void> | void): Promise<void> {
const logPath = path.join(this.runDir, `${name}.log`);
const timeoutMs = resolvePhaseTimeoutMs(timeoutSeconds);
say(name);
this.logTail = "";
this.currentLogPath = logPath;
this.deadlineMs = Date.now() + timeoutMs;
await writeFile(logPath, "", "utf8");
const startedAt = Date.now();
let status: "pass" | "fail" = "fail";
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`${name} timed out after ${timeoutSeconds}s`)),
timeoutMs,
);
});
try {
await Promise.race([Promise.resolve(fn()), timeout]);
status = "pass";
} catch (error) {
warn(`${name} failed`);
warn(`log tail: ${logPath}`);
process.stderr.write(this.logTail.split("\n").slice(-80).join("\n"));
process.stderr.write("\n");
throw error;
} finally {
this.timings.push({
durationMs: Date.now() - startedAt,
logPath,
name,
status,
timeoutSeconds,
});
await this.writeTimings().catch(() => undefined);
if (timer) {
clearTimeout(timer);
}
this.currentLogPath = undefined;
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;
}
const line = text.endsWith("\n") ? text : `${text}\n`;
if (this.currentLogPath) {
appendFileSync(this.currentLogPath, line, "utf8");
}
this.logTail = appendTextTail(this.logTail, line, this.logTailMaxBytes);
}
private async writeTimings(): Promise<void> {
const slowest = this.timings.toSorted((a, b) => b.durationMs - a.durationMs)[0] ?? null;
await writeFile(
path.join(this.runDir, "phase-timings.json"),
`${JSON.stringify({ phases: this.timings, slowest }, null, 2)}\n`,
"utf8",
);
}
}

View File

@@ -0,0 +1,208 @@
// Plugin Isolation script supports OpenClaw repository automation.
import { shellQuote } from "./host-command.ts";
import { providerIdFromModelId } from "./provider-auth.ts";
interface PluginIsolationOptions {
fallbackPluginId: string;
homeFallback?: string;
modelId: string;
nodeCommand?: string;
}
export function posixCodexPlatformPackageRepairFunction(): string {
return `repair_missing_codex_platform_package() {
output_file="$1"
grep -F 'Missing optional dependency @openai/codex-' "$output_file" >/dev/null 2>&1 || return 1
state_home="\${OPENCLAW_PARALLELS_HOME:-\${HOME:-}}"
codex_manifest=""
for candidate in "$state_home"/.openclaw/npm/projects/*/node_modules/@openclaw/codex/package.json; do
[ -f "$candidate" ] || continue
codex_manifest="$candidate"
break
done
if [ -z "$codex_manifest" ]; then
echo "codex-platform-repair: managed Codex project not found" >&2
return 1
fi
project_root="\${codex_manifest%/node_modules/@openclaw/codex/package.json}"
cache_dir="$(mktemp -d "\${TMPDIR:-/tmp}/openclaw-npm-cache.XXXXXX")"
echo "codex-platform-repair: retrying managed npm install once with a fresh cache" >&2
repair_rc=0
(
cd "$project_root"
NPM_CONFIG_CACHE="$cache_dir" npm_config_cache="$cache_dir" npm install --omit=dev --omit=peer --legacy-peer-deps --ignore-scripts --no-audit --no-fund
) || repair_rc=$?
rm -rf "$cache_dir"
if [ "$repair_rc" -ne 0 ]; then
echo "codex-platform-repair: npm install failed with exit code $repair_rc" >&2
return "$repair_rc"
fi
echo "codex-platform-repair: managed npm install completed" >&2
}`;
}
export function windowsCodexPlatformPackageRepairFunction(): string {
return String.raw`function Repair-MissingCodexPlatformPackage {
param([object[]] $Output)
$outputText = $Output | Out-String
if ($outputText -notmatch [regex]::Escape('Missing optional dependency @openai/codex-')) {
return $false
}
$projectsRoot = Join-Path $env:USERPROFILE '.openclaw\npm\projects'
$codexManifest = Get-ChildItem -Path $projectsRoot -Filter package.json -File -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match 'node_modules[\\/]@openclaw[\\/]codex[\\/]package\.json$' } |
Select-Object -First 1
if (-not $codexManifest) {
Write-Warning 'codex-platform-repair: managed Codex project not found'
return $false
}
$projectRoot = $codexManifest.Directory.Parent.Parent.Parent.FullName
$cacheDir = Join-Path ([System.IO.Path]::GetTempPath()) ('openclaw-npm-cache-' + [guid]::NewGuid().ToString('N'))
$oldUpperCache = [Environment]::GetEnvironmentVariable('NPM_CONFIG_CACHE', 'Process')
$oldLowerCache = [Environment]::GetEnvironmentVariable('npm_config_cache', 'Process')
$pushedLocation = $false
$repairExit = 1
try {
New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
[Environment]::SetEnvironmentVariable('NPM_CONFIG_CACHE', $cacheDir, 'Process')
[Environment]::SetEnvironmentVariable('npm_config_cache', $cacheDir, 'Process')
Push-Location $projectRoot
$pushedLocation = $true
Write-Host 'codex-platform-repair: retrying managed npm install once with a fresh cache'
$repairOutput = & npm.cmd install --omit=dev --omit=peer --legacy-peer-deps --ignore-scripts --no-audit --no-fund 2>&1
$repairExit = $LASTEXITCODE
if ($null -ne $repairOutput) { $repairOutput | ForEach-Object { Write-Host $_ } }
} finally {
if ($pushedLocation) { Pop-Location }
[Environment]::SetEnvironmentVariable('NPM_CONFIG_CACHE', $oldUpperCache, 'Process')
[Environment]::SetEnvironmentVariable('npm_config_cache', $oldLowerCache, 'Process')
Remove-Item $cacheDir -Force -Recurse -ErrorAction SilentlyContinue
}
if ($repairExit -ne 0) {
Write-Warning "codex-platform-repair: npm install failed with exit code $repairExit"
return $false
}
Write-Host 'codex-platform-repair: managed npm install completed'
return $true
}`;
}
export function providerOnlyPluginId(modelId: string, fallbackPluginId: string): string {
return providerIdFromModelId(modelId) || fallbackPluginId;
}
export function posixProviderOnlyPluginIsolationScript(options: PluginIsolationOptions): string {
const nodeCommand = shellQuote(options.nodeCommand ?? "node");
const homeEnv = options.homeFallback
? `OPENCLAW_PARALLELS_HOME=${shellQuote(options.homeFallback)} `
: "";
return `/usr/bin/env ${homeEnv}${nodeCommand} - <<'JS'
${providerOnlyPluginIsolationNodeScript(options)}
JS`;
}
export function windowsProviderOnlyPluginIsolationScript(options: PluginIsolationOptions): string {
const payloadJson = JSON.stringify({
modelId: options.modelId,
pluginId: providerOnlyPluginId(options.modelId, options.fallbackPluginId),
});
return `$env:OPENCLAW_PARALLELS_PLUGIN_ISOLATION = @'
${payloadJson}
'@
$isolationScriptPath = Join-Path ([System.IO.Path]::GetTempPath()) ('openclaw-parallels-plugin-isolation-' + [guid]::NewGuid().ToString('N') + '.cjs')
try {
@'
${providerOnlyPluginIsolationNodeSource()}
'@ | Set-Content -Path $isolationScriptPath -Encoding UTF8
node.exe $isolationScriptPath
if ($LASTEXITCODE -ne 0) { throw "plugin isolation failed with exit code $LASTEXITCODE" }
} finally {
Remove-Item $isolationScriptPath -Force -ErrorAction SilentlyContinue
Remove-Item Env:OPENCLAW_PARALLELS_PLUGIN_ISOLATION -Force -ErrorAction SilentlyContinue
}`;
}
function providerOnlyPluginIsolationNodeScript(options: PluginIsolationOptions): string {
const payloadJson = JSON.stringify({
homeFallback: options.homeFallback,
modelId: options.modelId,
pluginId: providerOnlyPluginId(options.modelId, options.fallbackPluginId),
});
return `process.env.OPENCLAW_PARALLELS_PLUGIN_ISOLATION = ${JSON.stringify(payloadJson)};
${providerOnlyPluginIsolationNodeSource()}`;
}
function providerOnlyPluginIsolationNodeSource(): string {
return String.raw`const fs = require("node:fs");
const path = require("node:path");
const payload = JSON.parse(process.env.OPENCLAW_PARALLELS_PLUGIN_ISOLATION || "{}");
const home =
process.env.OPENCLAW_PARALLELS_HOME ||
payload.homeFallback ||
process.env.HOME ||
process.env.USERPROFILE ||
"/root";
const configPath = path.join(home, ".openclaw", "openclaw.json");
const stateDir = path.dirname(configPath);
const modelId = String(payload.modelId || "");
const allowedPluginId = String(payload.pluginId || "").trim();
if (!allowedPluginId || !modelId) {
throw new Error("missing plugin isolation payload");
}
const readConfig = () => {
if (!fs.existsSync(configPath)) {
return {};
}
return JSON.parse(fs.readFileSync(configPath, "utf8"));
};
const objectRecord = (value) =>
value && typeof value === "object" && !Array.isArray(value) ? value : {};
const config = readConfig();
config.plugins = objectRecord(config.plugins);
config.plugins.entries = { [allowedPluginId]: { enabled: true } };
config.plugins.allow = [allowedPluginId];
config.agents = objectRecord(config.agents);
config.agents.defaults = objectRecord(config.agents.defaults);
config.agents.defaults.model = {
...objectRecord(config.agents.defaults.model),
primary: modelId,
};
config.agents.defaults.models = objectRecord(config.agents.defaults.models);
const selectedModelEntry = config.agents.defaults.models[modelId];
if (selectedModelEntry && typeof selectedModelEntry === "object" && !Array.isArray(selectedModelEntry)) {
delete selectedModelEntry.agentRuntime;
}
const providerId = modelId.split("/", 1)[0] || "";
const providerModelId = modelId.slice(providerId.length + 1);
const providers = objectRecord(objectRecord(config.models).providers);
const providerEntry = providers[providerId];
if (providerEntry && typeof providerEntry === "object" && !Array.isArray(providerEntry)) {
delete providerEntry.agentRuntime;
if (Array.isArray(providerEntry.models)) {
for (const model of providerEntry.models) {
if (
model &&
typeof model === "object" &&
(model.id === providerModelId ||
model.id === modelId ||
model.name === providerModelId ||
model.name === modelId)
) {
delete model.agentRuntime;
}
}
}
}
fs.rmSync(path.join(stateDir, "npm", "node_modules", "@openclaw", "codex"), {
recursive: true,
force: true,
});
fs.mkdirSync(stateDir, { recursive: true });
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");`;
}

View File

@@ -0,0 +1,193 @@
// Powershell script supports OpenClaw repository automation.
import { modelProviderConfigBatchJson, providerIdFromModelId } from "./provider-auth.ts";
export function psSingleQuote(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
export function encodePowerShell(script: string): string {
return Buffer.from(`$ProgressPreference = 'SilentlyContinue'\n${script}`, "utf16le").toString(
"base64",
);
}
export const windowsScopedEnvFunction = String.raw`function Invoke-WithScopedEnv {
param(
[Parameter(Mandatory = $true)][hashtable] $Values,
[Parameter(Mandatory = $true)][scriptblock] $Script
)
$previous = @{}
foreach ($key in $Values.Keys) {
$previous[$key] = [Environment]::GetEnvironmentVariable([string]$key, 'Process')
Set-Item -Path ('Env:' + $key) -Value ([string]$Values[$key])
}
try {
& $Script
} finally {
foreach ($key in $Values.Keys) {
if ($null -eq $previous[$key]) {
Remove-Item -Path ('Env:' + $key) -ErrorAction SilentlyContinue
} else {
Set-Item -Path ('Env:' + $key) -Value $previous[$key]
}
}
}
}`;
export function windowsAgentTurnConfigPatchScript(modelId: string): string {
const batchJson = modelProviderConfigBatchJson(modelId, "windows");
const pluginId = providerIdFromModelId(modelId) || modelId.split("/", 1)[0] || "openai";
const payloadJson = JSON.stringify({
modelId,
operations: batchJson ? (JSON.parse(batchJson) as unknown) : [],
pluginId,
});
return `$agentTurnConfigPatchPath = $env:OPENCLAW_CONFIG_PATH
if (-not $agentTurnConfigPatchPath) { $agentTurnConfigPatchPath = Join-Path $env:USERPROFILE '.openclaw\\openclaw.json' }
$agentTurnVersionText = Invoke-OpenClaw --version 2>$null | Out-String
$agentTurnRuntimePolicySupported = $false
if ($agentTurnVersionText -match 'OpenClaw\\s+(\\d{4})\\.(\\d{1,2})\\.(\\d{1,2})') {
$agentTurnYear = [int]$Matches[1]
$agentTurnMonth = [int]$Matches[2]
$agentTurnDay = [int]$Matches[3]
$agentTurnRuntimePolicySupported = ($agentTurnYear -gt 2026) -or ($agentTurnYear -eq 2026 -and (($agentTurnMonth -gt 5) -or ($agentTurnMonth -eq 5 -and $agentTurnDay -ge 9)))
}
$env:OPENCLAW_PARALLELS_AGENT_CONFIG_PATCH = @'
${payloadJson}
'@
$env:OPENCLAW_PARALLELS_AGENT_CONFIG_PATH = $agentTurnConfigPatchPath
$env:OPENCLAW_PARALLELS_AGENT_RUNTIME_POLICY_SUPPORTED = if ($agentTurnRuntimePolicySupported) { '1' } else { '0' }
$agentTurnConfigPatchScriptPath = Join-Path ([System.IO.Path]::GetTempPath()) 'openclaw-agent-turn-config-patch.cjs'
@'
const fs = require("node:fs");
const path = require("node:path");
const configPath = process.env.OPENCLAW_PARALLELS_AGENT_CONFIG_PATH;
const payload = JSON.parse(process.env.OPENCLAW_PARALLELS_AGENT_CONFIG_PATCH || "{}");
const canWriteAgentRuntime = process.env.OPENCLAW_PARALLELS_AGENT_RUNTIME_POLICY_SUPPORTED === "1";
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\\uFEFF/u, ""));
}
const cfg = fs.existsSync(configPath) ? readJsonFile(configPath) : {};
cfg.agents = cfg.agents && typeof cfg.agents === "object" ? cfg.agents : {};
cfg.agents.defaults = cfg.agents.defaults && typeof cfg.agents.defaults === "object" ? cfg.agents.defaults : {};
cfg.agents.defaults.skipBootstrap = true;
const existingModel = cfg.agents.defaults.model && typeof cfg.agents.defaults.model === "object" ? cfg.agents.defaults.model : {};
cfg.agents.defaults.model = { ...existingModel, primary: payload.modelId };
cfg.agents.defaults.models = cfg.agents.defaults.models && typeof cfg.agents.defaults.models === "object" ? cfg.agents.defaults.models : {};
cfg.tools = cfg.tools && typeof cfg.tools === "object" ? cfg.tools : {};
cfg.tools.profile = "minimal";
cfg.plugins = cfg.plugins && typeof cfg.plugins === "object" && !Array.isArray(cfg.plugins) ? cfg.plugins : {};
cfg.plugins.entries = { [payload.pluginId]: { enabled: true } };
cfg.plugins.allow = [payload.pluginId];
const stateDir = path.dirname(configPath);
fs.rmSync(path.join(stateDir, "npm", "node_modules", "@openclaw", "codex"), { recursive: true, force: true });
for (const op of payload.operations || []) {
const segments = String(op.path || "").match(/(?:[^.[\\]]+)|(?:\\["((?:\\\\.|[^"\\\\])*)"\\])/g) || [];
let cursor = cfg;
for (let i = 0; i < segments.length; i++) {
const raw = segments[i];
const key = raw.startsWith("[") ? JSON.parse(raw.slice(1, -1)) : raw;
if (i === segments.length - 1) {
const existing = cursor[key] && typeof cursor[key] === "object" && !Array.isArray(cursor[key]) ? cursor[key] : {};
cursor[key] = op.value && typeof op.value === "object" && !Array.isArray(op.value) ? { ...existing, ...op.value } : op.value;
} else {
cursor[key] = cursor[key] && typeof cursor[key] === "object" && !Array.isArray(cursor[key]) ? cursor[key] : {};
cursor = cursor[key];
}
}
}
const selectedModelEntry = cfg.agents.defaults.models[payload.modelId];
if (selectedModelEntry && typeof selectedModelEntry === "object" && !Array.isArray(selectedModelEntry)) {
if (canWriteAgentRuntime) {
selectedModelEntry.agentRuntime = { id: "openclaw" };
} else {
delete selectedModelEntry.agentRuntime;
}
}
const providerId = String(payload.modelId || "").split("/", 1)[0];
const providerModelId = String(payload.modelId || "").slice(providerId.length + 1);
const providerEntry = cfg.models && typeof cfg.models === "object" && cfg.models.providers && typeof cfg.models.providers === "object" ? cfg.models.providers[providerId] : undefined;
if (providerEntry && typeof providerEntry === "object" && !Array.isArray(providerEntry)) {
delete providerEntry.agentRuntime;
if (Array.isArray(providerEntry.models)) {
for (const model of providerEntry.models) {
if (model && typeof model === "object" && (model.id === providerModelId || model.id === payload.modelId || model.name === providerModelId || model.name === payload.modelId)) {
delete model.agentRuntime;
}
}
}
}
fs.mkdirSync(path.dirname(configPath), { recursive: true });
fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2) + "\\n", { mode: 0o600 });
'@ | Set-Content -Path $agentTurnConfigPatchScriptPath -Encoding UTF8
node.exe $agentTurnConfigPatchScriptPath
$agentTurnConfigPatchExit = $LASTEXITCODE
Remove-Item $agentTurnConfigPatchScriptPath -Force -ErrorAction SilentlyContinue
Remove-Item Env:OPENCLAW_PARALLELS_AGENT_CONFIG_PATCH -Force -ErrorAction SilentlyContinue
Remove-Item Env:OPENCLAW_PARALLELS_AGENT_CONFIG_PATH -Force -ErrorAction SilentlyContinue
Remove-Item Env:OPENCLAW_PARALLELS_AGENT_RUNTIME_POLICY_SUPPORTED -Force -ErrorAction SilentlyContinue
if ($agentTurnConfigPatchExit -ne 0) { throw "agent turn config patch failed" }`;
}
export const windowsOpenClawResolver = String.raw`$portableNode = if ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA 'Programs\nodejs' } else { $null }
if ($portableNode -and (Test-Path (Join-Path $portableNode 'node.exe'))) {
$env:PATH = "$portableNode;$env:PATH"
}
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
$previousErrorActionPreference = $ErrorActionPreference
$previousNativeErrorActionPreference = $PSNativeCommandUseErrorActionPreference
$ErrorActionPreference = 'Continue'
$PSNativeCommandUseErrorActionPreference = $false
try {
if ($command.Kind -eq 'node') {
& node.exe $command.Path @OpenClawArgs
} else {
& $command.Path @OpenClawArgs
}
} finally {
$ErrorActionPreference = $previousErrorActionPreference
$PSNativeCommandUseErrorActionPreference = $previousNativeErrorActionPreference
}
}`;

View File

@@ -0,0 +1,229 @@
// Provider Auth script supports OpenClaw repository automation.
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { parsePositiveInt, readPositiveIntEnv } from "./env-limits.ts";
import { die, run } from "./host-command.ts";
import type { Mode, Platform, Provider, ProviderAuth } from "./types.ts";
type ResolveLatestVersionDeps = {
createTempDir?: typeof mkdtempSync;
removeDir?: typeof rmSync;
runCommand?: typeof run;
tempDir?: typeof tmpdir;
writeFile?: typeof writeFileSync;
};
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 === "" || value.startsWith("-")) {
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 resolveWindowsProviderAuth(input: {
provider: Provider;
apiKeyEnv?: string;
modelId?: string;
}): ProviderAuth {
const auth = resolveProviderAuth(input);
if (input.provider !== "openai" || input.modelId) {
return auth;
}
const windowsModel = process.env.OPENCLAW_PARALLELS_WINDOWS_OPENAI_MODEL?.trim();
if (windowsModel) {
return { ...auth, modelId: windowsModel };
}
if (process.env.OPENCLAW_PARALLELS_OPENAI_MODEL?.trim()) {
return auth;
}
return { ...auth, modelId: "openai/gpt-5.5" };
}
export function providerIdFromModelId(modelId: string): string {
const providerId = modelId.split("/", 1)[0]?.trim() ?? "";
return /^[A-Za-z0-9_-]+$/u.test(providerId) ? providerId : "";
}
export function resolveParallelsModelTimeoutSeconds(platform?: Platform): number {
const platformEnvName =
platform === undefined
? undefined
: `OPENCLAW_PARALLELS_${platform.toUpperCase()}_MODEL_TIMEOUT_S`;
const platformEnv = platformEnvName === undefined ? undefined : process.env[platformEnvName];
const defaultSeconds = platform === "macos" || platform === "windows" ? 1800 : 900;
if (platformEnvName && platformEnv?.trim()) {
return parsePositiveInt(platformEnv, platformEnvName);
}
return readPositiveIntEnv("OPENCLAW_PARALLELS_MODEL_TIMEOUT_S", defaultSeconds);
}
export function providerTimeoutConfigJson(
modelId: string,
platform: Platform,
timeoutSeconds = resolveParallelsModelTimeoutSeconds(platform),
): string {
const providerId = providerIdFromModelId(modelId);
if (providerId !== "openai") {
return "";
}
const modelName = modelId.slice("openai/".length).trim();
if (!modelName) {
return "";
}
return JSON.stringify({
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
models: [
{
contextWindow: 1_047_576,
id: modelName,
maxTokens: 32_768,
name: modelName,
},
],
timeoutSeconds,
});
}
export function modelTransportConfigJson(modelId: string): string {
if (providerIdFromModelId(modelId) !== "openai") {
return "";
}
return JSON.stringify({
alias: "GPT",
params: {
transport: "sse",
},
});
}
export function configPathMapKey(key: string): string {
return `[${JSON.stringify(key)}]`;
}
export function modelProviderConfigBatchJson(
modelId: string,
platform: Platform,
timeoutSeconds = resolveParallelsModelTimeoutSeconds(platform),
): string {
const commands: Array<{ path: string; value: unknown }> = [];
const providerId = providerIdFromModelId(modelId);
const providerConfig = providerTimeoutConfigJson(modelId, platform, timeoutSeconds);
if (providerId && providerConfig) {
commands.push({
path: `models.providers.${providerId}`,
value: JSON.parse(providerConfig) as unknown,
});
}
const modelTransportConfig = modelTransportConfigJson(modelId);
if (modelTransportConfig) {
commands.push({
path: `agents.defaults.models${configPathMapKey(modelId)}`,
value: JSON.parse(modelTransportConfig) as unknown,
});
}
return commands.length === 0 ? "" : JSON.stringify(commands);
}
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") {
if (result.has(entry)) {
die(`duplicate --platform entry: ${entry}`);
}
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 = "",
deps: ResolveLatestVersionDeps = {},
): string {
if (versionOverride) {
return versionOverride;
}
const createTempDir = deps.createTempDir ?? mkdtempSync;
const removeDir = deps.removeDir ?? rmSync;
const runCommand = deps.runCommand ?? run;
const resolveTempDir = deps.tempDir ?? tmpdir;
const writeFile = deps.writeFile ?? writeFileSync;
const userConfigDir = createTempDir(path.join(resolveTempDir(), "openclaw-npm-"));
const userConfigPath = path.join(userConfigDir, "npmrc");
try {
writeFile(userConfigPath, "", "utf8");
return runCommand("npm", ["view", "openclaw", "version", "--userconfig", userConfigPath], {
quiet: true,
}).stdout.trim();
} finally {
removeDir(userConfigDir, { force: true, recursive: true });
}
}

View File

@@ -0,0 +1,360 @@
// Smoke Common helper supports OpenClaw script workflows.
import { readFile, rm } from "node:fs/promises";
import path from "node:path";
import { extractLastOpenClawVersionFromLog } from "./filesystem.ts";
import { run, say } from "./host-command.ts";
import { resolveHostIp, resolveHostPort } from "./host-server.ts";
import { startHostServer } from "./host-server.ts";
import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts";
import {
packageBuildCommitFromTgz,
packageVersionFromTgz,
packOpenClaw,
} from "./package-artifact.ts";
import type { HostServer, Mode, PackageArtifact, Provider, SnapshotInfo } from "./types.ts";
export interface SmokeHostOptions {
hostIp?: string;
hostPort: number;
hostPortExplicit: boolean;
}
export interface SmokeRunOptions {
installVersion?: string;
json: boolean;
keepServer: boolean;
mode: Mode;
provider: Provider;
snapshotHint: string;
targetPackageSpec?: string;
}
export interface SmokeLaneStatuses {
freshAgent: string;
freshGateway: string;
freshMain: string;
freshVersion: string;
latestInstalledVersion: string;
upgrade: string;
upgradeAgent: string;
upgradeGateway: string;
upgradeVersion: string;
}
export interface CommonSmokeSummary {
currentHead: string;
freshMain: {
agent: string;
gateway: string;
status: string;
version: string;
};
installVersion: string;
latestVersion: string;
mode: Mode;
provider: Provider;
runDir: string;
snapshotHint: string;
snapshotId: string;
targetPackageSpec: string;
upgrade: {
agent: string;
gateway: string;
latestVersionInstalled: string;
mainVersion: string;
status: string;
};
vm: string;
}
export abstract class SmokeRunController<TOptions extends SmokeRunOptions & SmokeHostOptions> {
protected hostIp = "";
protected hostPort = 0;
protected runDir = "";
protected server: HostServer | null = null;
protected tgzDir = "";
protected constructor(protected options: TOptions) {}
protected abstract runFreshLane(): Promise<void>;
protected abstract runUpgradeLane(): Promise<void>;
protected abstract writeSummary(): Promise<string>;
protected abstract printSummary(summaryPath: string): void;
protected abstract status: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">;
protected async prepareHost(
defaultPort: number,
latestVersion: string,
snapshot: SnapshotInfo,
vmName: string,
): Promise<void> {
[this.hostIp, this.hostPort] = await prepareSmokeRunHost(
this.options,
defaultPort,
latestVersion,
this.runDir,
snapshot,
this.options.snapshotHint,
vmName,
);
}
protected async runLanesAndFinish(): Promise<void> {
await runSmokeLanesAndFinish(
this.options.mode,
this.options.json,
this.status,
async () => this.runFreshLane(),
async () => this.runUpgradeLane(),
async () => this.writeSummary(),
(pathLocal) => this.printSummary(pathLocal),
);
}
protected async cleanupArtifacts(): Promise<void> {
await cleanupSmokeArtifacts({
keepServer: this.options.keepServer,
server: this.server,
tgzDir: this.tgzDir,
});
}
}
export async function resolveSmokeHostConfig(
options: SmokeHostOptions,
defaultPort: number,
): Promise<{ hostIp: string; hostPort: number }> {
return {
hostIp: resolveHostIp(options.hostIp),
hostPort: await resolveHostPort(options.hostPort, options.hostPortExplicit, defaultPort),
};
}
export async function prepareSmokeRunHost(
options: SmokeHostOptions,
defaultPort: number,
latestVersion: string,
runDir: string,
snapshot: SnapshotInfo,
snapshotHint: string,
vmName: string,
): Promise<readonly [hostIp: string, hostPort: number]> {
const host = await resolveSmokeHostConfig(options, defaultPort);
logSmokeRunStart({
latestVersion,
runDir,
snapshot,
snapshotHint,
vmName,
});
return [host.hostIp, host.hostPort];
}
export function logSmokeRunStart(input: {
latestVersion: string;
runDir: string;
snapshot: SnapshotInfo;
snapshotHint: string;
vmName: string;
}): void {
say(`VM: ${input.vmName}`);
say(`Snapshot hint: ${input.snapshotHint}`);
say(`Resolved snapshot: ${input.snapshot.name} [${input.snapshot.state}]`);
say(`Latest npm version: ${input.latestVersion}`);
say(`Current head: ${currentGitHeadShort()}`);
say(`Run logs: ${input.runDir}`);
}
export async function startSmokeArtifactServer(input: {
artifact: PackageArtifact;
dir: string;
hostIp: string;
label: string;
port: number;
}): Promise<{ hostPort: number; server: HostServer }> {
const server = await startHostServer({
artifactPath: input.artifact.path,
dir: input.dir,
hostIp: input.hostIp,
label: input.label,
port: input.port,
});
return { hostPort: server.port, server };
}
export async function packAndServeSmokeArtifact(
tgzDir: string,
packageSpec: string | undefined,
hostIp: string,
hostPort: number,
label: string,
requireControlUi = false,
): Promise<readonly [artifact: PackageArtifact, server: HostServer, hostPort: number]> {
const artifact = await packOpenClaw({
destination: tgzDir,
packageSpec,
requireControlUi,
});
const server = await startSmokeArtifactServer({
artifact,
dir: tgzDir,
hostIp,
label,
port: hostPort,
});
return [artifact, server.server, server.hostPort];
}
export async function runRequestedSmokeLanes(input: {
mode: Mode;
runFresh: () => Promise<void>;
runLane: (name: "fresh" | "upgrade", fn: () => Promise<void>) => Promise<void>;
runUpgrade: () => Promise<void>;
}): Promise<void> {
if (input.mode === "fresh" || input.mode === "both") {
await input.runLane("fresh", input.runFresh);
}
if (input.mode === "upgrade" || input.mode === "both") {
await input.runLane("upgrade", input.runUpgrade);
}
}
export async function runSmokeLaneWithStatus(
name: "fresh" | "upgrade",
fn: () => Promise<void>,
statuses: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">,
): Promise<void> {
await runSmokeLane(name, fn, (lane, status) => setSmokeLaneStatus(statuses, lane, status));
}
export function setSmokeLaneStatus(
statuses: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">,
name: SmokeLane,
status: SmokeLaneStatus,
): void {
if (name === "fresh") {
statuses.freshMain = status;
} else {
statuses.upgrade = status;
}
}
export async function finishSmokeRun(input: {
json: boolean;
printSummary: (summaryPath: string) => void;
status: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">;
summaryPath: string;
}): Promise<void> {
if (input.json) {
process.stdout.write(await readFile(input.summaryPath, "utf8"));
} else {
input.printSummary(input.summaryPath);
}
if (input.status.freshMain === "fail" || input.status.upgrade === "fail") {
process.exitCode = 1;
}
}
export async function runSmokeLanesAndFinish(
mode: Mode,
json: boolean,
status: Pick<SmokeLaneStatuses, "freshMain" | "upgrade">,
runFresh: () => Promise<void>,
runUpgrade: () => Promise<void>,
writeSummary: () => Promise<string>,
printSummary: (summaryPath: string) => void,
): Promise<void> {
await runRequestedSmokeLanes({
mode,
runFresh,
runLane: async (name, fn) => runSmokeLaneWithStatus(name, fn, status),
runUpgrade,
});
await finishSmokeRun({
json,
printSummary,
status,
summaryPath: await writeSummary(),
});
}
export async function cleanupSmokeArtifacts(input: {
keepServer: boolean;
server: HostServer | null;
tgzDir: string;
}): Promise<void> {
if (input.keepServer) {
return;
}
await input.server?.stop().catch(() => undefined);
await rm(input.tgzDir, { force: true, recursive: true }).catch(() => undefined);
}
export async function expectedPackageTargetVersion(artifact: PackageArtifact): Promise<string> {
return artifact.version || (await packageVersionFromTgz(artifact.path));
}
export async function expectedPackageBuildCommit(artifact: PackageArtifact): Promise<string> {
return artifact.buildCommitShort || (await packageBuildCommitFromTgz(artifact.path)).slice(0, 7);
}
export async function extractLastOpenClawVersion(
runDir: string,
phaseName: string,
pattern: RegExp,
): Promise<string> {
return await extractLastOpenClawVersionFromLog(path.join(runDir, `${phaseName}.log`), pattern);
}
export function buildCommonSmokeSummary(input: {
artifact: PackageArtifact | null;
latestVersion: string;
options: SmokeRunOptions;
runDir: string;
snapshot: SnapshotInfo;
status: SmokeLaneStatuses;
vmName: string;
}): CommonSmokeSummary {
return {
currentHead: input.artifact?.buildCommitShort || currentGitHeadShort(),
freshMain: {
agent: input.status.freshAgent,
gateway: input.status.freshGateway,
status: input.status.freshMain,
version: input.status.freshVersion,
},
installVersion: input.options.installVersion || "",
latestVersion: input.latestVersion,
mode: input.options.mode,
provider: input.options.provider,
runDir: input.runDir,
snapshotHint: input.options.snapshotHint,
snapshotId: input.snapshot.id,
targetPackageSpec: input.options.targetPackageSpec || "",
upgrade: {
agent: input.status.upgradeAgent,
gateway: input.status.upgradeGateway,
latestVersionInstalled: input.status.latestInstalledVersion,
mainVersion: input.status.upgradeVersion,
status: input.status.upgrade,
},
vm: input.vmName,
};
}
export function printSmokeTargetSummary(input: {
includeInstallVersion?: boolean;
installVersion?: string;
targetPackageSpec?: string;
}): void {
if (input.targetPackageSpec) {
process.stdout.write(` target-package: ${input.targetPackageSpec}\n`);
}
if (input.includeInstallVersion !== false && input.installVersion) {
process.stdout.write(` baseline-install-version: ${input.installVersion}\n`);
}
}
function currentGitHeadShort(): string {
return run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim();
}

View File

@@ -0,0 +1,115 @@
// Snapshots script supports OpenClaw repository automation.
import { die, run } from "./host-command.ts";
import type { Mode } from "./types.ts";
import type { SnapshotInfo } from "./types.ts";
const SNAPSHOT_LIST_TIMEOUT_MS = 120_000;
export const SKIP_SNAPSHOT_RESTORE_ENV = "OPENCLAW_PARALLELS_SKIP_SNAPSHOT_RESTORE";
export function shouldSkipSnapshotRestore(): boolean {
return /^(1|true|yes|on)$/iu.test(process.env[SKIP_SNAPSHOT_RESTORE_ENV] ?? "");
}
export function validateSnapshotRestoreMode(mode: Mode, platform: string): void {
if (!shouldSkipSnapshotRestore() || mode !== "both") {
return;
}
die(
`${SKIP_SNAPSHOT_RESTORE_ENV}=1 requires --mode fresh or --mode upgrade for ${platform}; --mode both would reuse the same mutated guest for both lanes`,
);
}
export function currentRunningSnapshotInfo(vmName: string): SnapshotInfo {
return {
id: "current-running-vm",
name: `current running ${vmName}`,
state: "running",
};
}
export function resolveSnapshot(vmName: string, hint: string): SnapshotInfo {
const output = run("prlctl", ["snapshot-list", vmName, "--json"], {
quiet: true,
timeoutMs: SNAPSHOT_LIST_TIMEOUT_MS,
}).stdout;
if (!output.trim()) {
die(
`prlctl snapshot-list ${vmName} --json returned no snapshots; create/restore a snapshot or set ${SKIP_SNAPSHOT_RESTORE_ENV}=1 for an already-started guest`,
);
}
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.flatMap((value) => {
const withoutLatest = value.replace(/\s+latest$/u, "").trim();
return withoutLatest && withoutLatest !== value ? [value, withoutLatest] : [value];
});
};
const normalizedHint = hint.trim().toLowerCase();
const normalizedHints = [normalizedHint, normalizedHint.replace(/\s+latest$/u, "").trim()].filter(
(value, index, values) => value && values.indexOf(value) === index,
);
for (const [id, meta] of Object.entries(payload)) {
const name = (meta.name ?? "").trim();
if (!name) {
continue;
}
let score = 0;
for (const hintAlias of normalizedHints) {
for (const alias of aliases(name.toLowerCase())) {
if (alias === hintAlias) {
score = Math.max(score, 10);
} else if (hintAlias && alias.includes(hintAlias)) {
score = Math.max(score, 5 + hintAlias.length / Math.max(alias.length, 1));
} else {
score = Math.max(score, stringSimilarity(hintAlias, 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,47 @@
// Types script supports OpenClaw repository automation.
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,79 @@
// Update Job Timeout script supports OpenClaw repository automation.
import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
interface TimedUpdateJobOptions {
abortSettleMs?: number;
append(this: void, chunk: string): void;
label: string;
run(this: void, context: { signal: AbortSignal }): Promise<void> | void;
timeoutDescription: string;
timeoutMs: number;
writeLog(this: void): Promise<void>;
}
export async function runTimedUpdateJob({
abortSettleMs = 2_500,
append,
label,
run,
timeoutDescription,
timeoutMs,
writeLog,
}: TimedUpdateJobOptions): Promise<number> {
let timedOut = false;
const controller = new AbortController();
const timeoutMessage = `${label} update timed out after ${timeoutDescription}`;
const resolvedAbortSettleMs = resolveTimerTimeoutMs(abortSettleMs, 0, 0);
const resolvedTimeoutMs = resolveTimerTimeoutMs(timeoutMs, 1);
let timeout: NodeJS.Timeout | undefined;
const runOutcome = Promise.resolve()
.then(() => run({ signal: controller.signal }))
.then(
() => ({ status: "pass" as const }),
(error: unknown) => ({ error, status: "fail" as const }),
);
const timeoutPromise = new Promise<"timeout">((resolve) => {
timeout = setTimeout(() => {
timedOut = true;
append(`${timeoutMessage}\n`);
controller.abort(new Error(timeoutMessage));
resolve("timeout");
}, resolvedTimeoutMs);
});
try {
const outcome = await Promise.race([runOutcome, timeoutPromise]);
if (outcome === "timeout") {
await waitForAbortSettle(runOutcome, resolvedAbortSettleMs);
await writeLog();
return 1;
}
if (outcome.status === "fail") {
append(`${outcome.error instanceof Error ? outcome.error.message : String(outcome.error)}\n`);
await writeLog();
return 1;
}
await writeLog();
return 0;
} catch (error) {
if (!timedOut) {
append(`${error instanceof Error ? error.message : String(error)}\n`);
}
await writeLog();
return 1;
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}
async function waitForAbortSettle<T>(runOutcome: Promise<T>, ms: number): Promise<T | undefined> {
return await new Promise((resolve) => {
const timeout = setTimeout(resolve, ms);
void runOutcome.then((outcome) => {
clearTimeout(timeout);
resolve(outcome);
});
});
}

View File

@@ -0,0 +1,135 @@
// Windows Git script supports OpenClaw repository automation.
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
preferred_names = [
"MinGit-2.53.0.2-64-bit.zip",
"MinGit-2.53.0.2-arm64.zip",
]
fallback_urls = {
"MinGit-2.53.0.2-arm64.zip": "https://github.com/git-for-windows/git/releases/download/v2.53.0.windows.2/MinGit-2.53.0.2-arm64.zip",
"MinGit-2.53.0.2-64-bit.zip": "https://github.com/git-for-windows/git/releases/download/v2.53.0.windows.2/MinGit-2.53.0.2-64-bit.zip",
}
try:
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)
except Exception:
print(preferred_names[0])
print(fallback_urls[preferred_names[0]])
raise SystemExit(0)
assets = data.get("assets", [])
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 "-64-bit." in name:
rank = 0
elif "-arm64." 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 --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${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,856 @@
#!/usr/bin/env -S pnpm tsx
// Windows Smoke script supports OpenClaw repository automation.
import path from "node:path";
import { pathToFileURL } from "node:url";
import { windowsAgentWorkspaceScript } from "./agent-workspace.ts";
import {
die,
ensureValue,
currentRunningSnapshotInfo,
makeTempDir,
parseMode,
parseTcpPort,
parseProvider,
readPositiveIntEnv,
resolveLatestVersion,
resolveParallelsModelTimeoutSeconds,
resolveWindowsProviderAuth,
resolveSnapshot,
run,
say,
shouldSkipSnapshotRestore,
validateSnapshotRestoreMode,
warn,
withProgressOnStderr,
writeSummaryMarkdown,
writeJson,
type Mode,
type PackageArtifact,
type Provider,
type ProviderAuth,
type SnapshotInfo,
} from "./common.ts";
import { runWindowsBackgroundPowerShell, WindowsGuest } from "./guest-transports.ts";
import { startHostServer } from "./host-server.ts";
import { ensureVmRunning } from "./parallels-vm.ts";
import { PhaseRunner } from "./phase-runner.ts";
import {
windowsCodexPlatformPackageRepairFunction,
windowsProviderOnlyPluginIsolationScript,
} from "./plugin-isolation.ts";
import {
psSingleQuote,
windowsAgentTurnConfigPatchScript,
windowsOpenClawResolver,
windowsScopedEnvFunction,
} from "./powershell.ts";
import {
buildCommonSmokeSummary,
expectedPackageBuildCommit,
expectedPackageTargetVersion,
extractLastOpenClawVersion,
packAndServeSmokeArtifact,
printSmokeTargetSummary,
SmokeRunController,
type SmokeHostOptions,
type SmokeRunOptions,
} from "./smoke-common.ts";
import { ensureGuestGit, prepareMinGitZip } from "./windows-git.ts";
interface WindowsOptions extends SmokeHostOptions, SmokeRunOptions {
vmName: string;
apiKeyEnv?: string;
modelId?: string;
installUrl: string;
latestVersion?: string;
upgradeFromPackedMain: boolean;
skipLatestRefCheck: 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 WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS = 900;
const WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS = WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS * 1000;
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",
});
const windowsPortableGitPathScript = `$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`;
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.
`;
}
export function parseArgs(argv: string[]): WindowsOptions {
const args = stripLeadingPackageManagerSeparator(argv);
const options = defaultOptions();
const valueHandlers: Record<string, (value: string) => void> = {
"--api-key-env": (value) => {
options.apiKeyEnv = value;
},
"--host-ip": (value) => {
options.hostIp = value;
},
"--host-port": (value) => {
options.hostPort = parseTcpPort(value, "--host-port");
options.hostPortExplicit = true;
},
"--install-url": (value) => {
options.installUrl = value;
},
"--install-version": (value) => {
options.installVersion = value;
},
"--latest-version": (value) => {
options.latestVersion = value;
},
"--model": (value) => {
options.modelId = value;
},
"--openai-api-key-env": (value) => {
options.apiKeyEnv = value;
},
"--provider": (value) => {
options.provider = parseProvider(value);
},
"--snapshot-hint": (value) => {
options.snapshotHint = value;
},
"--target-package-spec": (value) => {
options.targetPackageSpec = value;
},
"--vm": (value) => {
options.vmName = value;
},
"--mode": (value) => {
options.mode = parseMode(value);
},
};
const flagHandlers: Record<string, () => void> = {
"--json": () => {
options.json = true;
},
"--keep-server": () => {
options.keepServer = true;
},
"--skip-latest-ref-check": () => {
options.skipLatestRefCheck = true;
},
"--upgrade-from-packed-main": () => {
options.upgradeFromPackedMain = true;
},
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--") {
break;
}
const valueHandler = valueHandlers[arg];
if (valueHandler) {
valueHandler(ensureValue(args, i, arg));
i++;
continue;
}
const flagHandler = flagHandlers[arg];
if (flagHandler) {
flagHandler();
continue;
}
if (arg === "-h" || arg === "--help") {
process.stdout.write(usage());
process.exit(0);
}
die(`unknown arg: ${arg}`);
}
return options;
}
function stripLeadingPackageManagerSeparator(argv: string[]): string[] {
return argv[0] === "--" ? argv.slice(1) : argv;
}
class WindowsSmoke extends SmokeRunController<WindowsOptions> {
private auth: ProviderAuth;
private agentTimeoutSeconds = readPositiveIntEnv(
"OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S",
2700,
);
private updateTimeoutSeconds = readPositiveIntEnv(
"OPENCLAW_PARALLELS_WINDOWS_UPDATE_TIMEOUT_S",
1200,
);
private gatewayRecoveryAfterMs =
readPositiveIntEnv("OPENCLAW_PARALLELS_WINDOWS_GATEWAY_RECOVERY_AFTER_S", 180) * 1000;
private artifact: PackageArtifact | null = null;
private minGitZipPath = "";
private latestVersion = "";
private installVersion = "";
private snapshot!: SnapshotInfo;
private phases!: PhaseRunner;
private guest!: WindowsGuest;
protected status = {
freshAgent: "skip",
freshGateway: "skip",
freshMain: "skip",
freshVersion: "skip",
latestInstalledVersion: "skip",
upgrade: "skip",
upgradeAgent: "skip",
upgradeGateway: "skip",
upgradePrecheck: "skip",
upgradeVersion: "skip",
};
constructor(options: WindowsOptions) {
super(options);
this.auth = resolveWindowsProviderAuth({
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 {
validateSnapshotRestoreMode(this.options.mode, "Windows smoke");
this.snapshot = shouldSkipSnapshotRestore()
? currentRunningSnapshotInfo(this.options.vmName)
: resolveSnapshot(this.options.vmName, this.options.snapshotHint);
this.latestVersion = resolveLatestVersion(this.options.latestVersion);
this.installVersion = this.options.installVersion || this.latestVersion;
await this.prepareHost(
defaultOptions().hostPort,
this.latestVersion,
this.snapshot,
this.options.vmName,
);
this.minGitZipPath = await prepareMinGitZip(this.tgzDir);
if (this.needsHostTgz()) {
[this.artifact, this.server, this.hostPort] = await packAndServeSmokeArtifact(
this.tgzDir,
this.options.targetPackageSpec,
this.hostIp,
this.hostPort,
this.artifactLabel(),
);
}
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;
}
await this.runLanesAndFinish();
} finally {
await this.cleanupArtifacts();
}
}
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";
}
protected 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.preflight", 120, () => this.logGuestPreflight(true));
await this.phase("fresh.install-main", WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS, () =>
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", this.agentTimeoutSeconds, () => this.verifyTurn());
this.status.freshAgent = "pass";
}
protected 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 }),
);
await this.phase("upgrade.preflight", 120, () => this.logGuestPreflight(false));
if (this.options.targetPackageSpec || this.options.upgradeFromPackedMain) {
await this.phase(
"upgrade.install-baseline-package",
WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS,
() => 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", WINDOWS_PACKAGE_INSTALL_TIMEOUT_SECONDS, () =>
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.gateway-stop-before-update", 420, () => this.gatewayAction("stop"));
await this.phase("upgrade.update-dev", this.updateTimeoutSeconds, () =>
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", this.agentTimeoutSeconds, () => this.verifyTurn());
this.status.upgradeAgent = "pass";
}
private phase = async (name: string, timeoutSeconds: number, fn: () => Promise<void> | void) =>
await this.phases.phase(name, timeoutSeconds, fn);
private remainingPhaseTimeoutMs = (fallbackMs?: number): number | undefined =>
this.phases.remainingTimeoutMs(fallbackMs);
private phaseReturns = async (
name: string,
timeoutSeconds: number,
fn: () => Promise<void> | void,
): Promise<boolean> => await this.phases.phaseReturns(name, timeoutSeconds, fn);
private log = (text: string): void => this.phases.append(text);
private guestPowerShell(
script: string,
options: { check?: boolean; timeoutMs?: number } = {},
): string {
return this.guest.powershell(`${windowsOpenClawResolver}\n${script}`, options);
}
private restoreSnapshot(): void {
if (shouldSkipSnapshotRestore()) {
say(`Skip snapshot restore; using current running VM ${this.options.vmName}`);
return;
}
this.waitForVmNotRestoring(240);
say(`Restore snapshot ${this.options.snapshotHint} (${this.snapshot.id})`);
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,
timeoutMs: this.remainingPhaseTimeoutMs(),
},
);
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);
ensureVmRunning(this.options.vmName, 240, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
transitionTimeoutMs: () => this.remainingPhaseTimeoutMs(120_000),
});
}
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,
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
}).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) {
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 logGuestPreflight(cleanOpenClaw: boolean): void {
const cleanScript = cleanOpenClaw
? "npm.cmd uninstall -g openclaw --no-fund --no-audit --loglevel=error 2>$null; $global:LASTEXITCODE = 0"
: "";
this.guestPowerShell(
`$ErrorActionPreference = 'Continue'
cmd.exe /d /s /c whoami
Write-Host "USERPROFILE=$env:USERPROFILE"
Write-Host "PATH=$env:PATH"
npm.cmd root -g
${cleanScript}`,
{ check: false, timeoutMs: 120_000 },
);
}
private installLatestRelease(): Promise<void> {
const versionArg = this.installVersion ? ` -Tag ${psSingleQuote(this.installVersion)}` : "";
return this.guestPowerShellBackground(
"install-latest",
`$ErrorActionPreference = 'Stop'
$script = Invoke-RestMethod -Uri ${psSingleQuote(this.options.installUrl)} -TimeoutSec 120
& ([scriptblock]::Create($script))${versionArg} -NoOnboard
if ($LASTEXITCODE -ne 0) { throw "installer failed with exit code $LASTEXITCODE" }
Invoke-OpenClaw --version
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
this.remainingPhaseTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ??
WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS,
);
}
private installMain(tempName: string): Promise<void> {
if (!this.artifact || !this.server) {
die("package artifact/server missing");
}
const tgzUrl = this.server.urlFor(this.artifact.path);
return this.guestPowerShellBackground(
`install-main-${tempName.replaceAll(/[^A-Za-z0-9_-]/g, "-")}`,
`$ErrorActionPreference = 'Stop'
$tgz = Join-Path $env:TEMP ${psSingleQuote(tempName)}
curl.exe -fsSL --connect-timeout 10 --max-time 120 --retry 2 --retry-delay 2 ${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" }
Invoke-OpenClaw --version
if ($LASTEXITCODE -ne 0) { throw "openclaw --version failed with exit code $LASTEXITCODE" }`,
this.remainingPhaseTimeoutMs(WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS) ??
WINDOWS_PACKAGE_INSTALL_TIMEOUT_MS,
);
}
private async verifyTargetVersion(): Promise<void> {
if (this.options.targetPackageSpec) {
if (!this.artifact) {
die("package artifact missing");
}
this.verifyVersionContains(await expectedPackageTargetVersion(this.artifact));
return;
}
if (!this.artifact) {
die("package artifact missing");
}
this.verifyVersionContains(await expectedPackageBuildCommit(this.artifact));
}
private verifyVersionContains(needle: string): void {
const version = this.guestPowerShell("Invoke-OpenClaw --version");
if (!version.includes(needle)) {
throw new Error(`version mismatch: expected substring ${needle}`);
}
}
private async captureLatestRefFailure(): Promise<void> {
await this.runRefOnboard();
this.showGatewayStatusCompat();
}
private runRefOnboard(): Promise<void> {
return this.guestPowerShellBackground(
"ref-onboard",
`$ErrorActionPreference = 'Continue'
$PSNativeCommandUseErrorActionPreference = $false
Set-Item -Path ('Env:' + ${psSingleQuote(this.auth.apiKeyEnv)}) -Value ${psSingleQuote(this.auth.apiKeyValue)}
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" }
${this.windowsPluginIsolationScript()}`,
720_000,
);
}
private windowsPluginIsolationScript(): string {
return windowsProviderOnlyPluginIsolationScript({
fallbackPluginId: this.options.provider,
modelId: this.auth.modelId,
});
}
private async guestPowerShellBackground(
label: string,
script: string,
timeoutMs: number,
): Promise<void> {
await runWindowsBackgroundPowerShell({
append: (chunk) =>
this.log(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")),
beforeLaunchAttempt: () => {
ensureVmRunning(this.options.vmName, 120);
this.waitForGuestReady(120);
},
label,
onLaunchRetry: warn,
script: `${windowsOpenClawResolver}\n${script}`,
timeoutMs: this.remainingPhaseTimeoutMs(timeoutMs) ?? timeoutMs,
vmName: this.options.vmName,
});
}
private runDevChannelUpdate(): void {
this.guestPowerShell(
`$ErrorActionPreference = 'Stop'
${windowsPortableGitPathScript}
$configPath = Join-Path $env:USERPROFILE '.openclaw\\openclaw.json'
$config = Get-Content $configPath -Raw | ConvertFrom-Json
if ($null -eq $config.update) {
$config | Add-Member -MemberType NoteProperty -Name update -Value ([pscustomobject]@{})
}
$config.update | Add-Member -Force -MemberType NoteProperty -Name channel -Value 'dev'
$config | ConvertTo-Json -Depth 100 | Set-Content -Path $configPath -Encoding utf8
${windowsScopedEnvFunction}
$script:OpenClawUpdateExit = 0
Invoke-WithScopedEnv @{ OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS = '1'; OPENCLAW_DISABLE_BUNDLED_PLUGINS = '1' } {
Invoke-OpenClaw update --channel dev --yes --json
$script:OpenClawUpdateExit = $LASTEXITCODE
}
if ($script:OpenClawUpdateExit -ne 0) { throw "openclaw update failed with exit code $script:OpenClawUpdateExit" }
Invoke-OpenClaw --version
Invoke-OpenClaw update status --json`,
{ timeoutMs: this.updateTimeoutSeconds * 1000 },
);
}
private verifyDevChannelUpdate(): void {
const status = this.guestPowerShell(
`${windowsPortableGitPathScript}
Invoke-OpenClaw 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"): Promise<void> {
return this.guestPowerShellBackground(
`gateway-${action}`,
`$ErrorActionPreference = 'Continue'
$PSNativeCommandUseErrorActionPreference = $false
Invoke-OpenClaw gateway ${action}
if ($LASTEXITCODE -ne 0) { throw "gateway ${action} failed with exit code $LASTEXITCODE" }`,
420_000,
);
}
private verifyGatewayReachable(): void {
const deadline = Date.now() + 420_000;
let attempt = 1;
let recoveryTried = false;
const start = Date.now();
while (Date.now() < deadline) {
const probe = this.guestPowerShell(
"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)) {
return;
}
if (!recoveryTried && Date.now() - start >= this.gatewayRecoveryAfterMs) {
warn(
`gateway-reachable recovery: gateway start after ${Math.floor((Date.now() - start) / 1000)}s`,
);
this.guestPowerShell("Invoke-OpenClaw 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("Invoke-OpenClaw gateway status --help", {
check: false,
});
const suffix = help.includes("--require-rpc") ? "--deep --require-rpc" : "--deep";
this.guestPowerShell(`Invoke-OpenClaw gateway status ${suffix}`);
}
private verifyTurn(): Promise<void> {
return this.guestPowerShellBackground(
"agent-turn",
`$ErrorActionPreference = 'Continue'
$PSNativeCommandUseErrorActionPreference = $false
${windowsPortableGitPathScript}
${windowsAgentTurnConfigPatchScript(this.auth.modelId)}
${windowsAgentWorkspaceScript("Parallels Windows smoke test assistant.")}
${windowsCodexPlatformPackageRepairFunction()}
Set-Item -Path ('Env:' + ${psSingleQuote(this.auth.apiKeyEnv)}) -Value ${psSingleQuote(this.auth.apiKeyValue)}
$agentOk = $false
for ($attempt = 1; $attempt -le 2; $attempt++) {
$sessionId = if ($attempt -eq 1) { 'parallels-windows-smoke' } else { "parallels-windows-smoke-retry-$attempt" }
$sessionsDir = Join-Path $env:USERPROFILE '.openclaw\\agents\\main\\sessions'
$sessionPath = Join-Path $sessionsDir "$sessionId.jsonl"
Remove-Item $sessionPath -Force -ErrorAction SilentlyContinue
$args = @(
'agent',
'--local',
'--agent',
'main',
'--session-id',
$sessionId,
'--message',
'Reply with exact ASCII text OK only.',
'--thinking',
'off',
'--timeout',
'${resolveParallelsModelTimeoutSeconds("windows")}',
'--json'
)
$output = Invoke-OpenClaw @args 2>&1
$agentExitCode = $LASTEXITCODE
if ($null -ne $output) { $output | ForEach-Object { $_ } }
if ($agentExitCode -eq 0 -and ($output | Out-String) -match '"finalAssistant(Raw|Visible)Text":\\s*"OK"') {
$agentOk = $true
break
}
if ($agentExitCode -ne 0 -and $attempt -lt 2 -and (Repair-MissingCodexPlatformPackage -Output $output)) {
Write-Host "agent turn attempt $attempt hit a missing Codex platform package; retrying"
continue
}
if ($attempt -lt 2) {
Write-Host "agent turn attempt $attempt failed or finished without OK response; retrying"
Start-Sleep -Seconds 3
continue
}
if ($agentExitCode -ne 0) {
throw "agent failed with exit code $agentExitCode"
}
}
if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`,
this.agentTimeoutSeconds * 1000,
);
}
private async extractLastVersion(phaseName: string): Promise<string> {
return await extractLastOpenClawVersion(this.runDir, phaseName, /OpenClaw\s+([0-9][^\s]*)/gi);
}
protected async writeSummary(): Promise<string> {
const common = buildCommonSmokeSummary({
artifact: this.artifact,
latestVersion: this.latestVersion,
options: this.options,
runDir: this.runDir,
snapshot: this.snapshot,
status: this.status,
vmName: this.options.vmName,
});
const summary: WindowsSummary = {
...common,
upgrade: {
...common.upgrade,
precheck: this.status.upgradePrecheck,
},
};
const summaryPath = path.join(this.runDir, "summary.json");
await writeJson(summaryPath, summary);
await writeSummaryMarkdown({
lines: [
`- vm: ${summary.vm}`,
`- target package: ${summary.targetPackageSpec || "local-main"}`,
`- fresh: ${summary.freshMain.status} (${summary.freshMain.version}), gateway=${summary.freshMain.gateway}, agent=${summary.freshMain.agent}`,
`- upgrade: ${summary.upgrade.status} (${summary.upgrade.mainVersion}), precheck=${summary.upgrade.precheck}, gateway=${summary.upgrade.gateway}, agent=${summary.upgrade.agent}`,
`- logs: ${summary.runDir}`,
],
summaryPath,
title: "Parallels Windows Smoke",
});
return summaryPath;
}
protected printSummary(summaryPath: string): void {
process.stdout.write("\nSummary:\n");
printSmokeTargetSummary({ ...this.options, includeInstallVersion: false });
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`);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
const options = parseArgs(process.argv.slice(2));
const runSmoke = () => new WindowsSmoke(options).run();
const runPromise = options.json ? withProgressOnStderr(runSmoke) : runSmoke();
await runPromise.catch((error: unknown) => {
die(error instanceof Error ? error.message : String(error));
});
}