#!/usr/bin/env -S pnpm tsx import { readFile, rm } from "node:fs/promises"; import path from "node:path"; import { posixAgentWorkspaceScript } from "./agent-workspace.ts"; import { die, ensureValue, makeTempDir, packageBuildCommitFromTgz, packageVersionFromTgz, packOpenClaw, parseMode, parseProvider, modelProviderConfigBatchJson, resolveParallelsModelTimeoutSeconds, resolveHostIp, resolveHostPort, resolveLatestVersion, resolveProviderAuth, resolveSnapshot, run, say, shellQuote, startHostServer, warn, writeJson, writeSummaryMarkdown, type HostServer, type Mode, type PackageArtifact, type Provider, type ProviderAuth, type SnapshotInfo, } from "./common.ts"; import { MacosGuest } from "./guest-transports.ts"; import { runSmokeLane, type SmokeLane, type SmokeLaneStatus } from "./lane-runner.ts"; import { MacosDiscordSmoke } from "./macos-discord.ts"; import { waitForVmStatus } from "./parallels-vm.ts"; import { PhaseRunner } from "./phase-runner.ts"; interface MacosOptions { vmName: string; snapshotHint: string; mode: Mode; provider: Provider; apiKeyEnv?: string; modelId?: string; installUrl: string; hostPort: number; hostPortExplicit: boolean; hostIp?: string; latestVersion?: string; installVersion?: string; targetPackageSpec?: string; skipLatestRefCheck: boolean; keepServer: boolean; json: boolean; discordTokenEnv?: string; discordGuildId?: string; discordChannelId?: string; } interface MacosSummary { 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; dashboard: string; agent: string; discord: string; }; upgrade: { precheck: string; status: string; path: string; latestVersionInstalled: string; mainVersion: string; gateway: string; dashboard: string; agent: string; discord: string; }; } const guestPath = "/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin"; const guestOpenClaw = "/opt/homebrew/bin/openclaw"; const guestOpenClawEntry = "/opt/homebrew/lib/node_modules/openclaw/openclaw.mjs"; const guestNode = "/opt/homebrew/bin/node"; const guestNpm = "/opt/homebrew/bin/npm"; const defaultOptions = (): MacosOptions => ({ discordChannelId: undefined, discordGuildId: undefined, discordTokenEnv: undefined, hostIp: undefined, hostPort: 18425, hostPortExplicit: false, installUrl: "https://openclaw.ai/install.sh", installVersion: "", json: false, keepServer: false, latestVersion: "", mode: "both", modelId: undefined, provider: "openai", skipLatestRefCheck: false, snapshotHint: "macOS 26.3.1 latest", targetPackageSpec: "", vmName: "macOS Tahoe", }); function usage(): string { return `Usage: bash scripts/e2e/parallels-macos-smoke.sh [options] Options: --vm Parallels VM name. Default: "macOS Tahoe" --snapshot-hint Snapshot name substring/fuzzy match. Default: "macOS 26.3.1 latest" --mode --provider --model Override the model used for the agent-turn smoke. --api-key-env Host env var name for provider API key. --openai-api-key-env Alias for --api-key-env (backward compatible) --install-url Installer URL for latest release. Default: https://openclaw.ai/install.sh --host-port Host HTTP port for current-main tgz. Default: 18425 --host-ip Override Parallels host IP. --latest-version Override npm latest version lookup. --install-version Pin site-installer version/dist-tag for the baseline lane. --target-package-spec Install this npm package tarball instead of packing current main. --skip-latest-ref-check Skip the known latest-release ref-mode precheck in upgrade lane. --keep-server Leave temp host HTTP server running. --discord-token-env Host env var name for Discord bot token. --discord-guild-id Discord guild ID for smoke roundtrip. --discord-channel-id Discord channel ID for smoke roundtrip. --json Print machine-readable JSON summary. -h, --help Show help. `; } function parseArgs(argv: string[]): MacosOptions { const options = defaultOptions(); for (let i = 0; i < argv.length; i++) { const arg = argv[i]; switch (arg) { case "--": break; case "--vm": options.vmName = ensureValue(argv, i, arg); i++; break; case "--snapshot-hint": options.snapshotHint = ensureValue(argv, i, arg); i++; break; case "--mode": options.mode = parseMode(ensureValue(argv, i, arg)); i++; break; case "--provider": options.provider = parseProvider(ensureValue(argv, i, arg)); i++; break; case "--model": options.modelId = ensureValue(argv, i, arg); i++; break; case "--api-key-env": case "--openai-api-key-env": options.apiKeyEnv = ensureValue(argv, i, arg); i++; break; case "--install-url": options.installUrl = ensureValue(argv, i, arg); i++; break; case "--host-port": options.hostPort = Number(ensureValue(argv, i, arg)); options.hostPortExplicit = true; i++; break; case "--host-ip": options.hostIp = ensureValue(argv, i, arg); i++; break; case "--latest-version": options.latestVersion = ensureValue(argv, i, arg); i++; break; case "--install-version": options.installVersion = ensureValue(argv, i, arg); i++; break; case "--target-package-spec": options.targetPackageSpec = ensureValue(argv, i, arg); i++; break; case "--skip-latest-ref-check": options.skipLatestRefCheck = true; break; case "--keep-server": options.keepServer = true; break; case "--discord-token-env": options.discordTokenEnv = ensureValue(argv, i, arg); i++; break; case "--discord-guild-id": options.discordGuildId = ensureValue(argv, i, arg); i++; break; case "--discord-channel-id": options.discordChannelId = ensureValue(argv, i, arg); i++; break; case "--json": options.json = true; break; case "-h": case "--help": process.stdout.write(usage()); process.exit(0); default: die(`unknown arg: ${arg}`); } } return options; } class MacosSmoke { private auth: ProviderAuth; private discordToken = ""; private hostIp = ""; private hostPort = 0; private server: HostServer | null = null; private runDir = ""; private tgzDir = ""; private artifact: PackageArtifact | null = null; private targetExpectVersion = ""; private latestVersion = ""; private installVersion = ""; private snapshot!: SnapshotInfo; private phases!: PhaseRunner; private guest!: MacosGuest; private discord: MacosDiscordSmoke | null = null; private guestUser = ""; private guestTransport: "current-user" | "sudo" = "current-user"; private status = { freshAgent: "skip", freshDashboard: "skip", freshDiscord: "skip", freshGateway: "skip", freshMain: "skip", freshVersion: "skip", latestInstalledVersion: "skip", upgrade: "skip", upgradeAgent: "skip", upgradeDashboard: "skip", upgradeDiscord: "skip", upgradeGateway: "skip", upgradePrecheck: "skip", upgradeVersion: "skip", }; constructor(private options: MacosOptions) { this.auth = resolveProviderAuth({ apiKeyEnv: options.apiKeyEnv, modelId: options.modelId, provider: options.provider, }); this.validateDiscord(); } async run(): Promise { this.runDir = await makeTempDir("openclaw-parallels-macos."); this.phases = new PhaseRunner(this.runDir); this.guest = new MacosGuest( { getTransport: () => this.guestTransport, getUser: () => this.guestUser, path: guestPath, resolveDesktopHome: (user) => this.resolveDesktopHome(user), vmName: this.options.vmName, }, this.phases, ); this.discord = this.createDiscordSmoke(); this.tgzDir = await makeTempDir("openclaw-parallels-macos-tgz."); try { this.snapshot = resolveSnapshot(this.options.vmName, this.options.snapshotHint); this.latestVersion = resolveLatestVersion(this.options.latestVersion); this.installVersion = this.options.installVersion || this.latestVersion; this.hostIp = resolveHostIp(this.options.hostIp); this.hostPort = await resolveHostPort( this.options.hostPort, this.options.hostPortExplicit, defaultOptions().hostPort, ); say(`VM: ${this.options.vmName}`); say(`Snapshot hint: ${this.options.snapshotHint}`); say(`Resolved snapshot: ${this.snapshot.name} [${this.snapshot.state}]`); say(`Latest npm version: ${this.latestVersion}`); say( `Current head: ${run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim()}`, ); say( `Discord smoke: ${this.discordEnabled() ? `guild=${this.options.discordGuildId} channel=${this.options.discordChannelId}` : "disabled"}`, ); say(`Run logs: ${this.runDir}`); if (await this.needsHostTgz()) { this.artifact = await packOpenClaw({ destination: this.tgzDir, packageSpec: this.options.targetPackageSpec, requireControlUi: true, }); if (this.options.targetPackageSpec) { this.targetExpectVersion = this.artifact.version || (await packageVersionFromTgz(this.artifact.path)); } this.server = await startHostServer({ artifactPath: this.artifact.path, dir: this.tgzDir, hostIp: this.hostIp, label: this.artifactLabel(), port: this.hostPort, }); this.hostPort = this.server.port; } else if (this.targetInstallsDirectly()) { this.targetExpectVersion = run( "npm", [ "view", this.options.targetPackageSpec || "", "version", "--userconfig", path.join(this.tgzDir, "npmrc"), ], { quiet: true }, ).stdout.trim(); } if (this.options.mode === "fresh" || this.options.mode === "both") { await this.runLane("fresh", async () => this.runFreshLane()); } if (this.options.mode === "upgrade" || this.options.mode === "both") { await this.runLane("upgrade", async () => this.runUpgradeLane()); } const summaryPath = await this.writeSummary(); if (this.options.json) { process.stdout.write(await readFile(summaryPath, "utf8")); } else { this.printSummary(summaryPath); } if (this.status.freshMain === "fail" || this.status.upgrade === "fail") { process.exitCode = 1; } } finally { if (!this.options.keepServer) { await this.server?.stop().catch(() => undefined); await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined); } await this.cleanupDiscordMessages().catch(() => undefined); await this.stopVmAfterSuccessfulDiscordSmoke().catch(() => undefined); } } private validateDiscord(): void { if ( !this.options.discordTokenEnv && !this.options.discordGuildId && !this.options.discordChannelId ) { return; } if (!this.options.discordTokenEnv) { die("--discord-token-env is required when Discord smoke args are set"); } if (!this.options.discordGuildId) { die("--discord-guild-id is required when Discord smoke args are set"); } if (!this.options.discordChannelId) { die("--discord-channel-id is required when Discord smoke args are set"); } this.discordToken = process.env[this.options.discordTokenEnv] ?? ""; if (!this.discordToken) { die(`${this.options.discordTokenEnv} is required for Discord smoke`); } } private discordEnabled(): boolean { return Boolean( this.discordToken && this.options.discordGuildId && this.options.discordChannelId, ); } private createDiscordSmoke(): MacosDiscordSmoke | null { if (!this.discordEnabled()) { return null; } return new MacosDiscordSmoke({ config: { channelId: this.options.discordChannelId || "", guildId: this.options.discordGuildId || "", token: this.discordToken, }, guest: this.guest, guestNode, guestOpenClaw, guestOpenClawEntry, runDir: this.runDir, vmName: this.options.vmName, }); } private targetInstallsDirectly(): boolean { const spec = this.options.targetPackageSpec; return Boolean(spec && !/^(https?:|file:|\/|\.\/|\.\.\/|.*\.tgz$)/.test(spec)); } private async needsHostTgz(): Promise { if (!this.options.targetPackageSpec) { return true; } return !this.targetInstallsDirectly(); } private artifactLabel(): string { if (this.targetInstallsDirectly()) { return "target package spec"; } return this.options.targetPackageSpec ? "target package tgz" : "current main tgz"; } private async runLane(name: "fresh" | "upgrade", fn: () => Promise): Promise { await runSmokeLane(name, fn, (lane, status) => this.setLaneStatus(lane, status)); } private setLaneStatus(name: SmokeLane, status: SmokeLaneStatus): void { if (name === "fresh") { this.status.freshMain = status; } else { this.status.upgrade = status; } } private async runFreshLane(): Promise { await this.phase("fresh.restore-snapshot", 780, () => this.restoreSnapshot()); await this.phase("fresh.reset-state", 180, () => this.resetState()); await this.phase("fresh.install-main", this.targetInstallsDirectly() ? 420 : 420, () => this.installMain("openclaw-main-fresh.tgz"), ); this.status.freshVersion = await this.extractLastVersion("fresh.install-main"); await this.phase("fresh.verify-main-version", 60, () => this.verifyTargetVersion()); await this.phase("fresh.verify-bundle-permissions", 180, () => this.verifyBundlePermissions()); await this.phase("fresh.onboard-ref", 180, () => this.runRefOnboard()); await this.phase("fresh.gateway-start", 180, () => this.startManualGatewayIfNeeded()); await this.phase("fresh.gateway-status", 180, () => this.verifyGateway()); this.status.freshGateway = "pass"; await this.phase("fresh.dashboard-load", 180, () => this.verifyDashboardLoad()); this.status.freshDashboard = "pass"; await this.phase( "fresh.first-agent-turn", Number(process.env.OPENCLAW_PARALLELS_MACOS_AGENT_TIMEOUT_S || 2700), () => this.verifyTurn(), ); this.status.freshAgent = "pass"; if (this.discordEnabled()) { this.status.freshDiscord = "fail"; await this.phase("fresh.discord-config", 600, () => this.configureDiscord()); await this.phase("fresh.discord-roundtrip", 180, () => this.runDiscordRoundtrip("fresh")); this.status.freshDiscord = "pass"; } } private async runUpgradeLane(): Promise { await this.phase("upgrade.restore-snapshot", 780, () => this.restoreSnapshot()); await this.phase("upgrade.reset-state", 180, () => this.resetState()); await this.phase("upgrade.install-latest", 420, () => this.installLatestRelease()); this.status.latestInstalledVersion = await this.extractLastVersion("upgrade.install-latest"); await this.phase("upgrade.verify-latest-version", 60, () => this.verifyVersionContains(this.installVersion), ); if (this.options.skipLatestRefCheck) { this.status.upgradePrecheck = "skipped"; } else if ( await this.phaseReturns("upgrade.latest-ref-precheck", 180, () => this.captureLatestRefFailure(), ) ) { this.status.upgradePrecheck = "latest-ref-pass"; } else { this.status.upgradePrecheck = "latest-ref-fail"; } if (this.options.targetPackageSpec) { await this.phase("upgrade.install-main", this.targetInstallsDirectly() ? 420 : 420, () => this.installMain("openclaw-main-upgrade.tgz"), ); this.status.upgradeVersion = await this.extractLastVersion("upgrade.install-main"); await this.phase("upgrade.verify-main-version", 60, () => this.verifyTargetVersion()); await this.phase("upgrade.verify-bundle-permissions", 180, () => this.verifyBundlePermissions(), ); } else { await this.phase( "upgrade.update-dev", Number(process.env.OPENCLAW_PARALLELS_MACOS_UPDATE_DEV_TIMEOUT_S || 1800), () => this.runDevChannelUpdate(), ); this.status.upgradeVersion = await this.extractLastVersion("upgrade.update-dev"); await this.phase("upgrade.verify-dev-channel", 60, () => this.verifyDevChannelUpdate()); } await this.phase("upgrade.onboard-ref", 180, () => this.runRefOnboard()); await this.phase("upgrade.gateway-start", 180, () => this.startManualGatewayIfNeeded()); await this.phase("upgrade.gateway-status", 180, () => this.verifyGateway()); this.status.upgradeGateway = "pass"; await this.phase("upgrade.dashboard-load", 180, () => this.verifyDashboardLoad()); this.status.upgradeDashboard = "pass"; await this.phase( "upgrade.first-agent-turn", Number(process.env.OPENCLAW_PARALLELS_MACOS_AGENT_TIMEOUT_S || 2700), () => this.verifyTurn(), ); this.status.upgradeAgent = "pass"; if (this.discordEnabled()) { this.status.upgradeDiscord = "fail"; await this.phase("upgrade.discord-config", 600, () => this.configureDiscord()); await this.phase("upgrade.discord-roundtrip", 180, () => this.runDiscordRoundtrip("upgrade")); this.status.upgradeDiscord = "pass"; } } private async phase( name: string, timeoutSeconds: number, fn: () => Promise | void, ): Promise { await this.phases.phase(name, timeoutSeconds, fn); } private remainingPhaseTimeoutMs(): number | undefined { return this.phases.remainingTimeoutMs(); } private async phaseReturns( name: string, timeoutSeconds: number, fn: () => Promise | void, ): Promise { return await this.phases.phaseReturns(name, timeoutSeconds, fn); } private log(text: string): void { this.phases.append(text); } private guestExec( args: string[], options: { check?: boolean; env?: Record } = {}, ): string { return this.guest.exec(args, options); } private guestSh(script: string, env: Record = {}): string { return this.guest.sh(script, env); } private waitForCurrentUser(timeoutSeconds = 360): void { const prlctlDeadline = Date.now() + 45_000; const deadline = Date.now() + timeoutSeconds * 1000; while (Date.now() < prlctlDeadline && Date.now() < deadline) { const result = run("prlctl", ["exec", this.options.vmName, "--current-user", "whoami"], { check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(), }); const user = result.stdout.trim().replaceAll("\r", "").split("\n").at(-1) ?? ""; if (result.status === 0 && /^[A-Za-z0-9._-]+$/.test(user)) { this.guestUser = user; this.guestTransport = "current-user"; return; } run("sleep", ["2"], { quiet: true }); } const fallback = this.resolveDesktopUser(); if (fallback) { this.guestUser = fallback; this.guestTransport = "sudo"; warn( `desktop user unavailable via Parallels --current-user; using root sudo fallback for ${fallback}`, ); return; } while (Date.now() < deadline) { const result = run("prlctl", ["exec", this.options.vmName, "--current-user", "whoami"], { check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(), }); const user = result.stdout.trim().replaceAll("\r", "").split("\n").at(-1) ?? ""; if (result.status === 0 && /^[A-Za-z0-9._-]+$/.test(user)) { this.guestUser = user; this.guestTransport = "current-user"; return; } run("sleep", ["2"], { quiet: true }); } throw new Error("guest current user did not become available"); } private resolveDesktopUser(): string { const consoleUser = run("prlctl", ["exec", this.options.vmName, "/usr/bin/stat", "-f", "%Su", "/dev/console"], { check: false, quiet: true, }) .stdout.trim() .replaceAll("\r", "") .split("\n") .at(-1) ?? ""; if ( /^[A-Za-z0-9._-]+$/.test(consoleUser) && consoleUser !== "root" && consoleUser !== "loginwindow" ) { return consoleUser; } const users = run( "prlctl", ["exec", this.options.vmName, "/usr/bin/dscl", ".", "-list", "/Users", "NFSHomeDirectory"], { check: false, quiet: true, }, ).stdout.replaceAll("\r", ""); for (const line of users.split("\n")) { const [user, home] = line.trim().split(/\s+/); if ( user && home?.startsWith("/Users/") && !user.startsWith("_") && user !== "Shared" && user !== ".localized" ) { return user; } } return ""; } private resolveDesktopHome(user: string): string { const output = run( "prlctl", [ "exec", this.options.vmName, "/usr/bin/dscl", ".", "-read", `/Users/${user}`, "NFSHomeDirectory", ], { check: false, quiet: true }, ).stdout.replaceAll("\r", ""); const match = /^NFSHomeDirectory:\s+(.+)$/m.exec(output); return match?.[1]?.trim() || `/Users/${user}`; } private restoreSnapshot(): void { say(`Restore snapshot ${this.options.snapshotHint} (${this.snapshot.id})`); let restored = false; for (let attempt = 1; attempt <= 2; attempt++) { const result = run( "prlctl", ["snapshot-switch", this.options.vmName, "--id", this.snapshot.id, "--skip-resume"], { check: false, quiet: true, timeoutMs: 360_000 }, ); this.log(result.stdout); this.log(result.stderr); if (result.status === 0) { restored = true; break; } warn(`snapshot-switch attempt ${attempt} failed (rc=${result.status})`); const status = run("prlctl", ["status", this.options.vmName], { check: false, quiet: true, }).stdout; if (status.includes(" running") || status.includes(" suspended")) { run("prlctl", ["stop", this.options.vmName, "--kill"], { check: false, quiet: true }); waitForVmStatus(this.options.vmName, "stopped", 360); } run("sleep", ["3"], { quiet: true }); } if (!restored) { throw new Error("snapshot restore failed"); } const status = run("prlctl", ["status", this.options.vmName], { check: false, quiet: true, timeoutMs: 60_000, }).stdout; if (this.snapshot.state === "poweroff" || status.includes(" stopped")) { waitForVmStatus(this.options.vmName, "stopped", 360); say(`Start restored poweroff snapshot ${this.snapshot.name}`); run("prlctl", ["start", this.options.vmName], { quiet: true }); } else if (status.includes(" suspended")) { say(`Resume restored snapshot ${this.snapshot.name}`); run("prlctl", ["start", this.options.vmName], { quiet: true }); } this.waitForCurrentUser(); } private resetState(): void { this.guestSh(String.raw`/usr/bin/pkill -f 'openclaw.*gateway run' >/dev/null 2>&1 || true /usr/bin/pkill -f 'openclaw-gateway' >/dev/null 2>&1 || true /usr/bin/pkill -f 'openclaw.mjs gateway' >/dev/null 2>&1 || true 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' "$(${guestNpm} root -g 2>/dev/null || true)" ${guestNpm} uninstall -g openclaw >/dev/null 2>&1 || true rm -rf "$HOME/.openclaw" rm -f /tmp/openclaw-parallels-macos-gateway.log`); } private installLatestRelease(): void { this.guestSh( `export OPENCLAW_NO_ONBOARD=1 curl -fsSL ${shellQuote(this.options.installUrl)} -o /tmp/openclaw-install.sh bash /tmp/openclaw-install.sh --version ${shellQuote(this.installVersion)} ${guestOpenClaw} --version`, ); } private installMain(tempName: string): void { if (this.targetInstallsDirectly()) { this .guestSh(`printf 'install-source: registry-spec %s\\n' ${shellQuote(this.options.targetPackageSpec || "")} ${guestNpm} install -g ${shellQuote(this.options.targetPackageSpec || "")} ${guestOpenClaw} --version`); return; } if (!this.artifact || !this.server) { die("package artifact/server missing"); } const tgzUrl = this.server.urlFor(this.artifact.path); this.guestSh(`printf 'install-source: host-tgz %s\\n' ${shellQuote(tgzUrl)} curl -fsSL ${shellQuote(tgzUrl)} -o /tmp/${tempName} ${guestNpm} install -g /tmp/${tempName} ${guestOpenClaw} --version`); } private async verifyTargetVersion(): Promise { if (this.options.targetPackageSpec) { this.verifyVersionContains(this.targetExpectVersion); return; } if (!this.artifact) { die("package artifact missing"); } const commit = this.artifact.buildCommitShort || (await packageBuildCommitFromTgz(this.artifact.path)).slice(0, 7); this.verifyVersionContains(commit); } private verifyVersionContains(needle: string): void { const version = this.guestExec([guestOpenClaw, "--version"]); if (!version.includes(needle)) { throw new Error(`version mismatch: expected substring ${needle}`); } } private verifyBundlePermissions(): void { this.guestSh(String.raw`set -eu root=$(/opt/homebrew/bin/npm root -g) check_path() { path="$1" [ -e "$path" ] || return 0 perm=$(/usr/bin/stat -f '%OLp' "$path") perm_oct=$((8#$perm)) if (( perm_oct & 0002 )); then echo "world-writable install artifact: $path ($perm)" >&2 exit 1 fi } check_path "$root/openclaw" check_path "$root/openclaw/extensions" if [ -d "$root/openclaw/extensions" ]; then while IFS= read -r -d '' extension_dir; do check_path "$extension_dir" done < <(/usr/bin/find "$root/openclaw/extensions" -mindepth 1 -maxdepth 1 -type d -print0) fi`); } private runRefOnboard(): void { const daemonFlag = this.guestTransport === "sudo" ? "--skip-health" : "--install-daemon"; this.guestExec([ "/usr/bin/env", `${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`, guestOpenClaw, "onboard", "--non-interactive", "--mode", "local", "--auth-choice", this.auth.authChoice, "--secret-input-mode", "ref", "--gateway-port", "18789", "--gateway-bind", "loopback", daemonFlag, "--skip-skills", "--accept-risk", "--json", ]); } private captureLatestRefFailure(): void { this.runRefOnboard(); this.showGatewayStatusCompat(); } private ensureGuestPnpm(): void { this.guestSh(String.raw`set -eu bootstrap_root=/tmp/openclaw-smoke-pnpm-bootstrap bootstrap_bin="$bootstrap_root/node_modules/.bin" if [ -x "$bootstrap_bin/pnpm" ]; then echo "bootstrap-pnpm: reuse" "$bootstrap_bin/pnpm" --version exit 0 fi echo "bootstrap-pnpm: install" rm -rf "$bootstrap_root" mkdir -p "$bootstrap_root" /opt/homebrew/bin/node /opt/homebrew/bin/npm install --prefix "$bootstrap_root" --no-save pnpm@11 "$bootstrap_bin/pnpm" --version`); } private runDevChannelUpdate(): void { this.ensureGuestPnpm(); const home = this.guestHome(); this.guestSh( `set -eu rm -rf ${shellQuote(`${home}/openclaw`)} export PATH=${shellQuote(`/tmp/openclaw-smoke-pnpm-bootstrap/node_modules/.bin:${guestPath}`)} ${guestNode} - <<'JS' const fs = require("node:fs"); const path = require("node:path"); const configPath = path.join(process.env.HOME || ${JSON.stringify(home)}, ".openclaw", "openclaw.json"); const config = JSON.parse(fs.readFileSync(configPath, "utf8")); config.update = { ...(config.update || {}), channel: "dev" }; fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\\n"); JS /usr/bin/env NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS=1 OPENCLAW_DISABLE_BUNDLED_PLUGINS=1 ${guestNode} ${guestOpenClawEntry} update --channel dev --yes --json ${guestNode} ${guestOpenClawEntry} --version ${guestNode} ${guestOpenClawEntry} update status --json`, ); } private verifyDevChannelUpdate(): void { const status = this.guestExec([guestNode, guestOpenClawEntry, "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 startManualGatewayIfNeeded(): void { if (this.guestTransport !== "sudo") { return; } const home = this.guestHome(); this.guestSh( `set -euo pipefail trap '' HUP /usr/bin/pkill -f 'openclaw.*gateway run' >/dev/null 2>&1 || true /usr/bin/pkill -f 'openclaw-gateway' >/dev/null 2>&1 || true /usr/bin/pkill -f 'openclaw.mjs gateway' >/dev/null 2>&1 || true /usr/bin/env HOME=${shellQuote(home)} USER=${shellQuote(this.guestUser)} LOGNAME=${shellQuote(this.guestUser)} PATH=${shellQuote(guestPath)} ${shellQuote( `${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`, )} OPENCLAW_HOME=${shellQuote(home)} OPENCLAW_STATE_DIR=${shellQuote(`${home}/.openclaw`)} OPENCLAW_CONFIG_PATH=${shellQuote( `${home}/.openclaw/openclaw.json`, )} ${guestNode} ${guestOpenClawEntry} gateway run --bind loopback --port 18789 --force /tmp/openclaw-parallels-macos-gateway.log 2>&1 & sleep 1`, ); } private verifyGateway(): void { for (let attempt = 1; attempt <= 8; attempt++) { const result = this.guestOpenClaw( ["gateway", "status", "--deep", "--require-rpc", "--timeout", "15000"], false, ); if (result) { 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 showGatewayStatusCompat(): void { const help = this.guestExec([guestOpenClaw, "gateway", "status", "--help"], { check: false }); const args = help.includes("--require-rpc") ? ["gateway", "status", "--deep", "--require-rpc"] : ["gateway", "status", "--deep"]; if (!this.guestOpenClaw(args, false)) { throw new Error("gateway status failed"); } } private guestOpenClaw(args: string[], check: boolean): boolean { const result = run( "prlctl", [ "exec", this.options.vmName, ...(this.guestTransport === "sudo" ? [ "/usr/bin/sudo", "-H", "-u", this.guestUser, "/usr/bin/env", `HOME=${this.guestHome()}`, `PATH=${guestPath}`, ] : ["--current-user", "/usr/bin/env", `PATH=${guestPath}`]), guestOpenClaw, ...args, ], { check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs() }, ); this.log(result.stdout); this.log(result.stderr); if (check && result.status !== 0) { throw new Error(`openclaw ${args.join(" ")} failed`); } return result.status === 0; } private verifyDashboardLoad(): void { this.guestSh(String.raw`set -eu deadline=$((SECONDS + 120)) while [ $SECONDS -lt $deadline ]; do if curl -fsSL --connect-timeout 2 --max-time 5 http://127.0.0.1:18789/ >/tmp/openclaw-dashboard-smoke.html 2>/dev/null; then grep -F 'OpenClaw Control' /tmp/openclaw-dashboard-smoke.html >/dev/null && grep -F '' /tmp/openclaw-dashboard-smoke.html >/dev/null && exit 0 fi sleep 1 done echo "dashboard HTML did not become ready" >&2 exit 1`); } private verifyTurn(): void { this.guestExec([guestNode, guestOpenClawEntry, "models", "set", this.auth.modelId]); const modelProviderConfigBatch = modelProviderConfigBatchJson(this.auth.modelId, "macos"); if (modelProviderConfigBatch) { this.guestSh(`provider_config_batch="$(mktemp)" cat >"$provider_config_batch" <<'JSON' ${modelProviderConfigBatch} JSON ${shellQuote(guestNode)} ${shellQuote( guestOpenClawEntry, )} config set --batch-file "$provider_config_batch" --strict-json rm -f "$provider_config_batch"`); } this.guestExec([ guestNode, guestOpenClawEntry, "config", "set", "agents.defaults.skipBootstrap", "true", "--strict-json", ]); this.guestExec([guestNode, guestOpenClawEntry, "config", "set", "tools.profile", "minimal"]); this.guestSh( `${posixAgentWorkspaceScript("Parallels macOS smoke test assistant.")} agent_ok=false for attempt in 1 2; do session_id="parallels-macos-smoke" if [ "$attempt" -gt 1 ]; then session_id="parallels-macos-smoke-retry-$attempt"; fi rm -f "$HOME/.openclaw/agents/main/sessions/$session_id.jsonl" output_file="$(mktemp)" set +e /usr/bin/env ${shellQuote(`${this.auth.apiKeyEnv}=${this.auth.apiKeyValue}`)} ${guestNode} ${guestOpenClawEntry} agent --local --agent main --session-id "$session_id" --message ${shellQuote( "Reply with exact ASCII text OK only.", )} --thinking minimal --timeout ${resolveParallelsModelTimeoutSeconds("macos")} --json >"$output_file" 2>&1 rc=$? set -e cat "$output_file" if [ "$rc" -ne 0 ]; then 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 configureDiscord(): void { this.discord?.configure(); } private async runDiscordRoundtrip(phase: "fresh" | "upgrade"): Promise { if (!this.discord) { throw new Error("Discord smoke is not configured"); } await this.discord.runRoundtrip(phase); } private async cleanupDiscordMessages(): Promise { await this.discord?.cleanupMessages(); } private async stopVmAfterSuccessfulDiscordSmoke(): Promise { this.discord?.stopVmAfterSuccessfulSmoke(this.status.freshDiscord, this.status.upgradeDiscord); } private guestHome(): string { if (!this.guestUser) { this.waitForCurrentUser(); } return this.guestTransport === "sudo" ? this.resolveDesktopHome(this.guestUser) : this.guestExec(["/usr/bin/id", "-P"]).split(":")[8] || `/Users/${this.guestUser}`; } private async extractLastVersion(phaseName: string): Promise { const log = await readFile(path.join(this.runDir, `${phaseName}.log`), "utf8").catch(() => ""); const matches = [...log.matchAll(/OpenClaw\s+([0-9][^\s]*)/gi)]; return matches.at(-1)?.[1] ?? ""; } private upgradeSummaryLabel(): string { return this.options.targetPackageSpec ? "latest->target-package" : "latest->dev"; } private async writeSummary(): Promise { const summary: MacosSummary = { currentHead: this.artifact?.buildCommitShort || run("git", ["rev-parse", "--short", "HEAD"], { quiet: true }).stdout.trim(), freshMain: { agent: this.status.freshAgent, dashboard: this.status.freshDashboard, discord: this.status.freshDiscord, gateway: this.status.freshGateway, status: this.status.freshMain, version: this.status.freshVersion, }, installVersion: this.installVersion, latestVersion: this.latestVersion, mode: this.options.mode, provider: this.options.provider, runDir: this.runDir, snapshotHint: this.options.snapshotHint, snapshotId: this.snapshot.id, targetPackageSpec: this.options.targetPackageSpec || "", upgrade: { agent: this.status.upgradeAgent, dashboard: this.status.upgradeDashboard, discord: this.status.upgradeDiscord, gateway: this.status.upgradeGateway, latestVersionInstalled: this.status.latestInstalledVersion, mainVersion: this.status.upgradeVersion, path: this.upgradeSummaryLabel(), precheck: this.status.upgradePrecheck, status: this.status.upgrade, }, vm: this.options.vmName, }; const summaryPath = path.join(this.runDir, "summary.json"); await writeJson(summaryPath, summary); await writeSummaryMarkdown({ lines: [ `- vm: ${summary.vm}`, `- target: ${summary.targetPackageSpec || "current main"}`, `- fresh: ${summary.freshMain.status} ${summary.freshMain.version}`, `- fresh gateway/dashboard/agent: ${summary.freshMain.gateway}/${summary.freshMain.dashboard}/${summary.freshMain.agent}`, `- upgrade: ${summary.upgrade.status} ${summary.upgrade.mainVersion}`, `- logs: ${summary.runDir}`, ], summaryPath, title: "macOS Parallels Smoke", }); return summaryPath; } private printSummary(summaryPath: string): void { process.stdout.write("\nSummary:\n"); if (this.options.targetPackageSpec) { process.stdout.write(` target-package: ${this.options.targetPackageSpec}\n`); } if (this.installVersion) { process.stdout.write(` baseline-install-version: ${this.installVersion}\n`); } process.stdout.write( ` fresh-main: ${this.status.freshMain} (${this.status.freshVersion}) discord=${this.status.freshDiscord}\n`, ); process.stdout.write( ` latest precheck: ${this.status.upgradePrecheck} (${this.status.latestInstalledVersion})\n`, ); process.stdout.write( ` ${this.upgradeSummaryLabel()}: ${this.status.upgrade} (${this.status.upgradeVersion}) discord=${this.status.upgradeDiscord}\n`, ); process.stdout.write(` logs: ${this.runDir}\n`); process.stdout.write(` summary: ${summaryPath}\n`); } } await new MacosSmoke(parseArgs(process.argv.slice(2))).run().catch((error: unknown) => { die(error instanceof Error ? error.message : String(error)); });