Files
codex-app-server/cloud/codex-ui-host/host/app-server-bridge.mjs
T
host 3e0ff898e0
ci / test (push) Successful in 45m25s
feat: wire native Codex UI as /console entry, keep classic console at /console-classic
- Vendor codex-ui-stack host/ as cloud/codex-ui-host/ (original/ not vendored, 650MB)
- Bridge emulates 7 methods missing in codex 0.164.0-alpha.9, exposes 29
  experimental-protocol methods (150 -> 179), Linux binary candidates,
  read-only POST /__codex_git
- GET /console 302s to the UI host (:8095) when reachable, else falls back
  to the classic React console; GET /console-classic always serves it
- Alignment tables: docs/codex-native-ui-alignment.md
- Tests: test_console 7, new test_codex_ui_host 4, handler method pin 247 -> 249
2026-11-06 19:03:35 +00:00

371 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}
}