// Prove the built server starts with no node_modules in reach. dist-server is // copied out of the repo first: inside it, a bare import still resolves by // walking up to ./node_modules. import { execFile, spawn } from "node:child_process"; import assert from "node:assert/strict"; import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs, promisify } from "node:util"; import { browserBundlePaths, browserBundleSpec } from "../server/browser-bundle-release.ts"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const { values } = parseArgs({ options: { "browser-bundle": { type: "string" } } }); const browserBundle = values["browser-bundle"]; let browserSpec; if (browserBundle !== undefined) { assert(isAbsolute(browserBundle), "--browser-bundle must be an absolute staged browser target directory"); const manifest = JSON.parse(readFileSync(join(browserBundle, "manifest.json"), "utf8")); browserSpec = browserBundleSpec(manifest.target); assert.equal(manifest.target, `${process.platform}-${process.arch}`, "--browser-bundle must match this Node host's platform and architecture"); assert.equal(manifest.schemaVersion, browserSpec.schemaVersion, "Unsupported browser bundle manifest"); const paths = browserBundlePaths(browserBundle, manifest.target); for (const component of ["engine", "chrome"]) { assert.equal(manifest[component]?.version, browserSpec[component].version); assert.equal(manifest[component]?.executable, browserSpec[component].executable); assert(statSync(paths[component]).isFile(), `Missing bundled ${component}`); } } /** Asked of the OS (listen on 0), not picked at random: Windows reserves port * ranges (Hyper-V and WinNAT exclusions). Every listener stays open until all * are bound, so the ports differ. */ async function freePorts(count) { const servers = []; try { for (let i = 0; i < count; i++) { const server = createServer(); servers.push(server); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", resolve); }); } return servers.map((server) => server.address().port); } finally { await Promise.all(servers.map((server) => new Promise((resolve) => server.close(() => resolve())))); } } const staging = mkdtempSync(join(tmpdir(), "omb-smoke-")); const home = mkdtempSync(join(tmpdir(), "omb-smoke-home-")); const [port, webhookPort] = await freePorts(2); // OMB_SMOKE_DIST lets the release workflow aim this at a packaged app's // Resources/server tree instead of the repo build. try { // A desktop install can live beneath a user's CommonJS package scope. writeFileSync(join(staging, "package.json"), '{"type":"commonjs"}\n'); cpSync(process.env.OMB_SMOKE_DIST ?? join(root, "dist-server"), join(staging, "server"), { recursive: true }); if (browserBundle) cpSync(resolve(browserBundle), join(staging, "browser-engine"), { recursive: true }); } catch (error) { for (const directory of [staging, home]) rmSync(directory, { recursive: true, force: true }); throw error; } const fixtureEnv = { ...(process.env.PATH ? { PATH: process.env.PATH } : {}), ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}), HOME: home, USERPROFILE: home, APPDATA: join(home, "AppData", "Roaming"), LOCALAPPDATA: join(home, "AppData", "Local"), XDG_CONFIG_HOME: join(home, ".config"), XDG_CACHE_HOME: join(home, ".cache"), XDG_DATA_HOME: join(home, ".local", "share"), OMB_DATA_DIR: join(home, ".openmausbot"), OMB_PORT: String(port), OMB_WEBHOOK_PORT: String(webhookPort), // Not a genuine key: enough to make the server look for its enterprise layer. OMB_LICENSE_KEY: "omb1.not.real", ...(browserBundle ? { OMB_RESOURCES_PATH: staging, // A global engine on the developer's PATH must not make this test pass. PATH: process.platform === "win32" ? join(process.env.SystemRoot ?? "C:\\Windows", "System32") : "/usr/bin:/bin", } : {}), }; const child = spawn(process.execPath, [join(staging, "server", "index.js")], { cwd: staging, env: fixtureEnv, stdio: ["ignore", "pipe", "pipe"], }); let output = ""; child.stdout.on("data", (chunk) => (output += chunk)); child.stderr.on("data", (chunk) => (output += chunk)); // Best effort: Windows (and sometimes Linux) holds file handles past the // process, so removing the scratch dir right after the kill raises EPERM/EACCES. const cleanup = () => { child.kill("SIGKILL"); for (const dir of [staging, home]) { try { rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch { } } }; const deadline = Date.now() + 45_000; let listening = false; while (Date.now() < deadline) { if (child.exitCode !== null) break; try { const res = await fetch(`http://127.0.0.1:${port}/api/health`); if (res.ok) { listening = true; break; } } catch { } await new Promise((resolve) => setTimeout(resolve, 300)); } // The image ships dist-server without the repo's package.json (Dockerfile): the version must come from the bundle. let versionReport = null; if (listening) { try { versionReport = (await (await fetch(`http://127.0.0.1:${port}/.well-known/openmausbot/environment`, { signal: AbortSignal.timeout(5_000) })).json()).version; } catch (error) { versionReport = String(error); } } let searchReport = null; if (listening) { try { const response = await fetch(`http://127.0.0.1:${port}/api/search?q=packaged-worker-probe`, { signal: AbortSignal.timeout(10_000) }); searchReport = { status: response.status, body: await response.json() }; } catch (error) { searchReport = { error: String(error) }; } } let browserReport = null; if (browserBundle && listening) { try { const response = await fetch(`http://127.0.0.1:${port}/api/config`, { signal: AbortSignal.timeout(5_000) }); assert.equal(response.status, 200, "Packaged browser config did not respond successfully"); const config = await response.json(); browserReport = { browserEngine: config.browserEngine, browserEnabled: config.features?.browser }; } catch (error) { browserReport = { error: String(error) }; } } // Bundling relocates import.meta.url, so a proxy's sibling paths can resolve one // level too high while /api/health still answers: check the paths the server // actually resolved, from inside the staged copy. const probe = join(staging, "server", "probe-proxy-paths.mjs"); writeFileSync( probe, [ 'import { existsSync } from "node:fs";', 'import { SPAWNED_PROXIES } from "./proxy-paths.js";', "const missing = Object.entries(SPAWNED_PROXIES).filter(([, p]) => !existsSync(p));", "console.log(JSON.stringify({ resolved: SPAWNED_PROXIES, missing }));", ].join("\n"), ); // The packaged tree carries the bundled enterprise layer inside the server // root, or a licensed install silently runs the open-source edition. const layerShipped = existsSync(join(staging, "server", "enterprise", "server", "index.js")); let editionReport = null; if (listening) { try { editionReport = await (await fetch(`http://127.0.0.1:${port}/api/edition`, { signal: AbortSignal.timeout(5_000) })).json(); } catch (error) { editionReport = { error: String(error) }; } } let proxyReport = null; try { const { stdout } = await promisify(execFile)(process.execPath, [probe], { cwd: staging, env: fixtureEnv }); proxyReport = JSON.parse(stdout); } catch (error) { proxyReport = { error: String((error && error.message) || error) }; } // The public MCP process is a second packaged entry point. Closing stdin right // after a request proves shutdown lets the final JSON-RPC frame drain. let mcpReport = null; if (listening) { const mcp = spawn(process.execPath, [join(staging, "server", "mcp-server.js")], { cwd: staging, env: fixtureEnv, stdio: ["pipe", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; mcp.stdout.on("data", (chunk) => (stdout += chunk)); mcp.stderr.on("data", (chunk) => (stderr += chunk)); const initialize = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "package-smoke", version: "1" } }, }); const health = JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "get_system_health", arguments: {} }, }); mcp.stdin.write(`${initialize}\n${health}\n`); const healthDeadline = Date.now() + 8_000; while (Date.now() < healthDeadline && !stdout.split("\n").some((line) => line.includes('"id":2'))) { if (mcp.exitCode !== null) break; await new Promise((resolve) => setTimeout(resolve, 25)); } // EOF without waiting: if the stdio close path exits before buffered frames // drain, id 3 will be missing. mcp.stdin.end(`${JSON.stringify({ jsonrpc: "2.0", id: 3, method: "ping" })}\n`); const closed = new Promise((resolve) => mcp.once("close", (code, signal) => resolve({ code, signal }))); let timeout; const exit = await Promise.race([ closed, new Promise((resolve) => { timeout = setTimeout(() => resolve({ timeout: true }), 10_000); }), ]); clearTimeout(timeout); if (exit.timeout) { mcp.kill("SIGKILL"); await closed; } try { const responses = stdout.trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)); mcpReport = { exit, responses, stderr }; } catch (error) { mcpReport = { exit, stdout, stderr, error: String(error) }; } } // The published command's headless verbs run from the bundle too: doctor // (which reads the job capability) and bots, against the packaged server. let headlessReport = null; if (listening) { const runCli = (args) => new Promise((resolve) => { const cli = spawn(process.execPath, [join(staging, "server", "openmausbot.js"), ...args], { cwd: staging, env: { ...fixtureEnv, OPENMAUSBOT_URL: `http://127.0.0.1:${port}` }, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; let stderr = ""; cli.stdout.on("data", (chunk) => (stdout += chunk)); cli.stderr.on("data", (chunk) => (stderr += chunk)); const timer = setTimeout(() => cli.kill("SIGKILL"), 20_000); cli.on("close", (code) => { clearTimeout(timer); resolve({ code, stdout, stderr }); }); }); try { const doctor = await runCli(["doctor", "--json"]); const bots = await runCli(["bots", "--json"]); headlessReport = { doctor: { code: doctor.code, body: JSON.parse(doctor.stdout), stderr: doctor.stderr }, bots: { code: bots.code, body: JSON.parse(bots.stdout) } }; } catch (error) { headlessReport = { error: String((error && error.message) || error) }; } } // An HTTP export must actually start the bundled worker outside the checkout. // Existence checks alone cannot catch an unbundled transitive dependency. let backupReport = null; if (listening) { try { const response = await fetch(`http://127.0.0.1:${port}/api/workspace-backup/export`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password: "packaged-fixture-password-only" }), signal: AbortSignal.timeout(30_000), }); assert.equal(response.status, 200, "Packaged backup worker did not export successfully"); const archive = await response.json(); assert.equal(archive.summary.format, "openmaus.workspace-backup"); const download = await fetch(`http://127.0.0.1:${port}/api/workspace-backup/download/${archive.id}`); assert.equal(download.status, 200); const bytes = Buffer.from(await download.arrayBuffer()); assert.equal(bytes.length, archive.bytes); assert.equal(bytes.subarray(0, 16).toString(), "OMB-WORKSPACE-1\n"); backupReport = { ok: true, bytes: bytes.length }; } catch (error) { backupReport = { error: String(error) }; } } // SECURITY.md's loopback promise has two halves. The backup export above proves the // packaged desktop's own loopback may run a public mutation. The other half: the same // request is refused when the server runs where "local" includes every bot's shell. Start // the same bundle again at service trust and check both sides of that gate. let serviceTrustReport = null; if (listening) { const serviceHome = mkdtempSync(join(tmpdir(), "omb-smoke-service-")); const [servicePort, serviceWebhookPort] = await freePorts(2); const service = spawn(process.execPath, [join(staging, "server", "index.js")], { cwd: staging, env: { ...fixtureEnv, HOME: serviceHome, USERPROFILE: serviceHome, OMB_DATA_DIR: join(serviceHome, ".openmausbot"), OMB_PORT: String(servicePort), OMB_WEBHOOK_PORT: String(serviceWebhookPort), OMB_LOOPBACK_TRUST: "service" }, stdio: ["ignore", "pipe", "pipe"], }); let serviceOutput = ""; service.stdout.on("data", (chunk) => (serviceOutput += chunk)); service.stderr.on("data", (chunk) => (serviceOutput += chunk)); try { const until = Date.now() + 45_000; let serving = false; while (Date.now() < until && service.exitCode === null) { try { // A stalled connection must not outlive the readiness deadline: every probe is bounded. const remaining = Math.max(until - Date.now(), 1_000); if ((await fetch(`http://127.0.0.1:${servicePort}/api/health`, { signal: AbortSignal.timeout(Math.min(remaining, 10_000)) })).ok) { serving = true; break; } } catch { /* not up yet */ } await new Promise((resolve) => setTimeout(resolve, 300)); } const healthStatus = serving ? (await fetch(`http://127.0.0.1:${servicePort}/api/health`, { signal: AbortSignal.timeout(10_000) })).status : null; const mutation = await fetch(`http://127.0.0.1:${servicePort}/api/workspace-backup/export`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ password: "packaged-fixture-password-only" }), signal: AbortSignal.timeout(15_000), }); serviceTrustReport = { serving, healthStatus, exportStatus: mutation.status }; } catch (error) { serviceTrustReport = { error: String(error) }; } finally { // Windows holds data files open past the kill for a moment: wait for the exit before removing the home, and // say so when the home could not go. const exited = new Promise((resolve) => service.once("exit", () => resolve(null))); if (service.exitCode === null) service.kill("SIGKILL"); await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 10_000).unref())]); try { rmSync(serviceHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch (error) { serviceTrustReport ??= {}; serviceTrustReport.cleanup = `could not remove the service-trust home: ${error}`; } } } // The container image runs the server through server-launcher.js: it must start // the server as its own child, through the bootstrap that keeps Node's compile // cache on the data volume, and SIGTERM must end both with a clean exit. let launcherReport = null; if (listening && process.platform !== "win32") { const launcherHome = mkdtempSync(join(tmpdir(), "omb-smoke-launcher-")); const [launcherPort, launcherWebhookPort] = await freePorts(2); const launcher = spawn(process.execPath, [join(staging, "server", "server-launcher.js")], { cwd: staging, env: { ...fixtureEnv, HOME: launcherHome, USERPROFILE: launcherHome, OMB_DATA_DIR: join(launcherHome, ".openmausbot"), OMB_PORT: String(launcherPort), OMB_WEBHOOK_PORT: String(launcherWebhookPort) }, stdio: ["ignore", "pipe", "pipe"], }); let launcherOutput = ""; launcher.stdout.on("data", (chunk) => (launcherOutput += chunk)); launcher.stderr.on("data", (chunk) => (launcherOutput += chunk)); const exited = new Promise((resolve) => launcher.once("exit", (code, signal) => resolve({ code, signal }))); try { let serverPid = null; const until = Date.now() + 45_000; while (Date.now() < until && launcher.exitCode === null && serverPid === null) { try { const health = await (await fetch(`http://127.0.0.1:${launcherPort}/api/health`, { signal: AbortSignal.timeout(2_000) })).json(); if (Number.isSafeInteger(health.pid)) serverPid = health.pid; } catch { /* not up yet */ } if (serverPid === null) await new Promise((resolve) => setTimeout(resolve, 300)); } // The bootstrap writes the cache about 10 s after the server starts. const launcherCache = join(launcherHome, ".openmausbot", "cache", "node-compile"); let cached = false; for (const written = Date.now() + 30_000; serverPid !== null && !cached && Date.now() < written && launcher.exitCode === null;) { try { cached = readdirSync(launcherCache).some((name) => /^v\d+\.\d+\.\d+-/.test(name)); } catch { /* not yet */ } if (!cached) await new Promise((resolve) => setTimeout(resolve, 300)); } launcher.kill("SIGTERM"); let timer; const exit = await Promise.race([exited, new Promise((resolve) => { timer = setTimeout(() => resolve({ timeout: true }), 20_000); })]); clearTimeout(timer); let stillServing = false; try { await fetch(`http://127.0.0.1:${launcherPort}/api/health`, { signal: AbortSignal.timeout(2_000) }); stillServing = true; } catch { /* stopped */ } launcherReport = { serverPid, launcherPid: launcher.pid, cached, exit, stillServing, output: launcherOutput.slice(-2_000) }; } catch (error) { launcherReport = { error: String(error), output: launcherOutput.slice(-2_000) }; } finally { if (launcher.exitCode === null) launcher.kill("SIGKILL"); try { rmSync(launcherHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch { /* the OS will reap it */ } } } // The packaged desktop forks desktop-entry.mjs, which turns on Node's compile // cache. Started twice on one cache directory: the first must write the cache // while it runs (Windows has no SIGTERM, so a kill skips the exit-time write). let desktopEntryReport = null; if (listening) { const entryHome = mkdtempSync(join(tmpdir(), "omb-smoke-entry-")); const cacheDir = join(entryHome, "server-compile-cache"); const cacheEntries = () => { try { return readdirSync(cacheDir).filter((name) => /^v\d+\.\d+\.\d+-/.test(name)) .flatMap((version) => readdirSync(join(cacheDir, version)).map((file) => `${version}/${file}`)); } catch { return []; } }; desktopEntryReport = { starts: [] }; try { for (let round = 0; round < 2; round++) { const [entryPort, entryWebhookPort] = await freePorts(2); const entry = spawn(process.execPath, [join(staging, "server", "desktop-entry.mjs")], { cwd: staging, env: { ...fixtureEnv, HOME: entryHome, USERPROFILE: entryHome, OMB_DATA_DIR: join(entryHome, ".openmausbot"), OMB_PORT: String(entryPort), OMB_WEBHOOK_PORT: String(entryWebhookPort), OMB_SERVER_COMPILE_CACHE: cacheDir }, stdio: ["ignore", "pipe", "pipe"], }); let entryOutput = ""; entry.stdout.on("data", (chunk) => (entryOutput += chunk)); entry.stderr.on("data", (chunk) => (entryOutput += chunk)); const exited = new Promise((resolve) => entry.once("exit", (code, signal) => resolve({ code, signal }))); let served = false; const until = Date.now() + 45_000; while (Date.now() < until && entry.exitCode === null && !served) { try { served = (await fetch(`http://127.0.0.1:${entryPort}/api/health`, { signal: AbortSignal.timeout(2_000) })).ok; } catch { /* not up yet */ } if (!served) await new Promise((resolve) => setTimeout(resolve, 300)); } // The bootstrap writes the cache about 10 s after the server starts. const written = Date.now() + 30_000; while (served && round === 0 && Date.now() < written && entry.exitCode === null && cacheEntries().length === 0) { await new Promise((resolve) => setTimeout(resolve, 300)); } const cached = cacheEntries(); entry.kill("SIGTERM"); let timer; await Promise.race([exited, new Promise((resolve) => { timer = setTimeout(resolve, 20_000); })]); clearTimeout(timer); if (entry.exitCode === null) entry.kill("SIGKILL"); desktopEntryReport.starts.push({ served, cached, output: served ? undefined : entryOutput.slice(-2_000) }); } } catch (error) { desktopEntryReport.error = String(error); } finally { try { rmSync(entryHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); } catch { /* the OS will reap it */ } } } cleanup(); if (launcherReport && (launcherReport.error || !launcherReport.serverPid || launcherReport.serverPid === launcherReport.launcherPid || !launcherReport.cached || launcherReport.exit?.code !== 0 || launcherReport.stillServing)) { console.error("the packaged container launcher did not run the server as its child on its compile cache and stop it cleanly:"); console.error(JSON.stringify(launcherReport, null, 2)); process.exit(1); } if (desktopEntryReport && (desktopEntryReport.error || desktopEntryReport.starts.length !== 2 || !desktopEntryReport.starts.every((start) => start.served) || desktopEntryReport.starts[0].cached.length === 0)) { console.error("the packaged desktop entry did not start the server twice on a compile cache it wrote:"); console.error(JSON.stringify(desktopEntryReport, null, 2)); process.exit(1); } if (!listening) { console.error(`the packaged server never served /api/health on port ${port}.`); console.error(`exit code: ${child.exitCode}`); console.error(output.trim() || "(no output)"); process.exit(1); } const repoVersion = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version; if (!process.env.OMB_SMOKE_DIST && versionReport !== repoVersion) { console.error(`The packaged server reports version ${JSON.stringify(versionReport)}, not ${repoVersion}.`); process.exit(1); } if (searchReport?.status !== 200 || !Array.isArray(searchReport.body?.hits)) { console.error("The packaged read-only search worker did not respond:", searchReport); process.exit(1); } if (layerShipped && (!editionReport || editionReport.error || String(editionReport.notice ?? "").includes("no enterprise layer exists"))) { console.error("the packaged server ships an enterprise layer but did not find it:"); console.error(JSON.stringify(editionReport, null, 2)); process.exit(1); } if (!proxyReport || proxyReport.error || proxyReport.missing.length > 0) { console.error("spawned proxy paths do not resolve inside the packaged server dir:"); console.error(JSON.stringify(proxyReport, null, 2)); console.error("\nthe server would still answer /api/health — and every one of these"); console.error("features would be dead: permission prompts, computer use, dweb, peer comms."); process.exit(1); } if (browserBundle && (browserReport?.error || browserReport?.browserEngine?.kind !== "engine" || browserReport.browserEngine.version !== browserSpec.engine.version || browserReport.browserEnabled !== true)) { console.error("The fresh-home packaged server did not discover its browser bundle with browser access on by default:"); console.error(JSON.stringify(browserReport, null, 2)); process.exit(1); } if ( !mcpReport || mcpReport.error || mcpReport.exit?.timeout || mcpReport.exit?.code !== 0 || mcpReport.responses?.find((response) => response.id === 1)?.result?.serverInfo?.name !== "openmausbot-mcp" || mcpReport.responses?.find((response) => response.id === 2)?.result?.structuredContent?.status !== "connected" || JSON.stringify(mcpReport.responses?.find((response) => response.id === 3)?.result) !== "{}" ) { console.error("the packaged MCP stdio server failed its initialize-health-and-drain smoke test:"); console.error(JSON.stringify(mcpReport, null, 2)); process.exit(1); } if (!headlessReport || headlessReport.error || headlessReport.doctor.body?.server?.capabilities?.jobs !== true || headlessReport.bots.code !== 0 || !Array.isArray(headlessReport.bots.body?.bots)) { console.error("the packaged openmausbot command's headless verbs failed:"); console.error(JSON.stringify(headlessReport, null, 2)); process.exit(1); } const count = Object.keys(proxyReport.resolved).length; if (!backupReport?.ok) { console.error("the packaged backup worker failed its encrypted export smoke:", backupReport); process.exit(1); } if (!serviceTrustReport || serviceTrustReport.error || serviceTrustReport.serving !== true || serviceTrustReport.healthStatus !== 200 || serviceTrustReport.exportStatus !== 403) { console.error("the packaged server's loopback trust gate did not hold at service trust:", serviceTrustReport); process.exit(1); } console.log(`packaged server started with no node_modules in reach (port ${port}) ✓`); console.log(`all ${count} spawned proxy paths resolve inside the packaged server dir ✓`); console.log("packaged MCP stdio server reached the API and flushed its final frames ✓"); console.log("packaged openmausbot doctor and bots answered from the bundle ✓"); console.log("packaged backup worker exported an encrypted archive ✓"); console.log("packaged loopback trust gate: health serves, a public mutation is refused at service trust ✓"); if (launcherReport) console.log("packaged container launcher ran the server as its child and stopped it cleanly ✓"); if (desktopEntryReport) console.log(`packaged desktop entry started the server twice on its compile cache (${desktopEntryReport.starts[0].cached.length} entries) ✓`); if (layerShipped) console.log("packaged server found its enterprise layer inside the server dir ✓"); if (browserBundle) console.log(`packaged browser discovered without installation; access on by default ✓ ${JSON.stringify(browserReport)}`);