scripts/control-omb.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env -S node --experimental-strip-types
// The openmausbot headless verbs for verifying an isolated fixture. A verb that
// changes something never runs on a server it merely found.
import { spawn, type ChildProcess } from "node:child_process";
import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseArgs, type ParseArgsOptionsConfig } from "node:util";
import { EXIT, HeadlessError } from "../server/headless/args.ts";
import { runHeadlessCommand } from "../server/headless/main.ts";
import type { handleToolCall, request } from "./mcp-server.ts";
import { launchUi, runControlOmbUi } from "./testing/control-omb-ui.ts";
import { removeTempDir, waitForExit } from "../server/testing/cleanup.ts";
import { hostTimeout } from "../server/testing/host-timeout.ts";
import { freePortBlock } from "../server/testing/ports.ts";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
/** Outside the bot name pool, so no suite can plan a bot that collides with the
* starter. */
export const FIXTURE_STARTER_BOT_NAME = "Fixture Starter";
const FAKE_CLI = join(ROOT, "server", "testing", "fake-claude-cli.ts");
export class ControlOmbError extends HeadlessError {
constructor(message: string, hint?: string) {
super(message, EXIT.failed, hint);
}
}
export interface ControlOmbDependencies {
callTool?: typeof handleToolCall;
request?: typeof request;
env?: NodeJS.ProcessEnv;
}
export const HELP_UI = `renderer (needs a ui launch handle; every verb takes --ui HANDLE, never discovery):
node --experimental-strip-types scripts/control-omb.ts ui launch [--entry threads] [--tool-calls JSON] [--mode happy] [--engine codex]
ui snapshot --ui HANDLE [--interactive]
ui click --ui HANDLE (--ref @eN | --name NAME)
ui type --ui HANDLE (--ref @eN | --name NAME) --text TEXT
ui press --ui HANDLE --keys KEYS
ui screenshot --ui HANDLE --out PATH.png
ui console --ui HANDLE
ui eval --ui HANDLE --js CODE
ui flag --ui HANDLE --set features.NAME=VALUE [--dry-run]
ui wait-settle --ui HANDLE [--timeout 30]
ui help`;
export const HELP = `control-omb — the openmausbot headless verbs (docs/headless-cli.md), for verifying a fixture
read-only (every verb also takes --url URL):
doctor
bots
channels
models
messages --bot ID [--task ID] [--limit 30]
messages --channel ID [--task ID] [--limit 30]
wait --bot ID [--task ID] [--timeout 30] [--for job|turn]
wait --channel ID [--task ID] [--timeout 30]
export --thread ID --out DIR
events --job THREAD_ID --until-settled
mutating (an explicit --url or OPENMAUSBOT_URL/OMB_PORT is required):
new-bot --name NAME [--section TEAM]
new-channel --name NAME --members ID,ID
send --bot ID --text TEXT [--task ID] [--dry-run]
send-channel --channel ID --text TEXT [--task ID] [--dry-run]
run --bot ID --text TEXT [--wait job|turn|none] [--timeout 5m] [--export DIR]
interrupt --bot ID [--task ID] [--tree] [--dry-run]
interrupt --channel ID [--task ID] [--dry-run]
edit --bot ID --message ID --text TEXT [--task ID] [--dry-run]
set-model --bot ID --instance ID --model ID [--task ID] [--effort LEVEL] [--dry-run]
team apply FILE [--dry-run | --check]
${HELP_UI}
isolated fixture:
node --experimental-strip-types scripts/control-omb.ts launch
Output is JSON. wait defaults to 30 seconds here. launch and ui launch own a temporary fake-engine server until interrupted.`;
const commonOptions = {
url: { type: "string" },
} satisfies ParseArgsOptionsConfig;
export function parse(
command: string,
args: string[],
options: ParseArgsOptionsConfig = {},
): Record<string, unknown> & { url?: string } {
try {
return parseArgs({
args,
options: { ...commonOptions, ...options },
strict: true,
allowPositionals: false,
}).values as Record<string, unknown> & { url?: string };
} catch (error) {
throw new ControlOmbError(
error instanceof Error ? error.message : String(error),
`run control-omb help for the ${command} syntax`,
);
}
}
export async function runControlOmb(
argv: string[],
dependencies: ControlOmbDependencies = {},
): Promise<unknown> {
const [command = "help", ...args] = argv;
if (command === "help" || command === "--help" || command === "-h") return HELP;
if (command === "launch") throw new ControlOmbError("launch is available only from the executable CLI");
if (command === "ui") return runControlOmbUi(args);
return runHeadlessCommand(argv, {
env: dependencies.env ?? process.env,
...(dependencies.callTool ? { callTool: dependencies.callTool } : {}),
...(dependencies.request ? { request: dependencies.request } : {}),
requireExplicitForMutations: true,
defaultWaitMs: 30_000,
});
}
export interface VerificationServer {
info: { url: string; pid: number; dataDir: string; logPath: string };
fixtureDumpPath: string;
child: ChildProcess;
close(): Promise<void>;
}
/** A temporary home in `dataDir`, the fake engine's knobs from `parentEnv`, node
* on PATH, and nothing else from the parent shell or this machine's CLIs. */
export function verificationServerEnvironment(parentEnv: NodeJS.ProcessEnv, dataDir: string, port: number): NodeJS.ProcessEnv {
const childEnv: NodeJS.ProcessEnv = {};
const platformKeys = new Set(["SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "LANG", "LC_ALL", "TZ"]);
for (const [key, value] of Object.entries(parentEnv)) {
const normalized = key.toUpperCase();
if (value && platformKeys.has(normalized)) childEnv[normalized] = value;
}
const fixtureTemp = join(dataDir, "tmp");
Object.assign(childEnv, {
HOME: dataDir,
USERPROFILE: dataDir,
APPDATA: join(dataDir, "AppData", "Roaming"),
LOCALAPPDATA: join(dataDir, "AppData", "Local"),
XDG_CONFIG_HOME: join(dataDir, ".config"),
XDG_CACHE_HOME: join(dataDir, ".cache"),
XDG_DATA_HOME: join(dataDir, ".local", "share"),
TEMP: fixtureTemp,
TMP: fixtureTemp,
TMPDIR: fixtureTemp,
HERMES_HOME: join(dataDir, ".hermes"),
OMB_DATA_DIR: dataDir,
OMB_PORT: String(port),
OMB_WEBHOOK_PORT: String(port + 1),
// A caller's FAKE_CLAUDE_MODE overrides this below to drive failure paths.
FAKE_CLAUDE_MODE: parentEnv.FAKE_CLAUDE_MODE || "happy",
FAKE_CLAUDE_DUMP: join(dataDir, "fake-claude-dump.json"),
// POSIX resolves the fake CLI's `#!/usr/bin/env node` shebang through PATH;
// Windows resolves it through spawnCli without a shell.
PATH: dirname(process.execPath),
// Without it the server also scans /opt/homebrew/bin, /usr/local/bin and the
// login shell's PATH, finding a developer's own engine CLIs that CI lacks.
OMB_TEST_SEALED_PATH: "1",
});
// The fake engines' knobs are the one thing a caller may script into the child.
for (const [key, value] of Object.entries(parentEnv)) {
// FAKE_CLAUDE_DUMP stays the launcher's: assertions read fixtureDumpPath.
if ((key.startsWith("FAKE_CLAUDE_") || key.startsWith("FAKE_CODEX_TEXT_") || key.startsWith("FAKE_CODEX_COMMAND") || key === "FAKE_ACP_HANG_ON") && key !== "FAKE_CLAUDE_DUMP" && value) childEnv[key] = value;
}
// Enables POST /api/testing/org-library; the route does not exist without it.
if (parentEnv.OMB_TEST_ORG_LIBRARY_KEY) childEnv.OMB_TEST_ORG_LIBRARY_KEY = parentEnv.OMB_TEST_ORG_LIBRARY_KEY;
// Enables POST /api/testing/internal-capability (a bot's own tool routes, such as publish_page); the route refuses without it.
if (parentEnv.OMB_TEST_INTERNAL_CAPABILITY_KEY) childEnv.OMB_TEST_INTERNAL_CAPABILITY_KEY = parentEnv.OMB_TEST_INTERNAL_CAPABILITY_KEY;
// One log line per browser call and preview frame.
if (parentEnv.OMB_BROWSER_TIMING === "1") childEnv.OMB_BROWSER_TIMING = "1";
// A share on the tailnet (server/tailnet-listener.ts): a test names one of this machine's own addresses as its stand-in.
for (const key of ["OMB_TAILNET_NAME", "OMB_TAILNET_ADDRESSES", "OMB_TEST_TAILNET_STAND_IN"]) if (parentEnv[key]) childEnv[key] = parentEnv[key];
// Warm summaries' minutes as seconds (server/warm-summary.ts), and the switch that turns them off.
for (const key of ["OMB_WARM_SUMMARY_TIME_SCALE", "OMB_WARM_SUMMARY"]) if (parentEnv[key]) childEnv[key] = parentEnv[key];
// A loopback fake only: the key never reaches a real provider.
const liveUrl = parentEnv.OMB_OPENAI_LIVE_URL?.trim() ?? "";
if (/^http:\/\/127\.0\.0\.1:\d{1,5}$/.test(liveUrl)) {
childEnv.OMB_OPENAI_LIVE_URL = liveUrl;
if (parentEnv.OMB_OPENAI_LIVE_KEY) childEnv.OMB_OPENAI_LIVE_KEY = parentEnv.OMB_OPENAI_LIVE_KEY;
}
// A loopback stand-in for Postiz Cloud only (server/testing/fake-postiz.ts): no test signs in to the real one.
const postizUrl = parentEnv.OMB_POSTIZ_CLOUD_URL?.trim() ?? "";
if (/^http:\/\/127\.0\.0\.1:\d{1,5}$/.test(postizUrl)) childEnv.OMB_POSTIZ_CLOUD_URL = postizUrl;
// A loopback connected-apps service only (server/testing/broker-fake.ts): its token never reaches the real one.
const brokerUrl = parentEnv.OMB_COMPOSIO_BROKER_URL?.trim() ?? "";
if (/^http:\/\/127\.0\.0\.1:\d{1,5}$/.test(brokerUrl) && parentEnv.OMB_COMPOSIO_BROKER_TOKEN) {
childEnv.OMB_COMPOSIO_BROKER_URL = brokerUrl;
childEnv.OMB_COMPOSIO_BROKER_TOKEN = parentEnv.OMB_COMPOSIO_BROKER_TOKEN;
}
// Research against loopback stubs only (server/x-research.e2e.test.ts): the relay token crosses only with its stub,
// and a treg stand-in replaces treg.to, so no test reaches a real relay or treg.
const xUrl = parentEnv.OMB_CLOUD_X_URL?.trim() ?? "";
if (/^http:\/\/127\.0\.0\.1:\d{1,5}(?:\/[A-Za-z0-9_-]+)*$/.test(xUrl)) {
childEnv.OMB_CLOUD_X_URL = xUrl;
if (parentEnv.OMB_CLOUD_X_TOKEN) childEnv.OMB_CLOUD_X_TOKEN = parentEnv.OMB_CLOUD_X_TOKEN;
}
// @devs reports reach a loopback fake only, else a closed port: never the MausBot team.
const bugReportUrl = parentEnv.OMB_BUG_REPORT_URL?.trim() ?? "";
childEnv.OMB_BUG_REPORT_URL = /^http:\/\/127\.0\.0\.1:\d{1,5}\/[A-Za-z0-9/_-]*$/.test(bugReportUrl) ? bugReportUrl : "http://127.0.0.1:9/api/bug-reports";
const tregUrl = parentEnv.OMB_TREG_URL?.trim() ?? "";
if (/^http:\/\/127\.0\.0\.1:\d{1,5}$/.test(tregUrl)) childEnv.OMB_TREG_URL = tregUrl;
// Loopback only, for harness mechanics: a fake ElevenLabs the measurement
// substrate can drive without a key. Never a real address.
const elevenApi = parentEnv.OMB_ELEVENLABS_API?.trim() ?? "";
if (/^http:\/\/127\.0\.0\.1:\d{1,5}$/.test(elevenApi)) childEnv.OMB_ELEVENLABS_API = elevenApi;
// Live calls need Pro; without it the fixture refuses a call with 402 needsPro.
if (parentEnv.OMB_PRO_PLAN?.trim() === "1") childEnv.OMB_PRO_PLAN = "1";
// Arms the one-shot audio-append failure inside the fixture server.
if (parentEnv.OMB_TEST_FAIL_AUDIO_APPEND_ONCE) {
childEnv.OMB_TEST_FAIL_AUDIO_APPEND_ONCE = parentEnv.OMB_TEST_FAIL_AUDIO_APPEND_ONCE;
}
// The Claude broker's approval deadline fires on a marker file, not after 15 minutes.
if (parentEnv.OMB_TEST_APPROVAL_CLOCK === "1") childEnv.OMB_TEST_APPROVAL_CLOCK = "1";
// The stall watchdog fires on a marker file, not after 20 minutes of silence.
if (parentEnv.OMB_TEST_STALL_CLOCK === "1") childEnv.OMB_TEST_STALL_CLOCK = "1";
// Date.now() jumps when a marker file says so, as it does when a suspended machine wakes; timers don't.
if (parentEnv.OMB_TEST_RESUME_CLOCK === "1") childEnv.OMB_TEST_RESUME_CLOCK = "1";
// The memory gate runs here too, on the memory a fixture file says this machine has.
if (parentEnv.OMB_TEST_MEMORY_GATE === "1") childEnv.OMB_TEST_MEMORY_GATE = "1";
// The first-reply rescue's delay in ms, so a check need not sit through 20 s of silence.
if (/^\d{1,6}$/.test(parentEnv.OMB_FIRST_REPLY_NOTICE_MS ?? "")) childEnv.OMB_FIRST_REPLY_NOTICE_MS = parentEnv.OMB_FIRST_REPLY_NOTICE_MS;
// How long a tool call may hold the person's message unread, in ms, so a check need not sit through a minute.
if (/^\d{1,6}$/.test(parentEnv.OMB_PERSON_FIRST_MS ?? "")) childEnv.OMB_PERSON_FIRST_MS = parentEnv.OMB_PERSON_FIRST_MS;
// How often @devs reports are asked about, in ms, so a check sees a fix arrive without an hour's wait.
if (/^\d{1,7}$/.test(parentEnv.OMB_DEVS_STATUS_MS ?? "")) childEnv.OMB_DEVS_STATUS_MS = parentEnv.OMB_DEVS_STATUS_MS;
// The quiet-status knobs in ms, so a long tool call shows its sign of life without a minute's wait.
for (const knob of ["OMB_ACP_QUIET_NOTICE_MS", "OMB_ACP_QUIET_TICK_MS"] as const) {
if (/^\d{1,7}$/.test(parentEnv[knob] ?? "")) childEnv[knob] = parentEnv[knob];
}
// The UI checks drive this fixture from a real browser, as `pnpm dev` does; OMB_DEV_TRUST_LOCAL_BROWSER=0 tests the sign-in.
if (parentEnv.OMB_DEV_TRUST_LOCAL_BROWSER !== "0") childEnv.OMB_DEV_TRUST_LOCAL_BROWSER = "1";
// Desktop mode: the owner capability the app would hand the server.
if (parentEnv.OMB_TEST_DESKTOP_OWNER_TOKEN) {
childEnv.OMB_TEST_DESKTOP_OWNER_TOKEN = parentEnv.OMB_TEST_DESKTOP_OWNER_TOKEN;
childEnv.OMB_DESKTOP_PARENT = "1";
if (parentEnv.OMB_TEST_DESKTOP_PAGES_ACCESS) childEnv.OMB_TEST_DESKTOP_PAGES_ACCESS = parentEnv.OMB_TEST_DESKTOP_PAGES_ACCESS;
if (parentEnv.OMB_TEST_DESKTOP_TAILNET_SHARE) childEnv.OMB_TEST_DESKTOP_TAILNET_SHARE = parentEnv.OMB_TEST_DESKTOP_TAILNET_SHARE;
}
// The driver record the app hands its own server: without it, no server may use this computer.
if (parentEnv.OMB_TEST_DESKTOP_CUA_CONNECTION) childEnv.OMB_TEST_DESKTOP_CUA_CONNECTION = parentEnv.OMB_TEST_DESKTOP_CUA_CONNECTION;
return childEnv;
}
/** The fixture hooks its environment asks for, then the server. */
export function verificationServerArgs(childEnv: NodeJS.ProcessEnv): string[] {
const hook = (file: string) => ["--import", pathToFileURL(join(ROOT, "server", "testing", file)).href];
return [
"--experimental-strip-types",
...(childEnv.OMB_TEST_FAIL_AUDIO_APPEND_ONCE === "1" ? hook("fail-audio-append-once.mjs") : []),
...(childEnv.OMB_TEST_DESKTOP_OWNER_TOKEN || childEnv.OMB_TEST_DESKTOP_CUA_CONNECTION ? hook("desktop-parent.mjs") : []),
...(childEnv.OMB_TEST_APPROVAL_CLOCK === "1" ? hook("approval-clock-hooks.mjs") : []),
...(childEnv.OMB_TEST_STALL_CLOCK === "1" ? hook("stall-clock-hooks.mjs") : []),
...(childEnv.OMB_TEST_RESUME_CLOCK === "1" ? hook("resume-clock-hooks.mjs") : []),
...(childEnv.OMB_TEST_MEMORY_GATE === "1" ? hook("memory-gate-hooks.mjs") : []),
join(ROOT, "server", "index.ts"),
];
}
/** Start one foreground-owned, fake-engine server with no access to user data. */
export async function launchVerificationServer(
parentEnv: NodeJS.ProcessEnv = process.env,
signal?: AbortSignal,
localVm?: { binDir: string; host: string; sshKey: string; staticDir: string },
browser?: { binaryPath: string; executablePath: string },
/** A stand-in enterprise layer (the folder shape core loads) and the key
* it should accept, so a recipe can prove entitled behaviour offline. */
enterprise?: { dir: string; licenseKey: string },
room?: { scripted: boolean },
/** Optional repository-owned fake providers for multi-engine setup checks. */
extraProviders: Array<"codex" | "cline" | "copilot" | "customAcp"> = [],
/** Programmatic tests only: an owned loopback Boat provider, never a live account. */
boatFixtureApi?: string,
/** Programmatic web UI qualification against the fixture's static server. */
staticDir?: string,
): Promise<VerificationServer> {
if (boatFixtureApi) {
if (!/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}$/.test(boatFixtureApi)) {
throw new ControlOmbError("Boat verification requires an explicit loopback HTTP provider");
}
try { new URL(boatFixtureApi); }
catch { throw new ControlOmbError("Boat verification requires a valid loopback port"); }
}
if (localVm) {
const endpoint = new URL(localVm.host);
if (endpoint.protocol !== "ssh:" || endpoint.hostname !== "127.0.0.1" || endpoint.password) {
throw new ControlOmbError("Local VM verification requires an explicit loopback Podman machine");
}
}
// OMB_TEST_PORTS=19975-19999 keeps a recipe's fixture in a range of its own.
const range = /^(\d+)-(\d+)$/.exec((parentEnv.OMB_TEST_PORTS ?? process.env.OMB_TEST_PORTS)?.trim() ?? "");
const port = range ? await freePortBlock([0, 1], Number(range[1]), Number(range[2]) - Number(range[1])) : await freePortBlock([0, 1]);
if (signal?.aborted) throw new ControlOmbError("verification launch cancelled");
const url = `http://127.0.0.1:${port}`;
// Native browser daemons use UNIX sockets, and a macOS temp home can exceed
// their path limit.
const dataDir = mkdtempSync(join(browser && process.platform !== "win32" ? "/tmp" : tmpdir(), "openmausbot-verify-data-"));
const fixtureTemp = join(dataDir, "tmp");
const fixtureDumpPath = join(dataDir, "fake-claude-dump.json");
mkdirSync(fixtureTemp, { recursive: true });
const evidenceDir = join(tmpdir(), "openmausbot-verification-evidence");
mkdirSync(evidenceDir, { recursive: true });
const logPath = join(evidenceDir, `server-${Date.now()}-${process.pid}.log`);
const imageApi = parentEnv.OMB_FAKE_IMAGE_API_URL?.trim() ?? "";
const imageGen = /^http:\/\/127\.0\.0\.1:\d{1,5}\/v1$/.test(imageApi)
? { primary: { provider: "openai" }, fallback: { provider: "custom", model: "fixture-image" }, customUrl: imageApi }
: undefined;
writeFileSync(join(dataDir, "config.json"), JSON.stringify({
...(boatFixtureApi ? { box: { token: "box_verification_fixture" } } : {}),
...(imageGen ? { imageGen } : {}),
// A setup card's answers would be one more engine call at every new bot, taking replies a test scripts for its turns.
features: { smartSetupChoices: false },
instances: {
...(extraProviders.includes("codex") ? { codex: {
driver: "codex", displayName: "Verification Codex", config: { cli: fileURLToPath(new URL("../server/testing/fake-codex-app-server.ts", import.meta.url)) },
} } : {}),
...(extraProviders.includes("cline") ? { cline: {
driver: "clineAgent", displayName: "Verification Cline",
config: { cli: fileURLToPath(new URL("../server/testing/fake-acp-cli.ts", import.meta.url)) },
environment: { CLINE_PROVIDER: "openai-compatible", CLINE_MODEL: "fixture-model", CLINE_API_KEY: "fixture-only",
FAKE_ACP_DUMP: join(dataDir, "cline-launch.json") },
} } : {}),
...(extraProviders.includes("copilot") ? { githubCopilot: {
driver: "copilotAgent", displayName: "Verification Copilot",
config: { cli: fileURLToPath(new URL("../server/testing/fake-acp-cli.ts", import.meta.url)) },
environment: { FAKE_ACP_MODELS: "auto,claude-haiku-4.5,gpt-5.6-luna", FAKE_ACP_REJECT_STDIO_MCP: "1",
FAKE_ACP_DUMP: join(dataDir, "copilot-launch.json") },
} } : {}),
...(extraProviders.includes("customAcp") ? { myAgent: {
driver: "customAcp", displayName: "Verification custom ACP",
config: { cli: fileURLToPath(new URL("../server/testing/fake-acp-cli.ts", import.meta.url)) },
environment: { FAKE_ACP_MODELS: "fixture-fast,fixture-deep", FAKE_ACP_DUMP: join(dataDir, "custom-acp-launch.json") },
} } : {}),
claude: {
driver: "claudeAgent",
displayName: "Verification fixture",
config: { cli: FAKE_CLI },
...(room?.scripted ? { environment: { FAKE_CLAUDE_ROOM_PLAN: join(dataDir, "room-plan.json") } } : {}),
},
},
}, null, 2));
const log = openSync(logPath, "a", 0o600);
const childEnv = verificationServerEnvironment(parentEnv, dataDir, port);
// Opt-in live Local VM fixture: keep the temporary home and fake engine,
// granting only the explicitly selected machine connection and static UI.
if (localVm) Object.assign(childEnv, {
OMB_EXTRA_PATH: [localVm.binDir, ...(process.platform === "win32" ? [join(childEnv.SYSTEMROOT || "C:\\Windows", "System32")] : [])].join(delimiter),
CONTAINER_HOST: localVm.host,
CONTAINER_SSHKEY: localVm.sshKey,
OMB_STATIC_DIR: localVm.staticDir,
});
if (enterprise) Object.assign(childEnv, { OMB_ENTERPRISE_DIR: enterprise.dir, OMB_LICENSE_KEY: enterprise.licenseKey });
if (browser) Object.assign(childEnv, {
OMB_AGENT_BROWSER_PATH: browser.binaryPath,
AGENT_BROWSER_EXECUTABLE_PATH: browser.executablePath,
});
if (boatFixtureApi) childEnv.OMB_BOAT_API = boatFixtureApi;
if (staticDir) childEnv.OMB_STATIC_DIR = staticDir;
const child = spawn(process.execPath, verificationServerArgs(childEnv), {
cwd: ROOT,
env: childEnv,
stdio: ["ignore", log, log],
});
closeSync(log);
// A CI runner's temporary folder goes with the runner, so a failed start carries the end of the server's log.
const logTail = () => {
try {
return `\n${readFileSync(logPath, "utf8").slice(-2_000)}`;
} catch {
return "";
}
};
const deadline = Date.now() + hostTimeout(20_000);
try {
for (;;) {
if (signal?.aborted) throw new ControlOmbError("verification launch cancelled");
if (child.exitCode !== null || child.signalCode !== null) {
throw new Error(`verification server exited before it was ready; see ${logPath}${logTail()}`);
}
try {
const timeout = AbortSignal.timeout(1_000);
const response = await fetch(`${url}/api/health`, {
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
});
const body = response.ok ? await response.json() as { app?: string } : null;
if (body?.app === "openmausbot") break;
} catch {
}
if (Date.now() >= deadline) throw new Error(`verification server did not become ready; see ${logPath}${logTail()}`);
await new Promise((resolve) => setTimeout(resolve, 150));
}
} catch (error) {
await waitForExit(child, { signal: "SIGTERM" });
await removeTempDir(dataDir);
throw error;
}
// Pinned: the random starter name shares a pool with names e2e suites plan.
try {
// First-run filesystem work can exceed a second on Windows.
const timeout = AbortSignal.timeout(Math.max(1, deadline - Date.now()));
const list = await fetch(`${url}/api/bots`, {
headers: { origin: url },
signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
});
const body = list.ok ? await list.json() as { bots?: Array<{ id: string }> } : null;
const seeded = body?.bots;
if (!Array.isArray(seeded) || seeded.length !== 1) {
throw new Error(`verification fixture did not seed exactly one starter bot; see ${logPath}`);
}
const renameTimeout = AbortSignal.timeout(Math.max(1, deadline - Date.now()));
const rename = await fetch(`${url}/api/bots/${seeded[0].id}/profile`, {
method: "PATCH",
headers: { "content-type": "application/json", origin: url },
body: JSON.stringify({ name: FIXTURE_STARTER_BOT_NAME }),
signal: signal ? AbortSignal.any([signal, renameTimeout]) : renameTimeout,
});
if (!rename.ok) throw new Error(`verification starter rename failed (${rename.status}); see ${logPath}`);
} catch (error) {
await waitForExit(child, { signal: "SIGTERM" });
await removeTempDir(dataDir);
throw error;
}
let closed = false;
return {
info: { url, pid: child.pid!, dataDir, logPath },
fixtureDumpPath,
child,
async close() {
if (closed) return;
closed = true;
await waitForExit(child, { signal: "SIGTERM" });
await removeTempDir(dataDir);
},
};
}
export function controlResultSucceeded(command: string, result: unknown): boolean {
if (command === "doctor") return (result as { ok?: unknown })?.ok === true;
if (command === "wait") return (result as { status?: unknown })?.status === "settled";
if (command === "ui" || command === "run" || command === "team") return (result as { ok?: unknown })?.ok !== false;
return true;
}
/** pnpm swallows Ctrl-C; a launcher that owns processes must get the signal itself. */
function requireForegroundTerminal(command: string): void {
if (process.env.npm_lifecycle_event === "control:omb") {
throw new ControlOmbError(
`${command} must own the terminal directly so Ctrl-C can clean up its children`,
`run \`node --experimental-strip-types scripts/control-omb.ts ${command}\``,
);
}
}
async function main() {
const command = process.argv[2] ?? "help";
if (command === "ui" && process.argv[3] === "launch") {
requireForegroundTerminal("ui launch");
await launchUi(process.argv.slice(4));
return;
}
if (command === "launch") {
requireForegroundTerminal("launch");
const startup = new AbortController();
const cancelStartup = () => startup.abort();
process.once("SIGINT", cancelStartup);
process.once("SIGTERM", cancelStartup);
let session: VerificationServer;
try {
session = await launchVerificationServer(process.env, startup.signal);
} finally {
process.removeListener("SIGINT", cancelStartup);
process.removeListener("SIGTERM", cancelStartup);
}
process.stdout.write(`${JSON.stringify({ ok: true, ...session.info }, null, 2)}\n`);
await new Promise<void>((resolve) => {
let stopping = false;
const stop = () => {
if (stopping) return;
stopping = true;
void session.close().finally(resolve);
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
session.child.once("close", () => {
if (!stopping) {
stopping = true;
process.exitCode = 1;
process.stderr.write(`${JSON.stringify({
ok: false,
error: `verification server exited unexpectedly; see ${session.info.logPath}`,
}, null, 2)}\n`);
void removeTempDir(session.info.dataDir).finally(resolve);
}
});
});
return;
}
const result = await runControlOmb(process.argv.slice(2));
process.stdout.write(typeof result === "string" ? `${result}\n` : `${JSON.stringify(result, null, 2)}\n`);
if (!controlResultSucceeded(command, result)) process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main().catch((error) => {
const failure = error instanceof HeadlessError
? { ok: false, error: error.message, ...(error.hint ? { hint: error.hint } : {}) }
: { ok: false, error: error instanceof Error ? error.message : String(error) };
process.stderr.write(`${JSON.stringify(failure, null, 2)}\n`);
process.exitCode = 1;
});
}