mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 17:53:39 +00:00
202 lines
7.8 KiB
JavaScript
Executable file
202 lines
7.8 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
import assert from "node:assert/strict";
|
|
import { execFileSync } from "node:child_process";
|
|
// Package proof: relocation, native load dependencies, provenance, and actual
|
|
// JSONL worker readiness. Never admits or opens the operator's live state.
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { createInterface } from "node:readline";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
seedMacNodeWorkerProofState,
|
|
readMacNodeWorkerProofRows,
|
|
} from "./lib/mac-node-worker-proof-state.mjs";
|
|
import { auditMacWorkerPortability } from "./lib/mac-worker-portability.mjs";
|
|
import { runManagedCommand, terminateManagedChild } from "./lib/managed-child-process.mts";
|
|
|
|
const [runtimeArg, expectedInfoPath] = process.argv.slice(2);
|
|
if (!runtimeArg || !expectedInfoPath) {
|
|
throw new Error("Usage: verify-mac-node-worker.mjs <runtime> <expected-build-info.json>");
|
|
}
|
|
const runtime = fs.realpathSync(runtimeArg);
|
|
const node = path.join(runtime, "bin/node");
|
|
const packageRoot = path.join(runtime, "lib/node_modules/openclaw");
|
|
if (fs.existsSync(path.join(packageRoot, "dist/control-ui"))) {
|
|
throw new Error("Private worker must not contain Gateway Control UI assets");
|
|
}
|
|
const expected = JSON.parse(fs.readFileSync(expectedInfoPath, "utf8"));
|
|
const actual = JSON.parse(fs.readFileSync(path.join(packageRoot, "dist/build-info.json"), "utf8"));
|
|
for (const key of ["version", "commit", "builtAt", "buildId"]) {
|
|
if (!expected[key] || expected[key] !== actual[key]) {
|
|
throw new Error(`Private worker build mismatch: ${key}`);
|
|
}
|
|
}
|
|
if (fs.realpathSync(process.execPath) !== node) {
|
|
throw new Error("Worker proof must execute the bundled Node for the requested architecture");
|
|
}
|
|
|
|
const nativeFiles = auditMacWorkerPortability(runtime, node);
|
|
|
|
const home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-worker-proof-")));
|
|
try {
|
|
// Ready manifests do not load lazy native capabilities. Exercise their real
|
|
// package loaders so omitted optional packages and wrong slices fail staging.
|
|
const require = createRequire(path.join(packageRoot, "package.json"));
|
|
// Keep required native mode and bundled module identity in a fresh process;
|
|
// worker readiness below must still use OpenClaw's normal defaults.
|
|
execFileSync(
|
|
node,
|
|
[fileURLToPath(new URL("./verify-mac-node-worker-fs.mjs", import.meta.url)), packageRoot, home],
|
|
{
|
|
cwd: home,
|
|
env: { HOME: home, TMPDIR: home, FS_SAFE_NATIVE_MODE: "require" },
|
|
stdio: "inherit",
|
|
},
|
|
);
|
|
const database = new DatabaseSync(":memory:", { allowExtension: true });
|
|
try {
|
|
require("sqlite-vec").load(database);
|
|
assert.equal(
|
|
typeof database.prepare("SELECT vec_version() AS version").get().version,
|
|
"string",
|
|
);
|
|
} finally {
|
|
database.close();
|
|
}
|
|
await new Promise((resolve, reject) => {
|
|
const terminal = require("@lydell/node-pty").spawn(
|
|
"/bin/sh",
|
|
["-c", "printf worker-pty-proof"],
|
|
{
|
|
cwd: home,
|
|
env: { HOME: home, PATH: "/usr/bin:/bin" },
|
|
name: "xterm",
|
|
cols: 80,
|
|
rows: 24,
|
|
},
|
|
);
|
|
let output = "";
|
|
const timeout = setTimeout(() => {
|
|
terminal.kill("SIGKILL");
|
|
reject(new Error("Bundled PTY did not exit"));
|
|
}, 10_000);
|
|
terminal.onData((data) => {
|
|
output += data;
|
|
});
|
|
terminal.onExit(({ exitCode }) => {
|
|
clearTimeout(timeout);
|
|
if (exitCode === 0 && output === "worker-pty-proof") {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`Bundled PTY failed (${exitCode}): ${output}`));
|
|
}
|
|
});
|
|
});
|
|
for (const nativeFirst of [false, true]) {
|
|
const proofHome = path.join(home, nativeFirst ? "native-first" : "absent");
|
|
const stateDir = path.join(proofHome, "state");
|
|
const databasePath = path.join(stateDir, "state", "openclaw.sqlite");
|
|
fs.mkdirSync(proofHome, { recursive: true });
|
|
// State lifecycle coordination lives outside removable state. Only remove
|
|
// this fresh fixture's exact hashes after the complete worker tree exits.
|
|
const coordinatorHash = createHash("sha256").update(databasePath).digest("hex").slice(0, 8);
|
|
const coordinatorFiles = ["state-lifecycle", "gateway-lifecycle"].flatMap((family) => {
|
|
const file = path.join(
|
|
fs.realpathSync("/tmp"),
|
|
`openclaw-state-locks-${process.getuid()}`,
|
|
`${family}.${coordinatorHash}.lock.sqlite`,
|
|
);
|
|
return [file, `${file}-journal`, `${file}-wal`, `${file}-shm`];
|
|
});
|
|
assert(
|
|
coordinatorFiles.every((file) => !fs.existsSync(file)),
|
|
"Proof coordinator already exists",
|
|
);
|
|
const nativeRows = nativeFirst ? seedMacNodeWorkerProofState(databasePath) : undefined;
|
|
let ready = false;
|
|
let failure;
|
|
let diagnostic = "";
|
|
const exitCode = await runManagedCommand({
|
|
bin: node,
|
|
args: [path.join(packageRoot, "dist/entry.js"), "node", "worker"],
|
|
cwd: proofHome,
|
|
env: {
|
|
HOME: proofHome,
|
|
TMPDIR: proofHome,
|
|
OPENCLAW_STATE_DIR: stateDir,
|
|
OPENCLAW_CONFIG_PATH: path.join(proofHome, "openclaw.json"),
|
|
PATH: `${path.dirname(node)}:/usr/bin:/bin:/usr/sbin:/sbin`,
|
|
OPENCLAW_NODE_EXEC_HOST: "app",
|
|
OPENCLAW_NODE_EXEC_FALLBACK: "0",
|
|
// Same launch shape as MacNodeHostWorker: the worker must stay in the owned
|
|
// process group, or requireProcessTreeExit only proves the respawn wrapper died.
|
|
OPENCLAW_NO_RESPAWN: "1",
|
|
},
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
timeoutMs: 300_000,
|
|
requireProcessTreeExit: true,
|
|
onReady(child) {
|
|
const lines = createInterface({ input: child.stdout });
|
|
child.stderr.on("data", (data) => {
|
|
diagnostic = (diagnostic + data.toString()).slice(0, 4000);
|
|
});
|
|
lines.on("line", (line) => {
|
|
let message;
|
|
try {
|
|
message = JSON.parse(line);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (message.type !== "ready") {
|
|
return;
|
|
}
|
|
if (
|
|
message.version !== expected.version ||
|
|
!message.manifest?.commands?.includes("system.run") ||
|
|
!message.manifest?.commands?.includes("system.which") ||
|
|
!message.manifest?.commands?.includes("browser.proxy") ||
|
|
!message.manifest?.commands?.includes("browser.proxy.upload.v1") ||
|
|
!message.manifest?.commands?.includes("mcp.tools.call.v1")
|
|
) {
|
|
failure = new Error("Bundled worker returned an incompatible capability manifest");
|
|
child.stdin.end('{"type":"stop"}\n');
|
|
return;
|
|
}
|
|
ready = true;
|
|
process.stdout.write(
|
|
`${JSON.stringify({ architecture: process.arch, nativeFirst, build: actual, nativeFiles, databasePath, manifest: message.manifest })}\n`,
|
|
);
|
|
child.stdin.end('{"type":"stop"}\n');
|
|
});
|
|
child.on("close", () => lines.close());
|
|
child.stdin.on("error", (error) => {
|
|
failure = error;
|
|
terminateManagedChild(child, "SIGKILL");
|
|
});
|
|
},
|
|
});
|
|
for (const file of coordinatorFiles) {
|
|
fs.rmSync(file, { force: true });
|
|
}
|
|
if (failure || !ready || exitCode !== 0 || /failed during register/u.test(diagnostic)) {
|
|
throw new Error(
|
|
`Bundled worker proof failed (${exitCode}): ${failure?.message ?? "missing readiness or registration failure"}; ${diagnostic}`,
|
|
{ cause: failure },
|
|
);
|
|
}
|
|
if (nativeRows) {
|
|
const initialized = new DatabaseSync(databasePath, { readOnly: true });
|
|
try {
|
|
assert.deepEqual(readMacNodeWorkerProofRows(initialized), nativeRows);
|
|
} finally {
|
|
initialized.close();
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
fs.rmSync(home, { recursive: true, force: true });
|
|
}
|