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
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:
39
scripts/e2e/parallels/agent-workspace.ts
Normal file
39
scripts/e2e/parallels/agent-workspace.ts
Normal 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`;
|
||||
}
|
||||
13
scripts/e2e/parallels/common.ts
Normal file
13
scripts/e2e/parallels/common.ts
Normal 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";
|
||||
32
scripts/e2e/parallels/env-limits.ts
Normal file
32
scripts/e2e/parallels/env-limits.ts
Normal 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);
|
||||
}
|
||||
92
scripts/e2e/parallels/filesystem.ts
Normal file
92
scripts/e2e/parallels/filesystem.ts
Normal 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 });
|
||||
}
|
||||
614
scripts/e2e/parallels/guest-transports.ts
Normal file
614
scripts/e2e/parallels/guest-transports.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
783
scripts/e2e/parallels/host-command.ts
Normal file
783
scripts/e2e/parallels/host-command.ts
Normal 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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
202
scripts/e2e/parallels/host-server.ts
Normal file
202
scripts/e2e/parallels/host-server.ts
Normal 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,
|
||||
};
|
||||
19
scripts/e2e/parallels/lane-runner.ts
Normal file
19
scripts/e2e/parallels/lane-runner.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
887
scripts/e2e/parallels/linux-smoke.ts
Executable file
887
scripts/e2e/parallels/linux-smoke.ts
Executable 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());
|
||||
}
|
||||
214
scripts/e2e/parallels/macos-discord.ts
Normal file
214
scripts/e2e/parallels/macos-discord.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
1285
scripts/e2e/parallels/macos-smoke.ts
Executable file
1285
scripts/e2e/parallels/macos-smoke.ts
Executable file
File diff suppressed because it is too large
Load Diff
13
scripts/e2e/parallels/macos-users.ts
Normal file
13
scripts/e2e/parallels/macos-users.ts
Normal 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);
|
||||
}
|
||||
480
scripts/e2e/parallels/npm-update-scripts.ts
Normal file
480
scripts/e2e/parallels/npm-update-scripts.ts
Normal 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
|
||||
}`;
|
||||
}
|
||||
1527
scripts/e2e/parallels/npm-update-smoke.ts
Executable file
1527
scripts/e2e/parallels/npm-update-smoke.ts
Executable file
File diff suppressed because it is too large
Load Diff
302
scripts/e2e/parallels/package-artifact.ts
Normal file
302
scripts/e2e/parallels/package-artifact.ts
Normal 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,
|
||||
};
|
||||
154
scripts/e2e/parallels/parallels-vm.ts
Normal file
154
scripts/e2e/parallels/parallels-vm.ts
Normal 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;
|
||||
}
|
||||
129
scripts/e2e/parallels/phase-runner.ts
Normal file
129
scripts/e2e/parallels/phase-runner.ts
Normal 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",
|
||||
);
|
||||
}
|
||||
}
|
||||
208
scripts/e2e/parallels/plugin-isolation.ts
Normal file
208
scripts/e2e/parallels/plugin-isolation.ts
Normal 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");`;
|
||||
}
|
||||
193
scripts/e2e/parallels/powershell.ts
Normal file
193
scripts/e2e/parallels/powershell.ts
Normal 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
|
||||
}
|
||||
}`;
|
||||
229
scripts/e2e/parallels/provider-auth.ts
Normal file
229
scripts/e2e/parallels/provider-auth.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
360
scripts/e2e/parallels/smoke-common.ts
Normal file
360
scripts/e2e/parallels/smoke-common.ts
Normal 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();
|
||||
}
|
||||
115
scripts/e2e/parallels/snapshots.ts
Normal file
115
scripts/e2e/parallels/snapshots.ts
Normal 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);
|
||||
}
|
||||
47
scripts/e2e/parallels/types.ts
Normal file
47
scripts/e2e/parallels/types.ts
Normal 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>;
|
||||
}
|
||||
79
scripts/e2e/parallels/update-job-timeout.ts
Normal file
79
scripts/e2e/parallels/update-job-timeout.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
135
scripts/e2e/parallels/windows-git.ts
Normal file
135
scripts/e2e/parallels/windows-git.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
856
scripts/e2e/parallels/windows-smoke.ts
Executable file
856
scripts/e2e/parallels/windows-smoke.ts
Executable 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));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user