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