diff --git a/scripts/smoke-packaged-server.mjs b/scripts/smoke-packaged-server.mjs index 4acd013b0..c9a538775 100644 --- a/scripts/smoke-packaged-server.mjs +++ b/scripts/smoke-packaged-server.mjs @@ -10,11 +10,12 @@ // repo a bare import still resolves by walking up to ./node_modules and the // test passes on a build that would be dead in the field — which is precisely // how the bug escaped. The copy is the whole point; do not "simplify" it away. -import { spawn } from "node:child_process"; -import { cpSync, mkdtempSync, rmSync } from "node:fs"; +import { execFile, spawn } from "node:child_process"; +import { cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const staging = mkdtempSync(join(tmpdir(), "omb-smoke-")); @@ -71,6 +72,32 @@ while (Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, 300)); } +// Serving /api/health is necessary but nowhere near sufficient. Bundling +// relocates import.meta.url, so a module that used to sit in drivers/ resolves +// its sibling paths from the bundle's directory instead — one level too high. +// The 0.1.24 candidate booted and answered /api/health perfectly while every +// spawned proxy pointed outside Resources/server, silently killing permission +// prompts, computer use and dweb. So check the paths the server ACTUALLY +// resolved, from inside the staged copy, before calling the build good. +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"), +); + +let proxyReport = null; +try { + const { stdout } = await promisify(execFile)(process.execPath, [probe], { cwd: staging }); + proxyReport = JSON.parse(stdout); +} catch (error) { + proxyReport = { error: String((error && error.message) || error) }; +} + cleanup(); if (!listening) { @@ -80,4 +107,14 @@ if (!listening) { 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); +} + +const count = Object.keys(proxyReport.resolved).length; 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 ✓`); diff --git a/server/container-computer.ts b/server/container-computer.ts index e49f260d4..89babbb1e 100644 --- a/server/container-computer.ts +++ b/server/container-computer.ts @@ -7,15 +7,14 @@ // typing, screenshots, accessibility, or window discovery. import { execFile } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { existsSync } from "node:fs"; import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; +import { join, resolve } from "node:path"; import { promisify } from "node:util"; import { augmentedPath } from "./env-path.ts"; import { DATA_DIR } from "./config.ts"; +import { SPAWNED_PROXIES } from "./proxy-paths.ts"; const run = promisify(execFile); const SCREENSHOT_STATUS_TTL_MS = 10_000; @@ -769,10 +768,7 @@ export async function containerComputerScreenshot( let screenshotStatusCache: { status: ContainerComputerStatus; expiresAt: number } | null = null; -const containerMcpPath = (() => { - const ts = join(dirname(fileURLToPath(import.meta.url)), "container-mcp.ts"); - return existsSync(ts) ? ts : ts.replace(/\.ts$/, ".js"); -})(); +const containerMcpPath = SPAWNED_PROXIES.containerMcp; /** Spawn contract handed directly to agent runtimes. The tiny host wrapper * only preserves stdio through the container CLI; Cua Driver owns the MCP diff --git a/server/drivers/acp/core.ts b/server/drivers/acp/core.ts index 61c122473..e50cdfe7c 100644 --- a/server/drivers/acp/core.ts +++ b/server/drivers/acp/core.ts @@ -13,10 +13,7 @@ // is never a security contract). session/load REPLAYS history as ordinary // session/update notifications, so updates are double-gated: nothing emits // before the prompt is sent, and `_meta.isReplay` updates are dropped. -import { existsSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../../procs.ts"; @@ -37,13 +34,12 @@ import { newEventId, newId } from "../../contracts.ts"; import { computerProxyEnv } from "../../container-computer.ts"; import { augmentedPath } from "../../env-path.ts"; -// the computer proxy entry: .ts in dev (node type stripping), .js in the -// compiled dist-server the packaged app ships -const COMPUTER_PROXY_PATH = (() => { - const ts = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "computer-proxy.ts"); - return existsSync(ts) ? ts : ts.replace(/\.ts$/, ".js"); -})(); +// Resolved from the server root, never relative to this file: bundling inlines +// this module two directories up, so the `".."` pair here would climb past the +// packaged server dir entirely. See server/proxy-paths.ts. +const COMPUTER_PROXY_PATH = SPAWNED_PROXIES.computer; import { appendNative } from "../native.ts"; +import { SPAWNED_PROXIES } from "../../proxy-paths.ts"; export interface AcpConfig { cli: string; diff --git a/server/drivers/claude.ts b/server/drivers/claude.ts index 63e856ed2..d1236fe8c 100644 --- a/server/drivers/claude.ts +++ b/server/drivers/claude.ts @@ -8,11 +8,10 @@ // - Composio Sessions (connected apps → tools) over streamable HTTP // - the bot's cloud computer (box.ascii.dev) via server/computer-proxy.ts // — screenshot/exec/open_url, the CUA-on-the-box bridge -import { existsSync, mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { createServer as createNetServer } from "node:net"; import { homedir, tmpdir } from "node:os"; import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; import { DATA_DIR } from "../config.ts"; import { augmentedPath } from "../env-path.ts"; @@ -32,6 +31,7 @@ import { computerProxyEnv } from "../container-computer.ts"; import { newEventId, newId } from "../contracts.ts"; import { applyClaudeInject, mergeLocalInject } from "./local-inject.ts"; import { appendNative } from "./native.ts"; +import { SPAWNED_PROXIES } from "../proxy-paths.ts"; /** Whether `claude` has been signed in. * @@ -147,15 +147,12 @@ export function readClaudeModelCatalog(env: Record = return { default: STATIC_CLAUDE_MODELS.default, options }; } -// proxy entry files live next to this one as .ts in dev (node type -// stripping) and .js in the compiled dist-server the packaged app ships -const proxyPath = (basename: string) => { - const ts = join(dirname(fileURLToPath(import.meta.url)), "..", `${basename}.ts`); - return existsSync(ts) ? ts : ts.replace(/\.ts$/, ".js"); -}; -const PROXY_PATH = proxyPath("computer-proxy"); -const PERM_PROXY_PATH = proxyPath("permission-proxy"); -const DWEB_PROXY_PATH = proxyPath("drivers/dweb-proxy"); +// Resolved from the server root, never relative to this file: bundling inlines +// this module into an entry one directory up, so a `".."` here would climb too +// far. See server/proxy-paths.ts. +const PROXY_PATH = SPAWNED_PROXIES.computer; +const PERM_PROXY_PATH = SPAWNED_PROXIES.permission; +const DWEB_PROXY_PATH = SPAWNED_PROXIES.dweb; // in the packaged app process.execPath is the Electron binary — this env // makes it behave as plain node for the spawned MCP proxies (harmless in dev) const NODE_ENV_FLAG = { ELECTRON_RUN_AS_NODE: "1" }; diff --git a/server/index.ts b/server/index.ts index cd06c1488..e6ac6525a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -2,11 +2,10 @@ // (upstream rule): the React app dispatches typed commands over HTTP and // folds one SSE event stream; every provider process runs here. import { randomBytes, randomUUID, timingSafeEqual } from "node:crypto"; -import { existsSync, readFileSync, unlinkSync } from "node:fs"; +import { readFileSync, unlinkSync } from "node:fs"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { isIP } from "node:net"; -import { dirname, extname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { extname, join } from "node:path"; import { approvalKey, autoDecision } from "./auto-approve.ts"; import { validateBotCwd } from "./bot-cwd.ts"; @@ -64,6 +63,7 @@ import { createTeamManifest, parseTeamManifest } from "./team-manifest.ts"; import { listenWebhookIngress, webhookCredential, type WebhookIngress } from "./webhook-ingress.ts"; import { memberTurnSelection } from "./member-turn.ts"; import { WebhookManager } from "./webhooks.ts"; +import { SPAWNED_PROXIES } from "./proxy-paths.ts"; const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799); const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1); @@ -104,11 +104,10 @@ function authorizedComms(header: string | string[] | undefined): boolean { // a peer invoked via ask_bot runs at depth 1 and gets NO agents tool, so // A→B is allowed but B→C (and A→B→A loops) never start. const MAX_COMMS_DEPTH = 1; -// proxy entry: .ts in dev (node type-strips), .js in the packaged dist-server -const agentsProxyPath = (() => { - const ts = join(dirname(fileURLToPath(import.meta.url)), "drivers", "agents-proxy.ts"); - return existsSync(ts) ? ts : ts.replace(/\.ts$/, ".js"); -})(); +// Resolved from the server root — see server/proxy-paths.ts. This descending +// path happened to survive bundling, but it goes through the same anchor so +// there is exactly one way proxies are located. +const agentsProxyPath = SPAWNED_PROXIES.agents; // in the packaged app process.execPath is Electron — run the proxy as node const AGENTS_NODE_FLAG = { ELECTRON_RUN_AS_NODE: "1" }; diff --git a/server/proxy-paths.ts b/server/proxy-paths.ts new file mode 100644 index 000000000..4dd1c25e8 --- /dev/null +++ b/server/proxy-paths.ts @@ -0,0 +1,42 @@ +// Where the server's own files live at runtime, and the one place a spawned +// proxy's path is worked out. +// +// This must NOT be resolved relative to the module that happens to need it. +// esbuild inlines modules into an entry bundle (scripts/bundle-server.mjs), so +// a `".."` written inside drivers/claude.ts stops climbing from drivers/ and +// starts climbing from the BUNDLE's directory — one level too high, silently. +// +// That is not hypothetical: it shipped into the 0.1.24 release candidate. +// Every proxy resolved outside Resources/server, so permission prompting, +// computer use and dweb all died on the first turn that needed them, while +// /api/health stayed perfectly green and the packaging checks passed. +// +// This file sits at the server root and is only ever inlined into entries that +// also sit at the server root (index, computer-proxy, container-mcp) — the +// nested drivers/* entries import nothing local, which is what keeps the +// anchor correct in both the dev tree and the bundle. proxy-paths.test.ts pins +// that invariant. Resolve proxies through SPAWNED_PROXIES and nowhere else. +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +/** The directory holding the server's runtime files: server/ in dev, the + * extraResources copy of dist-server/ (Resources/server) in the packaged app. */ +export const SERVER_ROOT = dirname(fileURLToPath(import.meta.url)); + +/** .ts in dev, where node strips types; the compiled sibling once packaged. */ +export function resolveProxy(relative: string): string { + const source = join(SERVER_ROOT, `${relative}.ts`); + return existsSync(source) ? source : join(SERVER_ROOT, `${relative}.js`); +} + +/** Every file the server spawns as its own process. Single source of truth so + * the smoke test can assert each one actually exists in a packaged layout — + * the check that would have caught the 0.1.24 breakage. */ +export const SPAWNED_PROXIES = { + computer: resolveProxy("computer-proxy"), + permission: resolveProxy("permission-proxy"), + containerMcp: resolveProxy("container-mcp"), + agents: resolveProxy("drivers/agents-proxy"), + dweb: resolveProxy("drivers/dweb-proxy"), +} as const;