Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions scripts/smoke-packaged-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down Expand Up @@ -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) {
Expand All @@ -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 ✓`);
10 changes: 3 additions & 7 deletions server/container-computer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
14 changes: 5 additions & 9 deletions server/drivers/acp/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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;
Expand Down
19 changes: 8 additions & 11 deletions server/drivers/claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
*
Expand Down Expand Up @@ -147,15 +147,12 @@ export function readClaudeModelCatalog(env: Record<string, string | undefined> =
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" };
Expand Down
15 changes: 7 additions & 8 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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" };

Expand Down
42 changes: 42 additions & 0 deletions server/proxy-paths.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading