ci / test (push) Successful in 45m25s
- 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
371 lines
14 KiB
JavaScript
371 lines
14 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|