/** * Bridge to the real local `codex app-server`. * * The desktop app talks to the Codex app server over stdio JSON-RPC; the main * process owns that child process. In a browser we cannot spawn anything, so * this module owns the child on the Node side and exposes a plain * request/response surface over HTTP. * * The binary ships inside the ChatGPT desktop app: * ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex * * On Linux (this repo's server target) the same binary is found via * $CODEX_BIN (same env name host.py uses), /opt/hatch-image/bin/codex, or * PATH. $CODEX_APP_SERVER_BIN keeps the highest priority. * * Nothing here reaches the network: the app server reads `~/.codex` and runs * locally. Methods that would require a remote model call are only invoked when * the UI explicitly asks for them. */ import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); // A6: Linux candidates first after the explicit override, macOS paths kept. const CANDIDATE_BINARIES = [ process.env.CODEX_APP_SERVER_BIN, process.env.CODEX_BIN, "/opt/hatch-image/bin/codex", "/Applications/ChatGPT.app/Contents/Resources/codex-cli/CodexCLI.app/Contents/MacOS/codex", "/Applications/ChatGPT.app/Contents/Resources/codex-cli/bin/codex", ].filter(Boolean); /** Search PATH for an executable `codex` (last-resort candidate, A6). */ function findCodexOnPath() { const sep = process.platform === "win32" ? ";" : ":"; for (const dir of (process.env.PATH || "").split(sep)) { if (!dir) continue; const p = path.join(dir, "codex"); try { if (fs.existsSync(p) && fs.statSync(p).isFile()) { fs.accessSync(p, fs.constants.X_OK); return p; } } catch { /* ignore */ } } return null; } export function findCodexBinary() { for (const p of CANDIDATE_BINARIES) { try { if (fs.existsSync(p) && fs.statSync(p).isFile()) return p; } catch { /* ignore */ } } return findCodexOnPath(); } /** * Relay configuration. The web build talks to an OpenAI-compatible relay * instead of a ChatGPT account, so the app server is started with per-process * `-c` overrides. Nothing is written to ~/.codex/config.toml — the desktop app * keeps its own settings and the override dies with this process. * * Shape: { providerId, providerName, baseUrl, apiKey, envKey, model, wireApi } */ export function loadRelayConfig() { const file = process.env.CODEX_WEB_RELAY_CONFIG || path.join(HERE, "relay-config.json"); if (!fs.existsSync(file)) return null; try { const cfg = JSON.parse(fs.readFileSync(file, "utf8")); return cfg && cfg.baseUrl ? cfg : null; } catch { return null; } } /** Translate relay config into `codex app-server` CLI overrides. */ export function relayArgs(cfg) { if (!cfg) return []; const id = cfg.providerId || "relay"; const args = [ "-c", `model_provider=${JSON.stringify(id)}`, "-c", `model=${JSON.stringify(cfg.model)}`, "-c", `preferred_auth_method="apikey"`, "-c", `model_providers.${id}.name=${JSON.stringify(cfg.providerName || id)}`, "-c", `model_providers.${id}.base_url=${JSON.stringify(cfg.baseUrl)}`, "-c", `model_providers.${id}.wire_api=${JSON.stringify(cfg.wireApi || "responses")}`, "-c", `model_providers.${id}.env_key=${JSON.stringify(cfg.envKey || "RELAY_API_KEY")}`, // Everything that would talk to OpenAI's backend (plugin catalog, account // endpoints, telemetry) is answered by the mock backend served by the web // host itself: well-formed local JSON, no request ever leaves the machine, // and callers see success instead of connection errors. "-c", `chatgpt_base_url=${JSON.stringify(cfg.chatgptBaseUrl || mockBackendUrl())}`, ]; // Optional safety overrides. Left out by default so the app server keeps // whatever ~/.codex/config.toml says; set them in relay-config.json to pin // the sandbox for a shared or unattended browser build. if (cfg.approvalPolicy) args.push("-c", `approval_policy=${JSON.stringify(cfg.approvalPolicy)}`); if (cfg.sandboxMode) args.push("-c", `sandbox_mode=${JSON.stringify(cfg.sandboxMode)}`); return args; } /** The web host serves the ChatGPT-backend stand-in under /mock. */ function mockBackendUrl() { const port = process.env.CODEX_WEB_PORT || "8095"; return `http://127.0.0.1:${port}/mock`; } const DEFAULT_TIMEOUT_MS = 30_000; /** * Methods the app server can leave unanswered when the surrounding desktop * infrastructure is absent. MCP discovery is the only one today: with no MCP * servers configured there is nothing to enumerate, and the request simply * never completes. Answer it locally after a short grace period. */ const METHOD_TIMEOUT_MS = { "mcpServerStatus/list": 6_000 }; const METHOD_FALLBACKS = { "mcpServerStatus/list": { data: [] } }; // ------------------------------------------------------------------ A1–A4 // Emulation for methods the vendored UI calls but codex 0.164.0-alpha.9 does // not implement (verified: the binary answers them with `unknown variant`). // Only the seven method names listed here are intercepted; every other // method passes through to the real app server untouched. // // A1 thread/stop -> turn/interrupt (the Codex App stop button is // exactly a turn/interrupt; host.py confirms this). // A2 thread/rollback -> thread/revert (exists in this binary). // A3 thread/startAeon -> thread/start. Aeon is an experimental mode with // no entry point in this binary, so this is a // downgrade to an ordinary start, not Aeon. // A4 interactive/* -> the interactive-sessions surface does not exist // in this binary. *list methods answer with empty // lists (no sessions are fabricated — the UI shows // "no live sessions"); upload / sandbox read answer // with a structured -32601 method-not-found error // instead of fake data. (Per docs/codex-native-ui- // alignment.md A4: list 类回空列表.) const EMULATED_REDIRECTS = { "thread/stop": "turn/interrupt", "thread/rollback": "thread/revert", "thread/startAeon": "thread/start", }; const INTERACTIVE_EMPTY_LIST_METHODS = new Set([ "interactive/liveSessions/list", "interactive/sessionSandbox/list", ]); const INTERACTIVE_UNAVAILABLE_METHODS = new Set([ "interactive/session/upload", "interactive/sessionSandbox/read", ]); export class AppServerBridge { constructor({ log = console, onNotification = null } = {}) { this.log = log; this.onNotification = onNotification; this.child = null; this.seq = 0; this.pending = new Map(); this.buffer = ""; this.ready = null; this.notifications = []; this.waiter = null; this.lastError = null; } get binary() { return findCodexBinary(); } /** Spawn the app server (once) and complete the JSON-RPC handshake. */ async ensure() { if (this.ready) return this.ready; this.ready = (async () => { const bin = this.binary; if (!bin) throw new Error("codex binary not found"); const relay = loadRelayConfig(); const args = ["app-server", ...relayArgs(relay)]; const env = { ...process.env, RUST_LOG: process.env.RUST_LOG ?? "error" }; if (relay?.apiKey) env[relay.envKey || "RELAY_API_KEY"] = relay.apiKey; const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"], env }); this.child = child; if (relay) { this.log.log?.(`[app-server] relay ${relay.baseUrl} model=${relay.model}`); } child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk) => this.#onStdout(chunk)); child.stderr.setEncoding("utf8"); // Full, untruncated stderr for diagnosis; ANSI colour codes included. this.stderrFile = fs.createWriteStream("/tmp/codex-appserver-stderr.log", { flags: "a" }); child.stderr.on("data", (chunk) => { const text = String(chunk); this.stderrFile?.write(text); const clean = text.trim(); if (clean && !/unrecognized configuration settings|is ignored/.test(clean)) { this.lastError = clean.slice(0, 400); // Backend-shape mismatches surface here (the mock backend answers // every path; a parse complaint means a shape to improve). this.log.warn?.(`[app-server] ${clean.slice(0, 200)}`); } }); child.on("exit", (code, signal) => { this.log.warn?.(`[app-server] exited code=${code} signal=${signal}`); this.child = null; this.ready = null; for (const { reject } of this.pending.values()) { reject(new Error("app-server exited")); } this.pending.clear(); }); await this.request("initialize", { clientInfo: { name: "codex-web", title: "Codex Web", version: "1.0.0" }, // The renderer calls `collaborationMode/list` and friends, which the // server rejects with -32600 unless the client opts into the // experimental surface during the handshake. capabilities: { experimentalApi: true }, }); this.log.log?.("[app-server] ready"); return true; })(); this.ready.catch((err) => { this.log.error?.(`[app-server] failed: ${err.message}`); this.ready = null; }); return this.ready; } #onStdout(chunk) { this.buffer += chunk; let nl; while ((nl = this.buffer.indexOf("\n")) !== -1) { const line = this.buffer.slice(0, nl).trim(); this.buffer = this.buffer.slice(nl + 1); if (!line) continue; let msg; try { msg = JSON.parse(line); } catch { continue; } if (msg.id != null && this.pending.has(msg.id) && !msg.method) { const { resolve, reject } = this.pending.get(msg.id); this.pending.delete(msg.id); msg.error ? reject(Object.assign(new Error(msg.error.message ?? "rpc error"), { rpc: msg.error })) : resolve(msg.result); continue; } if (msg.method) { // Server-initiated request (has id) or notification (no id). Both go on // the same stream; the renderer distinguishes them by `id`. this.notifications.push(msg); if (this.notifications.length > 2000) this.notifications.shift(); if (this.onNotification) { try { this.onNotification(msg); } catch (err) { this.log.warn?.(`[app-server] onNotification handler failed: ${err?.message}`); } } this.#wake(); } } } /** Resolve the pending long-poll, if any. */ #wake() { const waiter = this.waiter; if (!waiter) return; this.waiter = null; clearTimeout(waiter.timer); waiter.resolve(); } /** * Wait until at least one notification is queued (or the timeout fires), then * hand back everything that accumulated. Long-polling keeps the renderer in * step with the app server without a websocket. */ waitForNotifications(timeoutMs = 15_000) { if (this.notifications.length) return Promise.resolve(this.drainNotifications()); return new Promise((resolve) => { const timer = setTimeout(() => { this.waiter = null; resolve(this.drainNotifications()); }, timeoutMs); this.waiter = { resolve: () => resolve(this.drainNotifications()), timer }; }); } request(method, params) { // A1–A3: redirect to the real method that implements the same action. // Only these three names are redirected; anything else falls through. if (method in EMULATED_REDIRECTS) { const target = EMULATED_REDIRECTS[method]; this.log.log?.(`[app-server] emulated ${method} -> ${target}`); return this.ensure().then(() => this.request(target, params)); } // A4: no live sessions exist in this binary — answer empty, fabricate none. // The three list-shaped fields cover the shapes the renderer dereferences; // all three are empty. if (INTERACTIVE_EMPTY_LIST_METHODS.has(method)) { return Promise.resolve({ sessions: [], data: [], items: [] }); } // A4: interactive session upload / sandbox access is unavailable in // codex 0.164.0-alpha.9. Structured -32601, exactly as the app server // reports an unknown method — the renderer surfaces it, nothing is faked. if (INTERACTIVE_UNAVAILABLE_METHODS.has(method)) { const error = new Error( `method not found: ${method} (interactive sessions not available in codex 0.164.0-alpha.9)`, ); error.code = -32601; error.rpc = { code: -32601, message: error.message }; return Promise.reject(error); } if (!this.child) return Promise.reject(new Error("app-server not running")); const id = ++this.seq; const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} }); const timeoutMs = METHOD_TIMEOUT_MS[method] ?? DEFAULT_TIMEOUT_MS; return new Promise((resolve, reject) => { const timer = setTimeout(() => { this.pending.delete(id); // A few methods never answer in a headless build (MCP discovery has no // servers to enumerate). Resolve with the truthful empty result instead // of leaving the renderer's query spinning for the full timeout. if (method in METHOD_FALLBACKS) { resolve(METHOD_FALLBACKS[method]); return; } reject(new Error(`app-server timeout: ${method}`)); }, timeoutMs); this.pending.set(id, { resolve: (v) => { clearTimeout(timer); resolve(v); }, reject: (e) => { clearTimeout(timer); reject(e); }, }); this.child.stdin.write(payload + "\n"); }); } /** Drain queued server notifications (used by the polling endpoint). */ drainNotifications() { const out = this.notifications; this.notifications = []; return out; } dispose() { this.child?.kill(); this.child = null; this.ready = null; } }