#!/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 & { url?: string } { try { return parseArgs({ args, options: { ...commonOptions, ...options }, strict: true, allowPositionals: false, }).values as Record & { 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 { 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; } /** 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 { 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((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; }); }