Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e0ff898e0 |
No files matched your search
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
[
|
||||
"account/bedrock/checkGovCloudRequirements",
|
||||
"account/bedrock/discover",
|
||||
"account/bedrock/setup",
|
||||
"account/gatewayOAuth/cancel",
|
||||
"account/gatewayOAuth/login",
|
||||
"account/gatewayOAuth/read",
|
||||
"account/login/cancel",
|
||||
"account/login/start",
|
||||
"account/logout",
|
||||
"account/rateLimitResetCredit/consume",
|
||||
"account/rateLimits/read",
|
||||
"account/read",
|
||||
"account/sendAddCreditsNudgeEmail",
|
||||
"account/usage/read",
|
||||
"account/workspaceMessages/read",
|
||||
"app/installed",
|
||||
"app/list",
|
||||
"app/read",
|
||||
"collaborationMode/list",
|
||||
"command/exec",
|
||||
"command/exec/resize",
|
||||
"command/exec/terminate",
|
||||
"command/exec/write",
|
||||
"config/batchWrite",
|
||||
"config/mcpServer/reload",
|
||||
"config/read",
|
||||
"config/value/write",
|
||||
"configRequirements/read",
|
||||
"environment/add",
|
||||
"environment/info",
|
||||
"environment/status",
|
||||
"experimentalFeature/enablement/set",
|
||||
"experimentalFeature/list",
|
||||
"externalAgentConfig/detect",
|
||||
"externalAgentConfig/import",
|
||||
"externalAgentConfig/import/readHistories",
|
||||
"externalAgentConfig/import/recordHistory",
|
||||
"feedback/upload",
|
||||
"fs/copy",
|
||||
"fs/createDirectory",
|
||||
"fs/getMetadata",
|
||||
"fs/readDirectory",
|
||||
"fs/readFile",
|
||||
"fs/remove",
|
||||
"fs/unwatch",
|
||||
"fs/watch",
|
||||
"fs/writeFile",
|
||||
"fuzzyFileSearch/sessionStart",
|
||||
"fuzzyFileSearch/sessionStop",
|
||||
"fuzzyFileSearch/sessionUpdate",
|
||||
"hooks/list",
|
||||
"interactive/liveSessions/list",
|
||||
"interactive/session/upload",
|
||||
"interactive/sessionSandbox/list",
|
||||
"interactive/sessionSandbox/read",
|
||||
"marketplace/add",
|
||||
"marketplace/remove",
|
||||
"marketplace/upgrade",
|
||||
"mcpServer/event/stream/start",
|
||||
"mcpServer/event/stream/stop",
|
||||
"mcpServer/oauth/login",
|
||||
"mcpServer/resource/read",
|
||||
"mcpServer/tool/call",
|
||||
"mcpServerStatus/list",
|
||||
"memory/reset",
|
||||
"memory/status",
|
||||
"mock/experimentalMethod",
|
||||
"model/list",
|
||||
"modelProvider/capabilities/read",
|
||||
"permissionProfile/list",
|
||||
"plugin/install",
|
||||
"plugin/installed",
|
||||
"plugin/list",
|
||||
"plugin/read",
|
||||
"plugin/reconcile",
|
||||
"plugin/search",
|
||||
"plugin/share/checkout",
|
||||
"plugin/share/delete",
|
||||
"plugin/share/list",
|
||||
"plugin/share/save",
|
||||
"plugin/share/updateTargets",
|
||||
"plugin/skill/read",
|
||||
"plugin/uninstall",
|
||||
"process/kill",
|
||||
"process/resizePty",
|
||||
"process/spawn",
|
||||
"process/writeStdin",
|
||||
"project/create",
|
||||
"project/delete",
|
||||
"project/import",
|
||||
"project/list",
|
||||
"project/move",
|
||||
"project/read",
|
||||
"project/update",
|
||||
"remoteControl/client/list",
|
||||
"remoteControl/client/revoke",
|
||||
"remoteControl/disable",
|
||||
"remoteControl/enable",
|
||||
"remoteControl/pairing/start",
|
||||
"remoteControl/pairing/status",
|
||||
"remoteControl/status/read",
|
||||
"review/start",
|
||||
"rollout/compress",
|
||||
"server/diagnostics",
|
||||
"skills/config/write",
|
||||
"skills/extraRoots/set",
|
||||
"skills/list",
|
||||
"thread/approveGuardianDeniedAction",
|
||||
"thread/archive",
|
||||
"thread/attachment/add",
|
||||
"thread/attachment/list",
|
||||
"thread/attachment/remove",
|
||||
"thread/attachmentOwner/list",
|
||||
"thread/backgroundTerminals/clean",
|
||||
"thread/backgroundTerminals/list",
|
||||
"thread/backgroundTerminals/terminate",
|
||||
"thread/compact/start",
|
||||
"thread/decrement_elicitation",
|
||||
"thread/delete",
|
||||
"thread/fork",
|
||||
"thread/goal/clear",
|
||||
"thread/goal/get",
|
||||
"thread/goal/set",
|
||||
"thread/increment_elicitation",
|
||||
"thread/inject_items",
|
||||
"thread/items/list",
|
||||
"thread/items/read",
|
||||
"thread/list",
|
||||
"thread/loaded/list",
|
||||
"thread/memoryMode/set",
|
||||
"thread/metadata/update",
|
||||
"thread/name/set",
|
||||
"thread/prediction/request",
|
||||
"thread/queue/add",
|
||||
"thread/queue/delete",
|
||||
"thread/queue/list",
|
||||
"thread/queue/reorder",
|
||||
"thread/queue/start",
|
||||
"thread/queue/update",
|
||||
"thread/read",
|
||||
"thread/readState/update",
|
||||
"thread/realtime/appendAudio",
|
||||
"thread/realtime/appendSpeech",
|
||||
"thread/realtime/appendText",
|
||||
"thread/realtime/listVoices",
|
||||
"thread/realtime/start",
|
||||
"thread/realtime/stop",
|
||||
"thread/resume",
|
||||
"thread/revert",
|
||||
"thread/rollback",
|
||||
"thread/sample",
|
||||
"thread/sample/cancel",
|
||||
"thread/search",
|
||||
"thread/searchOccurrences",
|
||||
"thread/section/move",
|
||||
"thread/settings/update",
|
||||
"thread/shellCommand",
|
||||
"thread/start",
|
||||
"thread/startAeon",
|
||||
"thread/stop",
|
||||
"thread/timeline/list",
|
||||
"thread/turns/list",
|
||||
"thread/unarchive",
|
||||
"thread/unsubscribe",
|
||||
"threadSection/create",
|
||||
"threadSection/delete",
|
||||
"threadSection/list",
|
||||
"threadSection/update",
|
||||
"turn/interrupt",
|
||||
"turn/settings/update",
|
||||
"turn/start",
|
||||
"turn/steer",
|
||||
"userVerification/cancel",
|
||||
"userVerification/delete",
|
||||
"userVerification/enroll",
|
||||
"userVerification/status",
|
||||
"userVerification/verify",
|
||||
"windowsSandbox/readiness",
|
||||
"windowsSandbox/setupStart"
|
||||
]
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Load the local web host in the headless browser, capture console + exceptions,
|
||||
* then dump the resulting page state and a screenshot.
|
||||
*
|
||||
* node host/cdp-load.mjs --port 9333 --url http://127.0.0.1:8095/ --out /tmp/shot.png
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const argOf = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback;
|
||||
};
|
||||
const PORT = argOf("--port", "9333");
|
||||
const URL_ = argOf("--url", "http://127.0.0.1:8095/");
|
||||
const OUT = argOf("--out", "/tmp/codexweb.png");
|
||||
const SETTLE = Number(argOf("--settle", "6000"));
|
||||
|
||||
const version = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json();
|
||||
const ws = new WebSocket(version.webSocketDebuggerUrl);
|
||||
let seq = 0;
|
||||
const pending = new Map();
|
||||
const events = [];
|
||||
|
||||
const send = (method, params = {}, sessionId) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const id = ++seq;
|
||||
pending.set(id, { resolve, reject });
|
||||
const msg = { id, method, params };
|
||||
if (sessionId) msg.sessionId = sessionId;
|
||||
ws.send(JSON.stringify(msg));
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (ev) => {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.method) {
|
||||
if (msg.method === "Runtime.consoleAPICalled") {
|
||||
const text = (msg.params.args || [])
|
||||
.map((a) => a.value ?? a.description ?? a.unserializableValue ?? "")
|
||||
.join(" ")
|
||||
.slice(0, 600);
|
||||
events.push(`[${msg.params.type}] ${text}`);
|
||||
} else if (msg.method === "Runtime.exceptionThrown") {
|
||||
const d = msg.params.exceptionDetails;
|
||||
events.push(`[EXCEPTION] ${d.exception?.description ?? d.text}`.slice(0, 2200));
|
||||
} else if (msg.method === "Log.entryAdded") {
|
||||
const e = msg.params.entry;
|
||||
events.push(`[log:${e.level}] ${e.text}`.slice(0, 500));
|
||||
}
|
||||
}
|
||||
if (!msg.id || !pending.has(msg.id)) return;
|
||||
const { resolve, reject } = pending.get(msg.id);
|
||||
pending.delete(msg.id);
|
||||
msg.error ? reject(new Error(JSON.stringify(msg.error).slice(0, 400))) : resolve(msg.result);
|
||||
});
|
||||
|
||||
await new Promise((r) => ws.addEventListener("open", r, { once: true }));
|
||||
|
||||
const { targetInfos } = await send("Target.getTargets");
|
||||
const pages = targetInfos.filter((t) => t.type === "page");
|
||||
const main = pages.find((t) => t.url.includes("127.0.0.1")) ?? pages[0];
|
||||
if (!main) {
|
||||
console.error("no page target");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { sessionId } = await send("Target.attachToTarget", { targetId: main.targetId, flatten: true });
|
||||
await send("Page.enable", {}, sessionId);
|
||||
await send("Runtime.enable", {}, sessionId);
|
||||
await send("Log.enable", {}, sessionId);
|
||||
|
||||
await send("Page.navigate", { url: URL_ }, sessionId);
|
||||
// Runtime.enable replays the previous page's console buffer; drop it so the
|
||||
// report only covers this load.
|
||||
events.length = 0;
|
||||
await new Promise((r) => setTimeout(r, SETTLE));
|
||||
|
||||
const state = await send(
|
||||
"Runtime.evaluate",
|
||||
{
|
||||
expression: `(() => {
|
||||
const root = document.querySelector('#root');
|
||||
const text = (root?.innerText || '').replace(/\\s+/g, ' ').trim();
|
||||
return JSON.stringify({
|
||||
url: location.href,
|
||||
title: document.title,
|
||||
rootChildren: root ? root.children.length : -1,
|
||||
rootBytes: root ? root.innerHTML.length : 0,
|
||||
textLength: text.length,
|
||||
textSample: text.slice(0, 500),
|
||||
bootstrapped: !!globalThis.__codexBootstrapped,
|
||||
hasShim: typeof globalThis.__codexHost,
|
||||
}, null, 1);
|
||||
})()`,
|
||||
returnByValue: true,
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
|
||||
console.log("STATE:", state.result.value);
|
||||
console.log(`\nEVENTS (${events.length}):`);
|
||||
for (const e of events.slice(0, 60)) console.log(" " + e);
|
||||
|
||||
const shot = await send("Page.captureScreenshot", { format: "png" }, sessionId);
|
||||
fs.writeFileSync(OUT, Buffer.from(shot.data, "base64"));
|
||||
console.log(`\nscreenshot: ${OUT} (${fs.statSync(OUT).size} bytes)`);
|
||||
ws.close();
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Local app host for the browser build.
|
||||
*
|
||||
* The desktop renderer obtains its host through `connect-app-host`, which in
|
||||
* Electron hands a MessagePort to the main process; the main process answers
|
||||
* with a capability-RPC object exposing `services` plus a handful of host
|
||||
* lifecycle hooks. In a browser there is no main process, so this file supplies
|
||||
* the same object locally:
|
||||
*
|
||||
* - data services are generated from the app-server method table and proxied
|
||||
* to the real local `codex app-server` (see host/app-server-bridge.mjs);
|
||||
* - lifecycle members that only exist inside Electron are stubbed.
|
||||
*
|
||||
* The generated shape matters: `services.thread.list` must resolve to a call
|
||||
* that issues `thread/list`, `services.config.read` to `config/read`, and so
|
||||
* on. Anything the UI probes but we do not provide is reported once on the
|
||||
* console so gaps are visible instead of silent.
|
||||
*
|
||||
* Served as a classic script. The marker below is replaced with the app-server
|
||||
* method list at response time.
|
||||
*/
|
||||
(() => {
|
||||
const METHODS = /*@METHODS@*/ [];
|
||||
|
||||
// Every path prefix of every method becomes a namespace object, so feature
|
||||
// detection (`if (services.terminal) ...`) sees `undefined` for members that
|
||||
// genuinely do not exist.
|
||||
const namespaces = new Set();
|
||||
for (const method of METHODS) {
|
||||
const parts = method.split("/");
|
||||
for (let i = 1; i < parts.length; i += 1) namespaces.add(parts.slice(0, i).join("/"));
|
||||
}
|
||||
|
||||
const reported = new Set();
|
||||
const reportMissing = (path) => {
|
||||
if (reported.has(path)) return;
|
||||
reported.add(path);
|
||||
const stack = (new Error().stack || "").split("\n").slice(2, 4).join(" | ");
|
||||
console.info("[codex-local-host] unimplemented service member:", path, "@", stack);
|
||||
};
|
||||
|
||||
// Permissive stub for Electron-main-only service namespaces: every unknown
|
||||
// member is an inert async function, so unguarded calls do not throw. Only
|
||||
// use this where the renderer never feature-detects the member itself.
|
||||
const permissive = (label, members = {}) =>
|
||||
new Proxy(members, {
|
||||
get(target, prop) {
|
||||
if (prop in target) return target[prop];
|
||||
if (typeof prop === "symbol" || prop === "then" || prop === "toJSON") return undefined;
|
||||
reportMissing(`${label}.${String(prop)}`);
|
||||
return () => Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
async function rpc(method, params) {
|
||||
const res = await fetch("/__codex_rpc", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ method, params: params ?? {} }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`app-server HTTP ${res.status}`);
|
||||
const body = await res.json();
|
||||
if (body && body.error) {
|
||||
const err = new Error(body.error.message || `app-server error: ${method}`);
|
||||
err.code = body.error.code;
|
||||
throw err;
|
||||
}
|
||||
return body ? body.result : undefined;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- persisted state
|
||||
// `r$t()` in the renderer resolves `mi.applicationState` and then calls
|
||||
// `.read(key)` -> `{ value }` and `.write(key, value)` -> `{ success }`.
|
||||
// The desktop build backs this with an Electron store on disk; localStorage
|
||||
// gives the same semantics here, so onboarding flags, the selected project
|
||||
// and the pinned-thread list survive a reload instead of erroring out.
|
||||
const STATE_PREFIX = "codex.appstate.";
|
||||
const stateKey = (key) => STATE_PREFIX + String(key);
|
||||
|
||||
const applicationState = {
|
||||
read: async (key) => {
|
||||
console.info("[codex-local-host] applicationState.read:", String(key).slice(0, 80));
|
||||
try {
|
||||
const raw = localStorage.getItem(stateKey(key));
|
||||
const out = { value: raw == null ? null : JSON.parse(raw) };
|
||||
console.info("[codex-local-host] applicationState.read ->", String(key).slice(0, 60), JSON.stringify(out).slice(0, 120));
|
||||
return out;
|
||||
} catch (e) {
|
||||
console.info("[codex-local-host] applicationState.read ERROR:", String(e));
|
||||
return { value: null };
|
||||
}
|
||||
},
|
||||
write: async (key, value) => {
|
||||
try {
|
||||
localStorage.setItem(stateKey(key), JSON.stringify(value));
|
||||
return { success: true };
|
||||
} catch {
|
||||
return { success: false };
|
||||
}
|
||||
},
|
||||
remove: async (key) => {
|
||||
try {
|
||||
localStorage.removeItem(stateKey(key));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
keys: async () => {
|
||||
const out = [];
|
||||
for (let i = 0; i < localStorage.length; i += 1) {
|
||||
const k = localStorage.key(i);
|
||||
if (k && k.startsWith(STATE_PREFIX)) out.push(k.slice(STATE_PREFIX.length));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------- http fetch
|
||||
// `$_t` calls `httpFetch.fetch(id, request, onUploadProgress)` where
|
||||
// `request` is `{ url, method, body, headers, keepalive, cache, ... }` and
|
||||
// awaits `{ response }` or `{ error, status, ... }` — the handle (both the
|
||||
// promise and the settled value) carries `Symbol.dispose`.
|
||||
//
|
||||
// Backend API paths (`/wham/...`, `/backend-api/...`, …) are answered by the
|
||||
// local mock backend served by the web host — the same policy the app
|
||||
// server's chatgpt_base_url follows. Nothing ever leaves the page.
|
||||
const API_PATH_RE = /^\/(wham|backend-api|ces|v1|api|public)\//;
|
||||
const httpFetch = {
|
||||
fetch: (id, request, onUploadProgress) => {
|
||||
const controller = new AbortController();
|
||||
const dispose = () => controller.abort();
|
||||
const opts = typeof request === "string" ? { url: request } : request || {};
|
||||
let url = String(opts.url ?? "");
|
||||
const method = String(opts.method || "GET").toUpperCase();
|
||||
if (API_PATH_RE.test(url)) url = "/mock" + url;
|
||||
const init = {
|
||||
method,
|
||||
headers: opts.headers,
|
||||
signal: controller.signal,
|
||||
cache: opts.cache,
|
||||
keepalive: opts.keepalive,
|
||||
};
|
||||
if (opts.body != null && method !== "GET" && method !== "HEAD") {
|
||||
init.body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body);
|
||||
}
|
||||
const pending = Promise.resolve()
|
||||
.then(() => fetch(url, init))
|
||||
.then(
|
||||
(response) => ({ response, [Symbol.dispose]: dispose }),
|
||||
(error) => ({
|
||||
error: String((error && error.message) || error),
|
||||
status: 0,
|
||||
errorCode: null,
|
||||
responseStatus: 0,
|
||||
errorKind: "network",
|
||||
[Symbol.dispose]: dispose,
|
||||
}),
|
||||
);
|
||||
// `$_t` disposes the handle *and* the settled value, so both carry one.
|
||||
pending[Symbol.dispose] = dispose;
|
||||
return pending;
|
||||
},
|
||||
cancel: async () => {},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------- projectless threads
|
||||
// A conversation that is not attached to a project still needs a working
|
||||
// directory. The desktop main process allocates one; here the Node host does
|
||||
// it (host/projectless.mjs) and this is the renderer-side handle.
|
||||
//
|
||||
// `createPaths` returns a cancellable resource rather than a promise: the
|
||||
// caller awaits `.wait()` and disposes the handle when it aborts.
|
||||
const hostCall = async (path, body) => {
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(`host call ${path} -> HTTP ${res.status}`);
|
||||
const payload = await res.json();
|
||||
if (payload && payload.error) throw new Error(payload.error.message || "host call failed");
|
||||
return payload ? payload.result : undefined;
|
||||
};
|
||||
|
||||
const projectlessThreads = {
|
||||
createPaths: (options) => {
|
||||
const controller = new AbortController();
|
||||
const settled = hostCall("/__codex_projectless", { op: "createPaths", args: options ?? {} });
|
||||
return {
|
||||
wait: () => settled,
|
||||
[Symbol.dispose]: () => controller.abort(),
|
||||
};
|
||||
},
|
||||
readWorkspaceRoot: () => hostCall("/__codex_projectless", { op: "readWorkspaceRoot" }),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- lifecycle
|
||||
// Members that live in the Electron main process rather than the app server.
|
||||
// Shapes follow the call sites in the renderer's bootstrap.
|
||||
const noopDisposable = { [Symbol.dispose]() {} };
|
||||
|
||||
const explicit = {
|
||||
"startup.whenReady": () => Promise.resolve(),
|
||||
// The renderer reports paint milestones through this and chains `.catch`.
|
||||
"startup.reach": async () => {},
|
||||
|
||||
applicationState,
|
||||
httpFetch,
|
||||
projectlessThreads,
|
||||
// `Q$` falls back to `{ values: {} }` when this is absent, which is the
|
||||
// right answer for a build with no desktop settings store behind it.
|
||||
settings: null,
|
||||
|
||||
// `remoteConnections` is consumed as `e.catalog.subscribe(fn)` and
|
||||
// `e.setRemoteControlEnabled(...)`; both must exist and return promises.
|
||||
remoteConnections: permissive("remoteConnections", {
|
||||
catalog: { subscribe: () => noopDisposable },
|
||||
setRemoteControlEnabled: async () => {},
|
||||
}),
|
||||
|
||||
"executionHosts.getDefaultHost": async () => ({ hostId: "local", kind: "local" }),
|
||||
"executionHosts.getExecutionHostKeys": () => ["local"],
|
||||
|
||||
"threadReadState.initialize": async () => {},
|
||||
"threadReadState.dispose": () => {},
|
||||
|
||||
"tracing.exportTraceBatch": async () => {},
|
||||
"tracing.setSampleRate": async () => ({}),
|
||||
|
||||
// Local-only build: no remote telemetry pipeline to feed.
|
||||
performanceTelemetry: { report: () => {}, flush: async () => {} },
|
||||
|
||||
// ---- host-owned app metadata -----------------------------------------
|
||||
// Normally answered by the Electron main process from the bundle plist.
|
||||
// Values are derived from the browser environment so the shell can print a
|
||||
// real platform string instead of "unknown".
|
||||
"appInfo.get": async () => ({
|
||||
version: "26.1021.31345",
|
||||
buildFlavor: "codex",
|
||||
buildNumber: "26.1021.31345",
|
||||
systemVersion: navigator.userAgent,
|
||||
preloadAttribution: null,
|
||||
chatGptApiBaseUrl: null,
|
||||
}),
|
||||
"appInfo.getSystemInfo": async () => ({
|
||||
platform: "darwin",
|
||||
osVersion: navigator.userAgent,
|
||||
arch: "arm64",
|
||||
release: navigator.userAgent,
|
||||
}),
|
||||
|
||||
// ---- thread configuration ---------------------------------------------
|
||||
// The desktop main process reads two things off disk before a thread can
|
||||
// start: developer instructions and the MCP block of the Codex config.
|
||||
// Neither exists for a browser build. The renderer dereferences
|
||||
// `.instructions` directly, so the shape matters even when it is empty.
|
||||
threadConfiguration: {
|
||||
readDeveloperInstructions: async () => ({ instructions: null }),
|
||||
readMcpConfig: async () => ({ config: null }),
|
||||
},
|
||||
|
||||
// The desktop shell auto-resolves pending user-input prompts after a
|
||||
// timeout, and tracks whether a conversation is on screen so it only fires
|
||||
// for visible chats. A local build never auto-resolves anything, but the
|
||||
// renderer calls these unconditionally (only the method itself is
|
||||
// optional-chained), so the namespace must exist.
|
||||
requestUserInputAutoResolution: {
|
||||
setConversationPresented: async () => {},
|
||||
recordConversationActivity: async () => {},
|
||||
snooze: async () => {},
|
||||
},
|
||||
|
||||
// ---- git integration ----------------------------------------------------
|
||||
// The desktop main process owns git queries (`RA('git')`). The UI gates the
|
||||
// composer on the `['git','availability']` query, which is only ever filled
|
||||
// as a side effect of a `stable-metadata` answer — so the service must
|
||||
// exist and answer non-null, or Send stays disabled with "Loading…".
|
||||
//
|
||||
// A7: instead of the old stub (always `{available:false, …}`), forward to
|
||||
// the host's POST /__codex_git, which runs read-only git commands inside
|
||||
// the cwd the renderer passes in params. The renderer calls this as
|
||||
// request({method, params:{cwd, …}}) (two-arg request(method, params)
|
||||
// is accepted too). Without a cwd there is nothing to inspect, so fall
|
||||
// back to the old stub shape for stable-metadata and null otherwise.
|
||||
// stable-metadata keeps the original six-field shape, now populated.
|
||||
git: permissive("git", {
|
||||
request: async (arg1, arg2) => {
|
||||
let method;
|
||||
let params = {};
|
||||
if (typeof arg1 === "string") {
|
||||
method = arg1;
|
||||
params = arg2 ?? {};
|
||||
} else {
|
||||
({ method, params = {} } = arg1 ?? {});
|
||||
}
|
||||
const cwd = typeof params?.cwd === "string" ? params.cwd : null;
|
||||
if (method === "stable-metadata" && !cwd) {
|
||||
return { available: false, branch: null, upstream: null, repository: null, state: null, worktree: null };
|
||||
}
|
||||
if (!cwd) return null;
|
||||
try {
|
||||
const res = await fetch("/__codex_git", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ op: method, cwd }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const body = await res.json();
|
||||
if (body && body.error) {
|
||||
// Not a repo / bad cwd: report unavailable instead of throwing,
|
||||
// so the composer degrades the way the old stub degraded it.
|
||||
if (method === "stable-metadata") {
|
||||
return { available: false, branch: null, upstream: null, repository: null, state: null, worktree: null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return body ? body.result ?? null : null;
|
||||
} catch {
|
||||
if (method === "stable-metadata") {
|
||||
return { available: false, branch: null, upstream: null, repository: null, state: null, worktree: null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
// ---- statsig evaluations ----------------------------------------------
|
||||
// In the desktop app the main process evaluates Statsig and pushes results
|
||||
// over this service; the renderer never talks to the network for it. The
|
||||
// local build pushes one truthful "no evaluations" payload: `payload: null`
|
||||
// maps to `hasEvaluations: false`, feature defaults apply, and nothing
|
||||
// ever polls a remote endpoint.
|
||||
statsigEvaluations: {
|
||||
subscribe: async (onValue) => {
|
||||
queueMicrotask(() =>
|
||||
onValue({
|
||||
userId: null,
|
||||
accountId: null,
|
||||
authMethod: null,
|
||||
stableId: "codex-web-local",
|
||||
payload: null,
|
||||
}),
|
||||
);
|
||||
return { [Symbol.dispose]() {} };
|
||||
},
|
||||
// The renderer publishes execution evaluations back to the host (which in
|
||||
// Electron forwards them to the main process). There is nothing to feed
|
||||
// locally; accept and drop.
|
||||
publish: async () => {},
|
||||
},
|
||||
|
||||
// Account state comes from the local app server, which reads ~/.codex.
|
||||
"accountInfo.readAccountInfo": () => rpc("account/read", {}),
|
||||
};
|
||||
|
||||
const explicitNulls = new Set(["histogramCollector", "statsig", "clientCoordination", "terminal", "devboxService"]);
|
||||
|
||||
function makeNamespace(path) {
|
||||
const node = {};
|
||||
const children = new Set();
|
||||
for (const candidate of namespaces) {
|
||||
if (!candidate.startsWith(path ? path + "/" : "")) continue;
|
||||
const rest = path ? candidate.slice(path.length + 1) : candidate;
|
||||
if (rest.includes("/")) continue;
|
||||
children.add(rest);
|
||||
}
|
||||
for (const method of METHODS) {
|
||||
if (!method.startsWith(path ? path + "/" : "")) continue;
|
||||
const rest = path ? method.slice(path.length + 1) : method;
|
||||
if (rest.includes("/")) continue;
|
||||
node[rest] = (params) => rpc(method, params);
|
||||
}
|
||||
for (const child of children) {
|
||||
node[child] = makeNamespace(path ? `${path}/${child}` : child);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function buildServices() {
|
||||
const services = makeNamespace("");
|
||||
|
||||
// Overlay the host-only members, then strip the null ones.
|
||||
for (const key of Object.keys(explicit)) {
|
||||
const parts = key.split(".");
|
||||
let cursor = services;
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
cursor[parts[i]] = cursor[parts[i]] ?? {};
|
||||
cursor = cursor[parts[i]];
|
||||
}
|
||||
cursor[parts[parts.length - 1]] = explicit[key];
|
||||
}
|
||||
for (const key of explicitNulls) services[key] = null;
|
||||
|
||||
// Report accesses to members we do not model.
|
||||
return new Proxy(services, {
|
||||
get(target, prop, receiver) {
|
||||
if (typeof prop === "symbol") return Reflect.get(target, prop, receiver);
|
||||
if (prop === "then" || prop === "toJSON") return undefined;
|
||||
if (!(prop in target)) {
|
||||
reportMissing(String(prop));
|
||||
return undefined;
|
||||
}
|
||||
return Reflect.get(target, prop, receiver);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const servicesPromise = Promise.resolve().then(buildServices);
|
||||
|
||||
globalThis.__codexLocalHost = () => ({
|
||||
services: servicesPromise,
|
||||
onRpcBroken: () => () => {},
|
||||
});
|
||||
|
||||
console.info("[codex-local-host] app host installed", METHODS.length, "methods");
|
||||
})();
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Local stand-in for the model wire.
|
||||
*
|
||||
* The app server speaks the OpenAI Responses API (`wire_api = "responses"`):
|
||||
* one POST to `{base_url}/responses` answered with a `text/event-stream` of
|
||||
* typed events. Producing that stream here keeps every byte of the
|
||||
* conversation on this machine — no model provider is ever contacted.
|
||||
*
|
||||
* Extracted from `web-server.mjs` so the local model lives in one place: the
|
||||
* host serves it, and any other local caller gets the identical wire shape.
|
||||
*/
|
||||
|
||||
/** Read a JSON request body with a hard size cap. */
|
||||
export const readJsonBody = (req, limit = 4_000_000) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let raw = "";
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (c) => {
|
||||
raw += c;
|
||||
if (raw.length > limit) reject(new Error("body too large"));
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(raw ? JSON.parse(raw) : {});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
|
||||
/** Collect every text fragment the app server put in the request. */
|
||||
const collectText = (payload) => {
|
||||
const texts = [];
|
||||
const collect = (item) => {
|
||||
if (!item || typeof item !== "object") return;
|
||||
if (typeof item.text === "string") texts.push(item.text);
|
||||
else if (Array.isArray(item.content)) for (const c of item.content) collect(c);
|
||||
else if (typeof item.content === "string") texts.push(item.content);
|
||||
};
|
||||
for (const item of payload?.input ?? []) collect(item);
|
||||
if (typeof payload?.input === "string") texts.push(payload.input);
|
||||
return texts.join(" ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Craft a deterministic local reply from the user's input.
|
||||
*
|
||||
* Deterministic arithmetic answers make end-to-end checks (`5+5 -> 10`)
|
||||
* readable at a glance; anything else echoes back what arrived so a wrong
|
||||
* request shape is immediately visible in the UI.
|
||||
*/
|
||||
export const mockReplyFor = (payload) => {
|
||||
const joined = collectText(payload);
|
||||
if (/5\s*\+\s*5/i.test(joined)) return "10";
|
||||
if (/3\s*\+\s*3/i.test(joined)) return "6";
|
||||
if (/2\s*\+\s*2/i.test(joined)) return "4";
|
||||
if (/hello/i.test(joined)) return "hello from the local mock model";
|
||||
return `Local mock reply. I received: ${joined.slice(0, 200) || "(empty input)"}`;
|
||||
};
|
||||
|
||||
let callCount = 0;
|
||||
|
||||
const sseEvent = (res, type, data) => {
|
||||
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Answer `/mockrelay/v1/*` — the model provider surface.
|
||||
*
|
||||
* @param {import("node:http").IncomingMessage} req
|
||||
* @param {import("node:http").ServerResponse} res
|
||||
* @param {string} urlPath path beginning with `/mockrelay`
|
||||
* @param {{ onRequest?: (line: string) => void }} [opts]
|
||||
*/
|
||||
export const serveMockRelay = (req, res, urlPath, opts = {}) => {
|
||||
opts.onRequest?.(`${req.method} ${urlPath} (mock model relay)`);
|
||||
|
||||
const route = urlPath.replace(/^\/mockrelay/, "") || "/";
|
||||
const json = (obj, status = 200) => {
|
||||
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
|
||||
// Model list — keeps provider probes happy.
|
||||
if (route === "/v1/models" || route === "/models") {
|
||||
json({ object: "list", data: [{ id: "gpt-6.1-sol", object: "model", owned_by: "local" }] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (route === "/v1/responses" || route === "/responses") {
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(405).end("method not allowed");
|
||||
return;
|
||||
}
|
||||
readJsonBody(req)
|
||||
.then((payload) => {
|
||||
const reply = mockReplyFor(payload);
|
||||
const model = typeof payload?.model === "string" ? payload.model : "gpt-6.1-sol";
|
||||
const n = ++callCount;
|
||||
const respId = `resp_local_${Date.now().toString(36)}_${n}`;
|
||||
const msgId = `msg_local_${n}`;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const responseShell = (status, extra = {}) => ({
|
||||
id: respId,
|
||||
object: "response",
|
||||
created_at: now,
|
||||
status,
|
||||
model,
|
||||
output: [],
|
||||
usage: null,
|
||||
...extra,
|
||||
});
|
||||
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
connection: "keep-alive",
|
||||
});
|
||||
|
||||
sseEvent(res, "response.created", { type: "response.created", sequence_number: 0, response: responseShell("in_progress") });
|
||||
sseEvent(res, "response.in_progress", { type: "response.in_progress", sequence_number: 1, response: responseShell("in_progress") });
|
||||
|
||||
const messageItem = { type: "message", id: msgId, role: "assistant", status: "in_progress", content: [] };
|
||||
sseEvent(res, "response.output_item.added", {
|
||||
type: "response.output_item.added",
|
||||
sequence_number: 2,
|
||||
output_index: 0,
|
||||
item: messageItem,
|
||||
});
|
||||
|
||||
// Stream the reply in small chunks so the renderer's delta path runs.
|
||||
const chunks = reply.match(/.{1,8}/gs) ?? [reply];
|
||||
let seq = 3;
|
||||
for (const chunk of chunks) {
|
||||
sseEvent(res, "response.output_text.delta", {
|
||||
type: "response.output_text.delta",
|
||||
sequence_number: seq++,
|
||||
item_id: msgId,
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
delta: chunk,
|
||||
});
|
||||
}
|
||||
|
||||
const completedItem = {
|
||||
type: "message",
|
||||
id: msgId,
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: reply, annotations: [] }],
|
||||
};
|
||||
sseEvent(res, "response.output_text.done", {
|
||||
type: "response.output_text.done",
|
||||
sequence_number: seq++,
|
||||
item_id: msgId,
|
||||
output_index: 0,
|
||||
content_index: 0,
|
||||
text: reply,
|
||||
});
|
||||
sseEvent(res, "response.output_item.done", {
|
||||
type: "response.output_item.done",
|
||||
sequence_number: seq++,
|
||||
output_index: 0,
|
||||
item: completedItem,
|
||||
});
|
||||
|
||||
const finalResponse = responseShell("completed", {
|
||||
output: [completedItem],
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
input_tokens_details: { cached_tokens: 0 },
|
||||
output_tokens: 1,
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
total_tokens: 1,
|
||||
},
|
||||
});
|
||||
sseEvent(res, "response.completed", { type: "response.completed", sequence_number: seq++, response: finalResponse });
|
||||
res.end();
|
||||
})
|
||||
.catch(() => {
|
||||
res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: { message: "bad request body" } }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
json({ error: { message: `mock relay: unhandled path ${route}` } }, 404);
|
||||
};
|
||||
|
||||
/**
|
||||
* Answer the ChatGPT-backend surface (`/mock/*`) with truthful local shapes.
|
||||
*
|
||||
* Every response is either the exact JSON the desktop main process would hand
|
||||
* over, or an empty-but-valid object. The point is that nothing errors and
|
||||
* nothing leaves the machine.
|
||||
*/
|
||||
export const serveMockBackend = (req, res, urlPath, opts = {}) => {
|
||||
opts.onRequest?.(`${req.method} ${urlPath}`);
|
||||
const json = (obj, status = 200) => {
|
||||
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
const p = urlPath.replace(/^\/mock/, "") || "/";
|
||||
|
||||
// Cloud MCP endpoint (ChatGPT-hosted app connectors). 404 makes the app
|
||||
// server mark these unavailable immediately — a semantically empty 200
|
||||
// would be retried and block thread startup on MCP enumeration.
|
||||
if (/^\/api\/codex\/ps\/mcp/.test(p)) {
|
||||
json({ error: { message: "cloud connectors are unavailable in a local build" } }, 404);
|
||||
return;
|
||||
}
|
||||
if (/^\/wham\/tasks\/list/.test(p)) {
|
||||
json({ items: [], cursor: null });
|
||||
return;
|
||||
}
|
||||
if (/plugin/i.test(p)) {
|
||||
json({ schema_version: 1, fetched_at: new Date().toISOString(), plugins: [] });
|
||||
return;
|
||||
}
|
||||
json({});
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Projectless thread workspaces.
|
||||
*
|
||||
* Codex keeps two kinds of conversation: one attached to a project directory and
|
||||
* one that starts from nothing. The desktop main process owns the second kind —
|
||||
* it picks a folder under the user's workspace root, creates it, and hands the
|
||||
* paths back to the renderer through `projectlessThreads.createPaths`.
|
||||
*
|
||||
* The browser cannot touch the filesystem, so the Node host does it here and
|
||||
* exposes the same three paths over HTTP:
|
||||
*
|
||||
* cwd the directory this conversation runs in
|
||||
* outputDirectory where generated files land (same folder)
|
||||
* workspaceRoot the shared root that contains every thread
|
||||
*
|
||||
* Layout mirrors the desktop app: <root>/<YYYY-MM-DD>/<slug>
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const WORKSPACE_ROOT =
|
||||
// Linux server default: ~/Codex. The desktop default ~/Documents/Codex
|
||||
// does not exist on a server, so it is not used here.
|
||||
process.env.CODEX_WEB_WORKSPACE_ROOT || path.join(os.homedir(), "Codex");
|
||||
|
||||
export function ensureWorkspaceRoot() {
|
||||
fs.mkdirSync(WORKSPACE_ROOT, { recursive: true });
|
||||
return WORKSPACE_ROOT;
|
||||
}
|
||||
|
||||
/** Lowercase, collapse anything awkward, keep CJK, cap the length. */
|
||||
function slugify(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text) return "";
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{Letter}\p{Number}]+/gu, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
export function createProjectlessPaths({ directoryName, prompt } = {}) {
|
||||
const root = ensureWorkspaceRoot();
|
||||
const day = new Date().toISOString().slice(0, 10);
|
||||
const base = slugify(directoryName) || slugify(prompt) || "thread";
|
||||
|
||||
let dir = path.join(root, day, base);
|
||||
let suffix = 1;
|
||||
while (fs.existsSync(dir)) {
|
||||
suffix += 1;
|
||||
dir = path.join(root, day, `${base}-${suffix}`);
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
return { cwd: dir, outputDirectory: dir, workspaceRoot: root };
|
||||
}
|
||||
|
||||
export function readWorkspaceRoot() {
|
||||
return { workspaceRoot: ensureWorkspaceRoot() };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# relay-config.json — mock 现状与接真实 relay
|
||||
|
||||
`relay-config.json` 保持 UI 栈自带的 Bumian 配置原样,未换 provider、未写任何密钥值。
|
||||
|
||||
## 当前状态:模型调用走本地 mock
|
||||
|
||||
- `baseUrl` 是 `http://127.0.0.1:8095/mockrelay/v1`:app-server 的模型请求由本机 `host/mock-model.mjs` 用确定性替身回答,不出本机、不产生真实调用、不花钱。
|
||||
- `relayBaseUrl`(`https://www.bumianapi.top/v1`)只写在 JSON 里,host 代码没有引用它,当前不生效。
|
||||
- `apiKey` 为空字符串:真实 relay 需要 key 时,key 值绝不写进本文件。
|
||||
|
||||
## 接真实 relay:两步
|
||||
|
||||
1. 把 `baseUrl` 的值改为 `relayBaseUrl` 的值(即 `https://www.bumianapi.top/v1`)。
|
||||
只改这一处;`providerId` / `providerName` / `model` / `wireApi` / `envKey` 不动。
|
||||
2. 在启动 UI host 的环境里设置 `BUMIAN_API_KEY` 环境变量(key 值只进环境,不进文件、不进仓库):
|
||||
`BUMIAN_API_KEY=<你的 key> scripts/run-codex-ui.sh`
|
||||
|
||||
改完后 app-server 启动时按 `relay-config.json` 的 `-c` 覆盖走真实 relay;`chatgptBaseUrl`
|
||||
仍指向本机 `/mock`,插件目录、账号端点、遥测等 ChatGPT 后端调用继续由本地替身回答。
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"providerId": "bumian",
|
||||
"providerName": "Bumian Relay",
|
||||
"baseUrl": "http://127.0.0.1:8095/mockrelay/v1",
|
||||
"relayBaseUrl": "https://www.bumianapi.top/v1",
|
||||
"apiKey": "",
|
||||
"envKey": "BUMIAN_API_KEY",
|
||||
"model": "gpt-6.1-sol",
|
||||
"wireApi": "responses",
|
||||
"chatgptBaseUrl": "http://127.0.0.1:8095/mock"
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
/* Codex web host shim — browser-only replacement for the Electron preload.
|
||||
*
|
||||
* Loaded as a classic script before the module entry. It provides the two
|
||||
* pieces of the Electron shell the renderer cannot live without:
|
||||
*
|
||||
* window.electronBridge — the preload API surface. Only the members the
|
||||
* renderer actually probes are meaningful; the rest
|
||||
* resolve to inert promises so .then/.catch chains
|
||||
* stay valid.
|
||||
* the app-server bridge — request envelopes sent through
|
||||
* sendMessageFromView are forwarded to the local
|
||||
* codex app server and answered on the window
|
||||
* message bus, which is where the renderer listens.
|
||||
*/
|
||||
(() => {
|
||||
const noop = () => {};
|
||||
|
||||
globalThis.__codexOut = [];
|
||||
globalThis.__codexIn = [];
|
||||
let rpcCount = 0;
|
||||
|
||||
// ------------------------------------------------------------------ inbound
|
||||
// The renderer validates inbound messages with `event.source === null` (it
|
||||
// skips the origin check in that case), which is exactly what a synthesized
|
||||
// MessageEvent gives us.
|
||||
const dispatchInbound = (payload) => {
|
||||
globalThis.__codexIn.push(payload);
|
||||
window.dispatchEvent(new MessageEvent("message", { data: payload }));
|
||||
};
|
||||
|
||||
const forwardRequest = (msg) => {
|
||||
const { id, method, params } = msg.request;
|
||||
rpcCount += 1;
|
||||
if (rpcCount <= 500) console.info("[codex-web] rpc ->", method);
|
||||
fetch("/__codex_rpc", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ method: method, params: params ?? {} }),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((body) => {
|
||||
// The renderer's app-server request client reads the reply from
|
||||
// `message: { id, result | error }`. `response` is kept alongside it
|
||||
// because the app-host side of the same bus uses that spelling.
|
||||
const message =
|
||||
body && body.error
|
||||
? { id: id, error: { code: body.error.code ?? -32000, message: body.error.message ?? "rpc failed" } }
|
||||
: { id: id, result: body ? body.result : null };
|
||||
dispatchInbound({
|
||||
type: "mcp-response",
|
||||
hostId: msg.hostId,
|
||||
message: message,
|
||||
response: message,
|
||||
requestMethod: method,
|
||||
receivedAtMs: Date.now(),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
const message = { id: id, error: { code: -32000, message: String((err && err.message) || err) } };
|
||||
dispatchInbound({
|
||||
type: "mcp-response",
|
||||
hostId: msg.hostId,
|
||||
message: message,
|
||||
response: message,
|
||||
requestMethod: method,
|
||||
receivedAtMs: Date.now(),
|
||||
});
|
||||
});
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------- events
|
||||
// Everything the app server says on its own initiative — streaming deltas,
|
||||
// turn progress, server-initiated approval prompts — arrives on this
|
||||
// long-poll. The renderer subscribes to it as `mcp-notification`.
|
||||
globalThis.__codexEvents = [];
|
||||
let polling = false;
|
||||
|
||||
const pumpEvents = async () => {
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
while (polling) {
|
||||
try {
|
||||
const res = await fetch("/__codex_events", { cache: "no-store" });
|
||||
const body = await res.json();
|
||||
for (const event of body.events || []) {
|
||||
globalThis.__codexEvents.push(event);
|
||||
if (globalThis.__codexEvents.length > 400) globalThis.__codexEvents.shift();
|
||||
if (event.id != null) {
|
||||
// Server-initiated request: the renderer owns the answer path, which
|
||||
// this shim does not model yet. Keep it visible instead of dropping.
|
||||
globalThis.__codexServerRequests = globalThis.__codexServerRequests || [];
|
||||
globalThis.__codexServerRequests.push(event);
|
||||
continue;
|
||||
}
|
||||
dispatchInbound({
|
||||
type: "mcp-notification",
|
||||
hostId: "local",
|
||||
method: event.method,
|
||||
params: event.params ?? {},
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
}
|
||||
};
|
||||
pumpEvents();
|
||||
|
||||
// ------------------------------------------------------------- electron API
|
||||
// Synchronous getters first: callers treat these as plain values.
|
||||
const SYNC = {
|
||||
getInitialSidebarBootstrap: () => null,
|
||||
getConfig: () => null,
|
||||
getPlatform: () => "darwin",
|
||||
getLocale: () => "zh-CN",
|
||||
getVersion: () => "26.1021.31345",
|
||||
isPackaged: () => true,
|
||||
getWindowType: () => "electron",
|
||||
getZoomLevel: () => 0,
|
||||
isDeviceCheckSupported: () => true,
|
||||
isIntelMacBuild: () => false,
|
||||
getSystemThemeVariant: () => "light",
|
||||
subscribeToSystemThemeVariant: () => noop,
|
||||
getBuildFlavor: () => "codex",
|
||||
getAppSessionId: () => "codex-web-local",
|
||||
getDesktopUserAgent: () => navigator.userAgent,
|
||||
getSentryInitOptions: () => ({ appVersion: "26.1021.31345", buildFlavor: "codex" }),
|
||||
getSharedObjectSnapshotValue: () => undefined,
|
||||
};
|
||||
|
||||
const makeBridge = () =>
|
||||
new Proxy(SYNC, {
|
||||
get(target, prop) {
|
||||
if (prop in target) return target[prop];
|
||||
if (prop === "then") return undefined; // never a thenable
|
||||
if (typeof prop === "symbol") return undefined;
|
||||
if (prop === "sendMessageFromView") {
|
||||
return (msg) => {
|
||||
globalThis.__codexOut.push(msg);
|
||||
if (msg && msg.type === "mcp-request" && msg.request && typeof msg.request.method === "string") {
|
||||
return forwardRequest(msg);
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
}
|
||||
// Unknown member: an inert async function, so feature probes see
|
||||
// "present" and promise chains stay valid.
|
||||
return () => Promise.resolve();
|
||||
},
|
||||
has: () => true,
|
||||
});
|
||||
|
||||
if (typeof window.electronBridge === "undefined") window.electronBridge = makeBridge();
|
||||
if (typeof window.codexWindowType === "undefined") window.codexWindowType = "electron";
|
||||
if (typeof window.__SENTRY_IPC__ === "undefined") window.__SENTRY_IPC__ = { sendMessage: noop };
|
||||
window.__codexDesktop = window.__codexDesktop || {};
|
||||
|
||||
// ------------------------------------------------------------- offline net
|
||||
// Nothing leaves the machine. Absolute URLs get a well-formed empty JSON body
|
||||
// so callers fail softly instead of hitting the CSP. The remaining browser
|
||||
// transports — XHR, sendBeacon, WebSocket, EventSource — are stubbed the same
|
||||
// way: local requests pass through, everything else succeeds locally with
|
||||
// empty data.
|
||||
const realFetch = window.fetch.bind(window);
|
||||
const isLocal = (url) =>
|
||||
!url ||
|
||||
url.startsWith("/") ||
|
||||
url.startsWith("data:") ||
|
||||
url.startsWith("blob:") ||
|
||||
url.startsWith(location.origin);
|
||||
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === "string" ? input : (input && input.url) || "";
|
||||
if (isLocal(url)) return realFetch(input, init);
|
||||
console.info("[codex-web] outbound:", (init?.method || "GET"), url.slice(0, 140));
|
||||
const json = (obj, status = 200) =>
|
||||
Promise.resolve(new Response(typeof obj === "string" ? obj : JSON.stringify(obj), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}));
|
||||
// Statsig telemetry hosts. The SDK requires its initialize payload to
|
||||
// carry `has_updates` (see _typedJsonParse), so hand back a valid empty
|
||||
// evaluation set — every gate then resolves to "off" locally and the SDK
|
||||
// completes initialization instead of retrying forever.
|
||||
if (/ab\.chatgpt\.com|api\.oaistatsig\.com|statsigcdn\.openai\.com/i.test(url)) {
|
||||
if (/\/initialize/i.test(url)) {
|
||||
return json({
|
||||
has_updates: true,
|
||||
hash_used: "djb2",
|
||||
time: Date.now(),
|
||||
feature_gates: {},
|
||||
dynamic_configs: {},
|
||||
layer_configs: {},
|
||||
param_stores: {},
|
||||
});
|
||||
}
|
||||
if (/sdk_exception/i.test(url)) {
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
// rgstr / download_config_specs: accepted-and-empty.
|
||||
return Promise.resolve(new Response(null, { status: 202 }));
|
||||
}
|
||||
// Everything else: generic empty JSON success.
|
||||
return json({});
|
||||
};
|
||||
|
||||
// XMLHttpRequest: same policy. A cross-origin send completes immediately
|
||||
// with `{}` and status 200, on the microtask queue so ordering stays sane.
|
||||
const XHR = window.XMLHttpRequest;
|
||||
if (XHR && XHR.prototype) {
|
||||
const openOrig = XHR.prototype.open;
|
||||
const sendOrig = XHR.prototype.send;
|
||||
XHR.prototype.open = function (method, url, ...rest) {
|
||||
this.__codexUrl = String(url ?? "");
|
||||
return openOrig.call(this, method, url, ...rest);
|
||||
};
|
||||
XHR.prototype.send = function (body) {
|
||||
if (isLocal(this.__codexUrl)) return sendOrig.call(this, body);
|
||||
const payload = this.responseType === "json" ? {} : "{}";
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
Object.defineProperty(this, "readyState", { value: 4, configurable: true });
|
||||
Object.defineProperty(this, "status", { value: 200, configurable: true });
|
||||
Object.defineProperty(this, "statusText", { value: "OK", configurable: true });
|
||||
Object.defineProperty(this, "responseText", { value: payload, configurable: true });
|
||||
Object.defineProperty(this, "response", { value: payload, configurable: true });
|
||||
Object.defineProperty(this, "responseURL", { value: this.__codexUrl, configurable: true });
|
||||
this.dispatchEvent(new Event("readystatechange"));
|
||||
this.dispatchEvent(new ProgressEvent("load"));
|
||||
this.dispatchEvent(new ProgressEvent("loadend"));
|
||||
} catch {
|
||||
/* the request object went away; nothing to satisfy */
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// sendBeacon is fire-and-forget by contract; "delivered" locally means dropped.
|
||||
try {
|
||||
if (navigator.sendBeacon) navigator.sendBeacon = () => true;
|
||||
} catch {
|
||||
/* some engines make it a getter; the CSP covers those */
|
||||
}
|
||||
|
||||
// WebSocket: hand back a socket that is already closed. No network dial, no
|
||||
// browser console noise, and well-behaved clients see a terminal state.
|
||||
const RealWS = window.WebSocket;
|
||||
if (RealWS) {
|
||||
class ClosedSocket extends EventTarget {
|
||||
constructor(url) {
|
||||
super();
|
||||
this.url = String(url ?? "");
|
||||
this.readyState = 3; // CLOSED
|
||||
this.bufferedAmount = 0;
|
||||
this.extensions = "";
|
||||
this.protocol = "";
|
||||
this.binaryType = "blob";
|
||||
queueMicrotask(() => {
|
||||
this.dispatchEvent(new Event("error"));
|
||||
this.dispatchEvent(new CloseEvent("close", { wasClean: true, code: 1000 }));
|
||||
});
|
||||
}
|
||||
send() {
|
||||
throw new DOMException("The socket is closed.", "InvalidStateError");
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
for (const k of ["CONNECTING", "OPEN", "CLOSING", "CLOSED"]) ClosedSocket[k] = RealWS[k];
|
||||
window.WebSocket = ClosedSocket;
|
||||
}
|
||||
|
||||
// EventSource: a stream that closes immediately, for the same reason.
|
||||
if (window.EventSource) {
|
||||
class ClosedStream extends EventTarget {
|
||||
constructor(url) {
|
||||
super();
|
||||
this.url = String(url ?? "");
|
||||
this.readyState = 2; // CLOSED
|
||||
this.withCredentials = false;
|
||||
queueMicrotask(() => this.dispatchEvent(new Event("error")));
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
window.EventSource = ClosedStream;
|
||||
}
|
||||
|
||||
console.info("[codex-web] offline host shim installed");
|
||||
})();
|
||||
|
||||
// ------------------------------------------------------------- config preseed
|
||||
// The scope-query bridge only fetches from mounted React readers, and the
|
||||
// config/effective + permission-profiles queries have no mounted reader in a
|
||||
// browser fallback (no project tree, no workspace picker). Prime both from the
|
||||
// local app server up front so the composer's agent-mode resolution sees real
|
||||
// data on first render instead of a query that would stay pending forever.
|
||||
(() => {
|
||||
const rpc = (method, params) =>
|
||||
fetch("/__codex_rpc", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ method, params: params ?? null }),
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((b) => (b && b.result ? b.result : null))
|
||||
.catch(() => null);
|
||||
|
||||
globalThis.__codexCfg = null;
|
||||
globalThis.__codexPerms = null;
|
||||
|
||||
rpc("config/read", { includeLayers: true, cwd: null }).then((r) => {
|
||||
if (r && r.config) {
|
||||
globalThis.__codexCfg = r;
|
||||
console.info("[codex-web] config preseeded");
|
||||
}
|
||||
});
|
||||
rpc("permissionProfile/list", { cursor: null, limit: 50, cwd: null }).then((r) => {
|
||||
if (r && Array.isArray(r.data)) {
|
||||
globalThis.__codexPerms = r.data;
|
||||
console.info("[codex-web] permission profiles preseeded");
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"source": "/tmp/codex-asar/webview",
|
||||
"vendoredAt": "2026-10-27T03:31:32.962Z",
|
||||
"fileCount": 20918,
|
||||
"indexHtmlSha256": "85e5c5b4ce1cd7e0fc42ccbac7fe6d9c6ac7b2eb4b8a7f77beb39d280fdca9f5",
|
||||
"verification": "diff -rq clean; identical sha256"
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Vendor the original webview verbatim into ./original.
|
||||
*
|
||||
* Source: the extracted ChatGPT.app/Contents/Resources/app.asar, i.e.
|
||||
* <extracted-asar>/webview
|
||||
*
|
||||
* Nothing is rewritten. The copy is byte-for-byte identical, which is what makes
|
||||
* the UI 100% the original rather than an approximation.
|
||||
*
|
||||
* Usage:
|
||||
* node host/vendor-original.mjs --from /tmp/codex-asar/webview
|
||||
* node host/vendor-original.mjs --from /path/to/webview --clean
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(__dirname, "..");
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const getArg = (name, fallback) => {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 && args[i + 1] ? args[i + 1] : fallback;
|
||||
};
|
||||
|
||||
const from = path.resolve(getArg("--from", "/tmp/codex-asar/webview"));
|
||||
const to = path.resolve(projectRoot, getArg("--to", "original"));
|
||||
const clean = args.includes("--clean");
|
||||
|
||||
if (!fs.existsSync(path.join(from, "index.html"))) {
|
||||
console.error(`[vendor] not a webview directory: ${from}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (clean && fs.existsSync(to)) {
|
||||
console.log(`[vendor] removing ${to}`);
|
||||
fs.rmSync(to, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(to, { recursive: true });
|
||||
|
||||
console.log(`[vendor] copying ${from} -> ${to}`);
|
||||
fs.cpSync(from, to, { recursive: true, force: true });
|
||||
|
||||
// Manifest (written outside ./original so the vendored tree stays untouched).
|
||||
function sha256(file) {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
}
|
||||
function countFiles(dir) {
|
||||
let n = 0;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
n += entry.isDirectory() ? countFiles(path.join(dir, entry.name)) : 1;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
source: from,
|
||||
vendoredAt: new Date().toISOString(),
|
||||
fileCount: countFiles(to),
|
||||
indexHtmlSha256: sha256(path.join(to, "index.html")),
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(projectRoot, "host", "vendor-manifest.json"),
|
||||
JSON.stringify(manifest, null, 2),
|
||||
);
|
||||
|
||||
console.log(`[vendor] done — ${manifest.fileCount} files`);
|
||||
console.log(`[vendor] index.html sha256 ${manifest.indexHtmlSha256}`);
|
||||
@@ -0,0 +1,718 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Local web host for the vendored Codex webview.
|
||||
*
|
||||
* `original/` stays byte-identical — every adaptation happens in the HTTP
|
||||
* response layer, so the vendored tree remains a clean reference.
|
||||
*
|
||||
* The desktop renderer is not a plain web app; three pieces of the Electron
|
||||
* shell are load-bearing and are replaced here:
|
||||
*
|
||||
* 1. CSP. The shipped policy is `default-src 'none'` with a connect-src that
|
||||
* only whitelists `wss://chatgpt.com`. A browser tab could not reach
|
||||
* anything, and we do not want it to: the policy is rewritten to `'self'`
|
||||
* so the page is fully local.
|
||||
* 2. The app host. `connect-app-host` creates a MessageChannel and posts one
|
||||
* port to the preload, which hands it to the main process. Here the port is
|
||||
* never used: `__codexLocalHost` answers instead (host/local-host.js).
|
||||
* 3. Host services. The bootstrap awaits `appHost.services`, which the main
|
||||
* process resolves with its service layer. Our local host resolves it with
|
||||
* a proxy onto the real `codex app-server` (host/app-server-bridge.mjs).
|
||||
*
|
||||
* Usage:
|
||||
* node host/web-server.mjs [--port 8095] [--root original]
|
||||
*/
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { AppServerBridge } from "./app-server-bridge.mjs";
|
||||
import { serveMockRelay } from "./mock-model.mjs";
|
||||
import { createProjectlessPaths, readWorkspaceRoot, WORKSPACE_ROOT } from "./projectless.mjs";
|
||||
|
||||
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PROJECT = path.resolve(HERE, "..");
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const argOf = (name, fallback) => {
|
||||
const i = argv.indexOf(name);
|
||||
return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback;
|
||||
};
|
||||
const PORT = Number(argOf("--port", process.env.CODEX_WEB_PORT || "8095"));
|
||||
// --root wins; otherwise CODEX_UI_ORIGINAL_ROOT (set by scripts/run-codex-ui.sh),
|
||||
// otherwise the in-repo `original/` directory (never committed — see §4).
|
||||
const ROOT = path.resolve(PROJECT, argOf("--root", process.env.CODEX_UI_ORIGINAL_ROOT || "original"));
|
||||
|
||||
if (!fs.existsSync(path.join(ROOT, "index.html"))) {
|
||||
console.error(`[web] missing index.html under ${ROOT}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const METHODS = JSON.parse(fs.readFileSync(path.join(HERE, "appserver-methods.json"), "utf8"));
|
||||
const bridge = new AppServerBridge();
|
||||
|
||||
// --------------------------------------------------------------- CSP rewrite
|
||||
// Fully local: nothing outside this origin is reachable. Blob/data stay allowed
|
||||
// because the app builds workers and assets in memory.
|
||||
const WEB_CSP = [
|
||||
"default-src 'self'",
|
||||
"img-src 'self' data: blob:",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"font-src 'self' data:",
|
||||
"connect-src 'self' data: blob:",
|
||||
"media-src 'self' data: blob:",
|
||||
"worker-src 'self' blob:",
|
||||
"frame-src 'self' blob:",
|
||||
"child-src 'self' blob:",
|
||||
].join("; ");
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".otf": "font/otf",
|
||||
".wasm": "application/wasm",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".wav": "audio/wav",
|
||||
".mp3": "audio/mpeg",
|
||||
};
|
||||
|
||||
/** Rewrite rules applied on the fly; `original/` on disk is never touched. */
|
||||
const JS_PATCHES = [
|
||||
{
|
||||
name: "local-app-host",
|
||||
// `connect-app-host` opens a MessageChannel and posts one port to the
|
||||
// preload, which forwards it to the Electron main process. There is no main
|
||||
// process here, so answer with the local host instead.
|
||||
find: /function (\w+)\((\w+)\)\{let\{port1:(\w+),port2:(\w+)\}=new MessageChannel;return window\.postMessage\(\{type:`connect-app-host`/g,
|
||||
replace: (_m, fn, arg, p1, p2) =>
|
||||
`function ${fn}(${arg}){if(globalThis.__codexLocalHost)return globalThis.__codexLocalHost(${arg});` +
|
||||
`let{port1:${p1},port2:${p2}}=new MessageChannel;return window.postMessage({type:\`connect-app-host\``,
|
||||
},
|
||||
{
|
||||
name: "bootstrap-guard",
|
||||
// The original bootstrap wires up seven main-process facilities: remote
|
||||
// connection discovery, an execution host, an RPC-broken hook, thread read
|
||||
// state, Statsig, client coordination and the terminal. Only the first two
|
||||
// affect the shell; the rest are Electron-only and would abort the whole
|
||||
// sequence on the first rejection. Keep the parts that matter, guard them,
|
||||
// and drop the rest.
|
||||
find: /async function pL\(\)\{[\s\S]*?\}function mL\(\)/g,
|
||||
replace: () =>
|
||||
`async function pL(){` +
|
||||
`const T=(ms)=>new Promise(r=>setTimeout(r,ms));` +
|
||||
`const guard=async(f,ms)=>{try{return await Promise.race([f(),T(ms)])}catch(e){console.warn("[codex-web] init step skipped:",e&&e.message)}};` +
|
||||
`await guard(async()=>{$t(kS(lL))},5000);` +
|
||||
`await guard(async()=>{dr(await Pt.services)},10000);` +
|
||||
`await guard(async()=>{await ql(H.remoteConnections)},2000);` +
|
||||
`await guard(async()=>{di(await H.executionHosts?.getDefaultHost())},2000);` +
|
||||
`globalThis.__codexBootstrapped=!0` +
|
||||
`}function mL()`,
|
||||
},
|
||||
{
|
||||
name: "skip-shell-gate",
|
||||
// The shell waits for auth, app-info, os-info and one more readiness signal
|
||||
// before rendering anything. In the desktop app the main process supplies
|
||||
// all four; here auth in particular only settles after the app server has
|
||||
// been asked for an account, which cannot happen until the shell exists.
|
||||
// Render the shell immediately and let the data arrive underneath it.
|
||||
find: /if\((\w+)\.isLoading\|\|(\w+)\|\|(\w+)\|\|(\w+)\)\{/g,
|
||||
replace: (_m, r, a, s, c) =>
|
||||
`if(console.info("[codex-web] shell gate",JSON.stringify({rLoad:${r}&&${r}.isLoading,a:${a},s:${s},c:${c}})),!1){`,
|
||||
},
|
||||
{
|
||||
name: "mode-probe",
|
||||
// `Ck` picks the window mode (`app` / `onboarding` / `login`) from a pile of
|
||||
// readiness signals and falls back to the loading blossom when it comes back
|
||||
// empty. The guard is a comma expression inside a single `if`, so the anchor
|
||||
// is the tail of that expression rather than the `if(` itself. Log the
|
||||
// decision so the missing input is identifiable.
|
||||
find: /,!(\w+)\|\|(\w+)===\x60loading\x60\)\{/g,
|
||||
replace: (_m, mode, status) =>
|
||||
`,console.info("[codex-web] mode",JSON.stringify({mode:${mode},status:${status}})),!${mode}||${status}===\x60loading\x60){`,
|
||||
},
|
||||
{
|
||||
name: "mode-inputs",
|
||||
// Bind the mode-input object to the global scope on its way into the
|
||||
// decision function, so a probe can read exactly what the shell decided on.
|
||||
find: /,(\w+)=(\w+)\(\{bedrockLoginPending:/g,
|
||||
replace: (_m, out, fn) => `,${out}=${fn}(globalThis.__modeIn={bedrockLoginPending:`,
|
||||
},
|
||||
{
|
||||
name: "mode-decide",
|
||||
// The window-mode decision gates on two desktop-only concepts:
|
||||
//
|
||||
// gatewayOAuthReadiness — an OpenAI gateway handshake that stays `loading`
|
||||
// forever here because there is no gateway: this build talks to an
|
||||
// API-key relay, so there is nothing to wait for.
|
||||
// onboarding completion — the desktop onboarding flow needs a ChatGPT
|
||||
// account and a backend, neither of which this build has.
|
||||
//
|
||||
// Collapse both: honour an explicit "login required", otherwise go straight
|
||||
// to the app. The relay authenticates with a key, so there is no login step.
|
||||
find: /(\w+)===\x60loading\x60\?null:\1===\x60required\x60\?\x60login\x60:[\s\S]{0,200}?:\x60welcome\x60\):\x60app\x60\}/g,
|
||||
replace: (_m, readiness) =>
|
||||
`${readiness}===\x60required\x60?\x60login\x60:\x60app\x60:\x60app\x60}`,
|
||||
},
|
||||
{
|
||||
name: "config-effective-preseed",
|
||||
// The scope-query bridge fetches only from mounted React readers. In a
|
||||
// browser fallback nothing mounts the config/effective query, so `ofo()`
|
||||
// would read `isPending: true` forever and keep `isAgentModeLoading` true.
|
||||
// Short-circuit to the preseeded app-server config (host/shim.js).
|
||||
find: /let\{data:(\w+),isPending:(\w+)\}=ixn\((\w+)===`~`\?null:\3,(\w+)\),/,
|
||||
replace: (_m, w, T, p, C) =>
|
||||
`let{data:${w},isPending:${T}}=globalThis.__codexCfg?{data:{config:globalThis.__codexCfg.config,origins:globalThis.__codexCfg.origins??{},layers:null},isPending:!1}:(ixn(${p}===\`~\`?null:${p},${C}),{data:void 0,isPending:!0}),`,
|
||||
},
|
||||
{
|
||||
name: "permission-profiles-preseed",
|
||||
// Same mount requirement for the permission-profiles query read inside the
|
||||
// agent-mode hook (`sfo`); short-circuit to the preseeded profile list.
|
||||
find: /let (\w+)=ns\(kdo,(\w+),(\w+)\),(\w+)=\w+\|\|\w+\|\|\1\.isPending,(\w+)=\4\|\|\1\.isError,/,
|
||||
replace: (_m, O, T2, D2, k, A) =>
|
||||
`let ${O}=globalThis.__codexPerms?{data:globalThis.__codexPerms,isPending:!1,isError:!1}:ns(kdo,${T2},${D2}),${k}=h||g||${O}.isPending,${A}=${k}||${O}.isError,`,
|
||||
},
|
||||
{
|
||||
name: "guardian-off",
|
||||
// The guardian-approvals feature flag derives from a Statsig dynamic
|
||||
// config the desktop main process evaluates; with a null evaluations
|
||||
// payload (this build has no ChatGPT account) the guard reads as
|
||||
// "unavailable" and `ofo()` treats guardian mode as pending forever.
|
||||
// This build has no guardian capability at all, so pin it off.
|
||||
find: /N=(\w+)&&oee\(\w+\?\?void 0\)!==!1&&\(\w+===void 0\|\|\w+\),/,
|
||||
replace: (_m, e) => `N=!1,`,
|
||||
},
|
||||
{
|
||||
name: "statsig-bootstrap-local",
|
||||
// The renderer fetches its Statsig bootstrap from the ChatGPT backend
|
||||
// (`POST /wham/statsig/bootstrap`). Without a ChatGPT token that request
|
||||
// is rejected before it is ever sent, and the SDK retries forever with
|
||||
// "SDK initialization has not completed" warnings. Serve the bootstrap
|
||||
// from the local host instead (see /__codex_statsig_bootstrap).
|
||||
find: /function (\w+)\((\w+),(\w+)\)\{return (\w+)\.safePost\(`\/wham\/statsig\/bootstrap`,\{requestBody:\2,retry:!1,signal:\3\}\)\}/g,
|
||||
replace: (_m, fn, reqBody, signal) =>
|
||||
`async function ${fn}(${reqBody},${signal}){` +
|
||||
`const r=await fetch("/__codex_statsig_bootstrap",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(${reqBody}??{}),signal:${signal}});` +
|
||||
`return r.json()}`,
|
||||
},
|
||||
{
|
||||
name: "expose-app-host",
|
||||
// setAppHost / setAppServices only assign module-scoped variables, which
|
||||
// makes the wiring invisible from the outside. Mirror them for diagnostics.
|
||||
find: /function hSt\((\w+)\)\{_St=\1\}/g,
|
||||
replace: (_m, v) => `function hSt(${v}){_St=${v};globalThis.__appHost=${v}}`,
|
||||
},
|
||||
{
|
||||
name: "expose-app-services",
|
||||
find: /function gSt\((\w+)\)\{f\$=\1\}/g,
|
||||
replace: (_m, v) => `function gSt(${v}){f$=${v};globalThis.__appServices=${v}}`,
|
||||
},
|
||||
];
|
||||
|
||||
function patchJs(source) {
|
||||
let out = source;
|
||||
const applied = [];
|
||||
for (const rule of JS_PATCHES) {
|
||||
if (!rule.find.test(out)) continue;
|
||||
rule.find.lastIndex = 0;
|
||||
out = out.replace(rule.find, rule.replace);
|
||||
applied.push(rule.name);
|
||||
}
|
||||
return { out, applied };
|
||||
}
|
||||
|
||||
function patchHtml(source) {
|
||||
let out = source;
|
||||
|
||||
out = out.replace(
|
||||
/<meta http-equiv="Content-Security-Policy" content="[\s\S]*?"\s*\/?>/i,
|
||||
`<meta http-equiv="Content-Security-Policy" content="${WEB_CSP.replace(/"/g, """)}" />`,
|
||||
);
|
||||
|
||||
// Both shims must run before the module entry.
|
||||
if (!out.includes("__codex_shim.js")) {
|
||||
out = out.replace(
|
||||
/<script type="module"/i,
|
||||
`<script src="/__codex_local_host.js"></script>\n <script src="/__codex_shim.js"></script>\n <script type="module"`,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const LOCAL_HOST_JS = fs
|
||||
.readFileSync(path.join(HERE, "local-host.js"), "utf8")
|
||||
.replace("/*@METHODS@*/ []", JSON.stringify(METHODS));
|
||||
|
||||
const SHIM = fs.readFileSync(path.join(HERE, "shim.js"), "utf8");
|
||||
// ------------------------------------------------------------- mock backend
|
||||
// Local stand-in for the ChatGPT backend. The app server runs with
|
||||
// chatgpt_base_url pointed at /mock, so every call that would reach OpenAI's
|
||||
// cloud — plugin catalog refresh, account endpoints, telemetry — is answered
|
||||
// from this process with well-formed local JSON. Nothing leaves the machine
|
||||
// and no caller ever sees a connection error.
|
||||
const MOCK_LOG = [];
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
/** Valid, empty Statsig initialize payload — every gate resolves to "off". */
|
||||
const statsigBootstrapPayload = (stableId = "codex-web-local") => ({
|
||||
has_updates: true,
|
||||
hash_used: "djb2",
|
||||
time: Date.now(),
|
||||
feature_gates: {},
|
||||
dynamic_configs: {},
|
||||
layer_configs: {},
|
||||
param_stores: {},
|
||||
user: {
|
||||
userID: null,
|
||||
email: null,
|
||||
custom: { auth_method: null },
|
||||
customIDs: { stableID: stableId, WebAnonymousCookieID: stableId, DeviceId: stableId },
|
||||
},
|
||||
});
|
||||
|
||||
/** A remote-catalog plugin descriptor the renderer can render without data. */
|
||||
const mockRemotePluginRead = (pluginName) => ({
|
||||
plugin: {
|
||||
summary: {
|
||||
id: pluginName,
|
||||
name: String(pluginName).replace(/^plugin_[^_]*_/, ""),
|
||||
remotePluginId: pluginName,
|
||||
version: null,
|
||||
installed: false,
|
||||
enabled: false,
|
||||
installPolicy: "NOT_AVAILABLE",
|
||||
installPolicySource: null,
|
||||
mustShowInstallationInterstitial: false,
|
||||
authPolicy: "ON_USE",
|
||||
availability: "NOT_AVAILABLE",
|
||||
disabledReason: "unavailable_offline",
|
||||
interface: {
|
||||
displayName: pluginName,
|
||||
shortDescription: "Cloud plugin (offline build)",
|
||||
longDescription: "This plugin belongs to the ChatGPT cloud catalog and is not available in a fully local build.",
|
||||
developerName: null,
|
||||
category: null,
|
||||
capabilities: [],
|
||||
websiteUrl: null,
|
||||
privacyPolicyUrl: null,
|
||||
termsOfServiceUrl: null,
|
||||
defaultPrompt: [],
|
||||
brandColor: "#6B7280",
|
||||
composerIcon: null,
|
||||
composerIconUrl: null,
|
||||
logo: null,
|
||||
logoDark: null,
|
||||
logoUrl: null,
|
||||
logoUrlDark: null,
|
||||
screenshots: [],
|
||||
screenshotUrls: [],
|
||||
},
|
||||
keywords: [],
|
||||
},
|
||||
skills: [],
|
||||
onboardingSkill: null,
|
||||
},
|
||||
});
|
||||
|
||||
const serveMock = (req, res, urlPath) => {
|
||||
let search = "";
|
||||
try {
|
||||
search = new URL(req.url, "http://localhost").search;
|
||||
} catch {
|
||||
/* keep empty */
|
||||
}
|
||||
MOCK_LOG.push(`${req.method} ${urlPath}${search}`);
|
||||
if (MOCK_LOG.length > 200) MOCK_LOG.shift();
|
||||
|
||||
const json = (obj, status = 200) => {
|
||||
res.writeHead(status, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
const p = urlPath.replace(/^\/mock/, "") || "/";
|
||||
|
||||
// Cloud MCP endpoint (ChatGPT-hosted app connectors). Answer 404 so the app
|
||||
// server marks these connectors unavailable immediately — with a semantically
|
||||
// empty 200 it would retry and block thread startup on MCP enumeration.
|
||||
if (/^\/api\/codex\/ps\/mcp/.test(p)) {
|
||||
res.writeHead(404, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ error: { message: "cloud connectors are unavailable in a local build" } }));
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- known wham (ChatGPT backend) shapes -------------------------------
|
||||
// Cloud tasks: the renderer dereferences `.items` and paginates on `.cursor`.
|
||||
if (/^\/wham\/tasks\/list/.test(p)) {
|
||||
json({ items: [], cursor: null });
|
||||
return;
|
||||
}
|
||||
// Statsig bootstrap: `{ statsigPayload: <string> }`, exactly as the desktop
|
||||
// main process hands it over.
|
||||
if (/^\/wham\/statsig\/bootstrap/.test(p)) {
|
||||
json({ statsigPayload: JSON.stringify(statsigBootstrapPayload()) });
|
||||
return;
|
||||
}
|
||||
|
||||
// Plugin catalog refresh: the app server persists this under
|
||||
// ~/.codex/cache/remote_plugin_catalog. The truthful local answer is an
|
||||
// empty catalog — no cloud plugins exist in this build.
|
||||
if (/plugin/i.test(p)) {
|
||||
json({ schema_version: 1, fetched_at: nowIso(), plugins: [] });
|
||||
return;
|
||||
}
|
||||
// Generic empty-but-valid JSON: callers parse it, find nothing to do, and
|
||||
// move on without errors.
|
||||
json({});
|
||||
};
|
||||
|
||||
// ------------------------------------------------------- mock model provider
|
||||
// The model wire lives in ./mock-model.mjs so the local model is defined once
|
||||
// and every caller gets the identical Responses-API stream.
|
||||
const logMockRequest = (line) => {
|
||||
MOCK_LOG.push(line);
|
||||
if (MOCK_LOG.length > 200) MOCK_LOG.shift();
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------- server
|
||||
// Read-only git ops accepted by POST /__codex_git (A7). Anything else is
|
||||
// rejected; there is intentionally no write op in this set.
|
||||
const GIT_OPS = new Set(["stable-metadata", "branch", "status"]);
|
||||
|
||||
const readJsonBody = (req) =>
|
||||
new Promise((resolve, reject) => {
|
||||
let raw = "";
|
||||
req.setEncoding("utf8");
|
||||
req.on("data", (c) => {
|
||||
raw += c;
|
||||
if (raw.length > 4_000_000) reject(new Error("body too large"));
|
||||
});
|
||||
req.on("end", () => {
|
||||
try {
|
||||
resolve(raw ? JSON.parse(raw) : {});
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
let urlPath;
|
||||
try {
|
||||
urlPath = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
|
||||
} catch {
|
||||
res.writeHead(400).end("bad request");
|
||||
return;
|
||||
}
|
||||
|
||||
if (urlPath === "/__codex_shim.js") {
|
||||
res.writeHead(200, { "content-type": MIME[".js"], "cache-control": "no-store" });
|
||||
res.end(SHIM);
|
||||
return;
|
||||
}
|
||||
|
||||
if (urlPath === "/__codex_local_host.js") {
|
||||
res.writeHead(200, { "content-type": MIME[".js"], "cache-control": "no-store" });
|
||||
res.end(LOCAL_HOST_JS);
|
||||
return;
|
||||
}
|
||||
|
||||
// Filesystem work the Electron main process would do: allocate a directory for
|
||||
// a conversation that is not attached to a project.
|
||||
if (urlPath === "/__codex_projectless") {
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(405).end("method not allowed");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { op, args } = await readJsonBody(req);
|
||||
const result = op === "readWorkspaceRoot" ? readWorkspaceRoot() : createProjectlessPaths(args ?? {});
|
||||
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ result }));
|
||||
} catch (err) {
|
||||
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ error: { message: err?.message ?? "workspace error" } }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Read-only git (A7). The renderer gates the composer on
|
||||
// `git.request("stable-metadata")`, which the desktop main process answers.
|
||||
// Only three read-only ops are accepted; write operations cannot be
|
||||
// expressed, and no caller-supplied path ever reaches argv — `cwd` is
|
||||
// passed to execFile as the working-directory option only, and every git
|
||||
// invocation runs strictly inside that directory. A missing cwd or a
|
||||
// non-repository cwd gets a structured error, not a silent stub.
|
||||
if (urlPath === "/__codex_git") {
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(405).end("method not allowed");
|
||||
return;
|
||||
}
|
||||
const gitError = (message) => {
|
||||
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ error: { message } }));
|
||||
};
|
||||
try {
|
||||
const { op, cwd } = await readJsonBody(req);
|
||||
if (!GIT_OPS.has(op)) {
|
||||
gitError(`unsupported git op: ${String(op)}`);
|
||||
return;
|
||||
}
|
||||
if (typeof cwd !== "string" || !cwd) {
|
||||
gitError("git request needs a cwd");
|
||||
return;
|
||||
}
|
||||
const dir = path.resolve(cwd);
|
||||
let st;
|
||||
try {
|
||||
st = fs.statSync(dir);
|
||||
} catch {
|
||||
gitError(`cwd does not exist: ${dir}`);
|
||||
return;
|
||||
}
|
||||
if (!st.isDirectory()) {
|
||||
gitError(`cwd is not a directory: ${dir}`);
|
||||
return;
|
||||
}
|
||||
const runGit = (args) => execFileSync("git", args, { cwd: dir, encoding: "utf8", timeout: 10_000 }).trim();
|
||||
let toplevel;
|
||||
try {
|
||||
toplevel = runGit(["rev-parse", "--show-toplevel"]);
|
||||
} catch {
|
||||
gitError(`not a git repository: ${dir}`);
|
||||
return;
|
||||
}
|
||||
let branch = null;
|
||||
try {
|
||||
const raw = runGit(["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
branch = raw === "HEAD" ? null : raw; // detached HEAD, or no commits yet
|
||||
} catch {
|
||||
branch = null;
|
||||
}
|
||||
let upstream = null;
|
||||
try {
|
||||
upstream = runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
||||
} catch {
|
||||
/* no upstream configured */
|
||||
}
|
||||
const porcelain = runGit(["status", "--porcelain=v1"]);
|
||||
const clean = porcelain === "";
|
||||
// stable-metadata keeps the six fields the old stub returned, now
|
||||
// populated for real; branch/status return the subset they need.
|
||||
const result =
|
||||
op === "branch"
|
||||
? { branch, upstream }
|
||||
: op === "status"
|
||||
? { branch, upstream, clean, files: clean ? [] : porcelain.split("\n") }
|
||||
: {
|
||||
available: true,
|
||||
branch,
|
||||
upstream,
|
||||
repository: toplevel,
|
||||
state: clean ? "clean" : "dirty",
|
||||
worktree: toplevel,
|
||||
};
|
||||
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ result }));
|
||||
} catch (err) {
|
||||
gitError(err?.message ?? "git error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Diagnostic: what has the app server asked the mock backend for?
|
||||
if (urlPath === "/__codex_mocklog") {
|
||||
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ requests: MOCK_LOG }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Statsig bootstrap. The renderer normally POSTs /wham/statsig/bootstrap to
|
||||
// the ChatGPT backend; the `statsig-bootstrap-local` patch redirects it here.
|
||||
// The response is a valid, empty initialize payload — every gate resolves to
|
||||
// "off" and the SDK completes initialization instead of retrying forever.
|
||||
if (urlPath === "/__codex_statsig_bootstrap") {
|
||||
MOCK_LOG.push(`POST ${urlPath} (statsig bootstrap)`);
|
||||
if (MOCK_LOG.length > 200) MOCK_LOG.shift();
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(405).end("method not allowed");
|
||||
return;
|
||||
}
|
||||
let body = {};
|
||||
try {
|
||||
body = await readJsonBody(req);
|
||||
} catch {
|
||||
/* empty request body is fine */
|
||||
}
|
||||
const stableId = typeof body.stable_id === "string" ? body.stable_id : "codex-web-local";
|
||||
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
// The renderer expects `{ statsigPayload: <string> }` — the payload as a
|
||||
// JSON string, exactly as the desktop main process hands it over.
|
||||
res.end(JSON.stringify({ statsigPayload: JSON.stringify(statsigBootstrapPayload(stableId)) }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Same-origin Statsig event flush: the renderer posts to /ces/v1/rgstr, which
|
||||
// lands here. Accept and drop — nothing is measured, nothing leaves.
|
||||
if (urlPath.startsWith("/ces/")) {
|
||||
res.writeHead(202, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
res.end("{}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Mock model provider (see ./mock-model.mjs). The app server's
|
||||
// model_provider points here, so `turn/start` completions are answered from
|
||||
// this process with a well-formed Responses-API SSE stream. Nothing reaches
|
||||
// the network and the reply renders exactly as a live model's would.
|
||||
if (urlPath === "/mockrelay" || urlPath.startsWith("/mockrelay/")) {
|
||||
serveMockRelay(req, res, urlPath, { onRequest: logMockRequest });
|
||||
return;
|
||||
}
|
||||
|
||||
// Mock ChatGPT backend (see serveMock above).
|
||||
if (urlPath === "/mock" || urlPath.startsWith("/mock/")) {
|
||||
serveMock(req, res, urlPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Event stream. Everything the app server emits on its own initiative —
|
||||
// streaming deltas, turn progress, server-initiated approval requests — comes
|
||||
// back through here. Long-polled so the renderer stays in step without a
|
||||
// websocket.
|
||||
if (urlPath === "/__codex_events") {
|
||||
try {
|
||||
await bridge.ensure();
|
||||
const messages = await bridge.waitForNotifications(15_000);
|
||||
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ events: messages }));
|
||||
} catch (err) {
|
||||
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ events: [], error: String(err?.message ?? err) }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Local JSON-RPC passthrough to the real codex app server.
|
||||
if (urlPath === "/__codex_rpc") {
|
||||
if (req.method !== "POST") {
|
||||
res.writeHead(405).end("method not allowed");
|
||||
return;
|
||||
}
|
||||
let method, params;
|
||||
try {
|
||||
({ method, params } = await readJsonBody(req));
|
||||
if (typeof method !== "string") {
|
||||
res.writeHead(400, { "content-type": MIME[".json"] }).end('{"error":{"message":"missing method"}}');
|
||||
return;
|
||||
}
|
||||
// The desktop main process strips `originator` filtering before it
|
||||
// forwards `thread/list` to the app server — a local app-server rejects
|
||||
// the parameter with -32602. Mirror the sanitisation here.
|
||||
if (method === "thread/list" && params && typeof params === "object") {
|
||||
const strip = (obj) => {
|
||||
if (!obj || typeof obj !== "object") return;
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key === "originator" || key === "originators") delete obj[key];
|
||||
else if (obj[key] && typeof obj[key] === "object") strip(obj[key]);
|
||||
}
|
||||
};
|
||||
strip(params);
|
||||
}
|
||||
await bridge.ensure();
|
||||
const result = await bridge.request(method, params);
|
||||
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ result: result ?? null }));
|
||||
} catch (err) {
|
||||
// A cloud-catalog plugin cannot be fetched in a local build; answer with
|
||||
// a well-formed offline descriptor instead of an error the renderer
|
||||
// would surface as a toast.
|
||||
if (method === "plugin/read" && params && typeof params.pluginName === "string") {
|
||||
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ result: mockRemotePluginRead(params.pluginName) }));
|
||||
return;
|
||||
}
|
||||
const message = err?.rpc?.message ?? err?.message ?? "rpc failed";
|
||||
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
||||
res.end(JSON.stringify({ error: { code: err?.rpc?.code ?? -32000, message } }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const rel = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, "");
|
||||
const abs = path.join(ROOT, rel);
|
||||
|
||||
if (!abs.startsWith(ROOT)) {
|
||||
res.writeHead(403).end("forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
let file = abs;
|
||||
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) {
|
||||
file = path.join(ROOT, "index.html"); // SPA fallback
|
||||
if (!fs.existsSync(file)) {
|
||||
res.writeHead(404).end("not found");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
const type = MIME[ext] || "application/octet-stream";
|
||||
|
||||
try {
|
||||
let body = fs.readFileSync(file);
|
||||
let patched = null;
|
||||
|
||||
if (ext === ".html") {
|
||||
body = Buffer.from(patchHtml(body.toString("utf8")), "utf8");
|
||||
} else if (ext === ".js") {
|
||||
const { out, applied } = patchJs(body.toString("utf8"));
|
||||
if (applied.length) {
|
||||
body = Buffer.from(out, "utf8");
|
||||
patched = applied.join(",");
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"content-type": type,
|
||||
"cache-control": "no-store",
|
||||
...(patched ? { "x-codex-patched": patched } : {}),
|
||||
});
|
||||
res.end(body);
|
||||
} catch (err) {
|
||||
console.error("[web] read error", file, err.message);
|
||||
res.writeHead(500).end("read error");
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`[web] Codex webview served from ${ROOT}`);
|
||||
console.log(`[web] http://127.0.0.1:${PORT}/`);
|
||||
console.log(`[web] app server: ${bridge.binary ?? "not found"}`);
|
||||
console.log(`[web] workspace root: ${WORKSPACE_ROOT}`);
|
||||
console.log(`[web] mock backend: http://127.0.0.1:${PORT}/mock`);
|
||||
});
|
||||
|
||||
// A long-running local host must survive its own bugs: log loudly, keep
|
||||
// serving. Without this one bad request would take the whole app down.
|
||||
process.on("uncaughtException", (err) => {
|
||||
console.error("[web] uncaught:", err?.stack ?? err);
|
||||
});
|
||||
process.on("unhandledRejection", (err) => {
|
||||
console.error("[web] unhandled rejection:", err?.stack ?? err);
|
||||
});
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the standalone original-UI web host in development mode.
|
||||
#
|
||||
# ./start.sh UI :8095
|
||||
# UI_PORT=9000 ./start.sh custom port
|
||||
# node verify.mjs --deep run the self-check in another shell
|
||||
#
|
||||
# The app-server binary ships inside ChatGPT.app; set CODEX_APP_SERVER_BIN
|
||||
# if it lives elsewhere. There is no orchestrator/API layer in this stack.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
UI_PORT="${UI_PORT:-8095}"
|
||||
|
||||
export CODEX_WEB_PORT="$UI_PORT"
|
||||
|
||||
echo "[stack] UI http://127.0.0.1:${UI_PORT}/"
|
||||
echo "[stack] test node ${ROOT}/verify.mjs --deep"
|
||||
|
||||
exec node "$ROOT/host/web-server.mjs" --port "$UI_PORT"
|
||||
Executable
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Self-check for the standalone original-UI web host. Run while ./start.sh is up.
|
||||
* node verify.mjs served assets + HTTP surface
|
||||
* node verify.mjs --deep also drive a real app-server RPC through the UI host
|
||||
*
|
||||
* The orchestrator/API layer is intentionally not part of this stack; nothing
|
||||
* here probes port 8899. Everything the UI needs is served by host/web-server.mjs
|
||||
* on UI_PORT, including the local JSON-RPC passthrough and the mock backends.
|
||||
*/
|
||||
import http from "node:http";
|
||||
|
||||
const uiPort = Number(process.env.UI_PORT || 8095);
|
||||
const deep = process.argv.includes("--deep");
|
||||
|
||||
function request(path, { method = "GET", headers = {}, body = null, timeout = 8000 } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request({ host: "127.0.0.1", port: uiPort, path, method, headers }, (res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => resolve({
|
||||
status: res.statusCode,
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
}));
|
||||
});
|
||||
req.setTimeout(timeout, () => req.destroy(new Error("timeout")));
|
||||
req.on("error", (e) => resolve({ status: 0, body: String(e.message) }));
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function json(r) {
|
||||
try { return JSON.parse(r.body); } catch { return null; }
|
||||
}
|
||||
|
||||
const results = [];
|
||||
async function check(name, fn) {
|
||||
let out;
|
||||
try {
|
||||
out = await fn();
|
||||
} catch (e) {
|
||||
out = { ok: false, detail: String(e.message) };
|
||||
}
|
||||
results.push({ name, ...out });
|
||||
console.log(`${out.ok ? "PASS" : "FAIL"} ${name} ${out.detail ?? ""}`);
|
||||
}
|
||||
|
||||
const post = (path, payload, timeout = 8000) => request(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
timeout,
|
||||
});
|
||||
|
||||
await check("UI GET / 注入两个宿主脚本", async () => {
|
||||
const r = await request("/");
|
||||
const ok = r.status === 200
|
||||
&& r.body.includes("/__codex_local_host.js")
|
||||
&& r.body.includes("/__codex_shim.js");
|
||||
return { ok, detail: `HTTP ${r.status}, ${r.body.length}B` };
|
||||
});
|
||||
|
||||
await check("UI GET /__codex_local_host.js", async () => {
|
||||
const r = await request("/__codex_local_host.js");
|
||||
return {
|
||||
ok: r.status === 200 && r.body.includes("__codexLocalHost"),
|
||||
detail: `HTTP ${r.status}, ${r.body.length}B`,
|
||||
};
|
||||
});
|
||||
|
||||
await check("UI GET /__codex_shim.js", async () => {
|
||||
const r = await request("/__codex_shim.js");
|
||||
return { ok: r.status === 200 && r.body.length > 1000, detail: `HTTP ${r.status}, ${r.body.length}B` };
|
||||
});
|
||||
|
||||
await check("UI 响应层补丁命中 connect-app-host", async () => {
|
||||
const r = await request("/assets/connect-app-host-90a50ca9e4b4.js");
|
||||
return {
|
||||
ok: r.status === 200 && r.body.includes("__codexLocalHost"),
|
||||
detail: `HTTP ${r.status}, ${r.body.length}B`,
|
||||
};
|
||||
});
|
||||
|
||||
await check("UI 原版入口 chunk 可加载", async () => {
|
||||
const r = await request("/assets/index-fcd3fcef6840.js");
|
||||
return { ok: r.status === 200 && r.body.length > 100, detail: `HTTP ${r.status}, ${r.body.length}B` };
|
||||
});
|
||||
|
||||
await check("UI GET /ces/v1/rgstr 遥测丢弃", async () => {
|
||||
const r = await request("/ces/v1/rgstr");
|
||||
return { ok: r.status === 202, detail: `HTTP ${r.status}` };
|
||||
});
|
||||
|
||||
await check("UI GET /__codex_rpc 拒绝非 POST", async () => {
|
||||
const r = await request("/__codex_rpc");
|
||||
return { ok: r.status === 405, detail: `HTTP ${r.status}` };
|
||||
});
|
||||
|
||||
await check("UI POST /__codex_projectless 工作区根", async () => {
|
||||
const r = await post("/__codex_projectless", { op: "readWorkspaceRoot" });
|
||||
const j = json(r);
|
||||
return {
|
||||
ok: r.status === 200 && typeof j?.result?.workspaceRoot === "string",
|
||||
detail: `HTTP ${r.status}, ${j?.result?.workspaceRoot ?? r.body.slice(0, 80)}`,
|
||||
};
|
||||
});
|
||||
|
||||
await check("UI POST /__codex_statsig_bootstrap 空配置", async () => {
|
||||
const r = await post("/__codex_statsig_bootstrap", { stable_id: "verify" });
|
||||
const j = json(r);
|
||||
const gates = json({ body: j?.statsigPayload ?? "" })?.feature_gates;
|
||||
return {
|
||||
ok: r.status === 200 && gates && Object.keys(gates).length === 0,
|
||||
detail: `HTTP ${r.status}, ${r.body.length}B`,
|
||||
};
|
||||
});
|
||||
|
||||
await check("UI GET /mock/wham/tasks/list 云端替身", async () => {
|
||||
const r = await request("/mock/wham/tasks/list");
|
||||
const j = json(r);
|
||||
return {
|
||||
ok: r.status === 200 && Array.isArray(j?.items) && j.items.length === 0,
|
||||
detail: `HTTP ${r.status}, ${r.body.trim()}`,
|
||||
};
|
||||
});
|
||||
|
||||
await check("UI GET /__codex_mocklog 诊断日志", async () => {
|
||||
const r = await request("/__codex_mocklog");
|
||||
const j = json(r);
|
||||
return {
|
||||
ok: r.status === 200 && Array.isArray(j?.requests),
|
||||
detail: `HTTP ${r.status}, ${j?.requests?.length ?? 0} 条`,
|
||||
};
|
||||
});
|
||||
|
||||
if (deep) {
|
||||
await check("UI POST /__codex_rpc -> 真实 app-server thread/list", async () => {
|
||||
const r = await post("/__codex_rpc", { method: "thread/list", params: { limit: 1 } }, 45000);
|
||||
const j = json(r);
|
||||
const ok = r.status === 200 && j && !j.error && j.result != null;
|
||||
return { ok: !!ok, detail: `HTTP ${r.status}, ${r.body.slice(0, 140)}` };
|
||||
});
|
||||
|
||||
await check("UI POST /__codex_rpc -> config/read 含 relay 覆盖", async () => {
|
||||
const r = await post("/__codex_rpc", { method: "config/read", params: {} }, 45000);
|
||||
const j = json(r);
|
||||
const model = j?.result?.config?.model;
|
||||
return {
|
||||
ok: r.status === 200 && typeof model === "string" && model.length > 0,
|
||||
detail: `HTTP ${r.status}, model=${model ?? "n/a"}`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const failed = results.filter((r) => !r.ok);
|
||||
console.log(`\n${results.length - failed.length}/${results.length} passed`);
|
||||
console.log("orchestrator 已移除;本栈只有 :8095 一个服务,无 :8899 API。");
|
||||
if (!deep) console.log("shallow run: pass --deep to exercise the real app-server RPC");
|
||||
process.exit(failed.length ? 1 : 0);
|
||||
+56
-12
@@ -54,6 +54,7 @@ from collections import deque
|
||||
from decimal import Decimal
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
import urllib.request
|
||||
|
||||
from cloud.agent_def import default_agents_dir as _agent_default_dir
|
||||
from cloud.agent_def import list_known_skills as _agent_known_skills
|
||||
@@ -1042,6 +1043,28 @@ def _db_backend_name():
|
||||
return (os.environ.get("CLOUD_DB_BACKEND") or "sqlite").strip().lower()
|
||||
|
||||
|
||||
def _codex_ui_url():
|
||||
"""原生 UI host 地址:环境变量 CODEX_UI_URL,未设则默认本机 :8095。"""
|
||||
return os.environ.get("CODEX_UI_URL") or "http://127.0.0.1:8095/"
|
||||
|
||||
|
||||
def _codex_ui_reachable():
|
||||
"""探测 UI host 是否可达:GET <CODEX_UI_URL 的 origin>/,2 秒超时。
|
||||
|
||||
任一异常(连接失败、超时、DNS 失败等)或非 200 一律视为不可达,
|
||||
调用方回落旧控制台;探测失败绝不影响 /console 本身的响应。"""
|
||||
try:
|
||||
parsed = urlparse(_codex_ui_url())
|
||||
if not parsed.netloc:
|
||||
return False
|
||||
probe = "{}://{}/".format(parsed.scheme or "http", parsed.netloc)
|
||||
request = urllib.request.Request(probe, method="GET")
|
||||
with urllib.request.urlopen(request, timeout=2) as resp:
|
||||
return resp.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _deployed_commit():
|
||||
"""当前上线代码的 commit:deploy.py 部署时在包根写的 DEPLOYED_COMMIT。
|
||||
|
||||
@@ -2329,6 +2352,29 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
|
||||
self._responded = True
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_redirect(self, location):
|
||||
self.send_response(302)
|
||||
self.send_header("Location", location)
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
# 发头即算已响应(与 _send_json/_send_html 同款约定)。
|
||||
self._responded = True
|
||||
|
||||
def _serve_console_classic(self):
|
||||
# 旧 React 控制台(cloud/console.html):只出静态壳,不走鉴权
|
||||
# 与限流(与 /health 同款 pre-auth 待遇)。壳里没有任何数据;
|
||||
# 数据一律凭用户在浏览器里填的 X-API-Key 调接口,key 只存在
|
||||
# 用户浏览器 localStorage。页面文件与 server.py 同目录,改动
|
||||
# 随部署走。/console(UI host 不可达时回落)与 /console-classic
|
||||
# 共用本方法,不要复制两份读文件代码。
|
||||
try:
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
with open(os.path.join(here, "console.html"),
|
||||
"r", encoding="utf-8") as f:
|
||||
self._send_html(200, f.read())
|
||||
except OSError:
|
||||
self._send_json(404, {"error": "console not found"})
|
||||
|
||||
def _read_body(self):
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
@@ -2616,18 +2662,16 @@ class CloudControlPlaneHandler(OAuthMixin, IdentityMemoryMixin, ProjectMixin, Au
|
||||
"commit": _deployed_commit(),
|
||||
})
|
||||
return
|
||||
if path == "/console" and method == "GET":
|
||||
# 命令台 v1:只出静态壳,不走鉴权与限流(与 /health 同款
|
||||
# pre-auth 待遇)。壳里没有任何数据;数据一律凭用户在
|
||||
# 浏览器里填的 X-API-Key 调接口,key 只存在用户浏览器
|
||||
# localStorage。页面文件与 server.py 同目录,改动随部署走。
|
||||
try:
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
with open(os.path.join(here, "console.html"),
|
||||
"r", encoding="utf-8") as f:
|
||||
self._send_html(200, f.read())
|
||||
except OSError:
|
||||
self._send_json(404, {"error": "console not found"})
|
||||
if path in ("/console", "/console-classic") and method == "GET":
|
||||
# /console:原生 UI 入口(见 docs/codex-native-ui-alignment.md
|
||||
# §3):UI host 可达时 302 到 CODEX_UI_URL(默认
|
||||
# http://127.0.0.1:8095/),不可达时回落旧控制台;
|
||||
# /console-classic 永远 serve 旧控制台。两者都不走鉴权与
|
||||
# 限流(与 /health 同款 pre-auth 待遇)。
|
||||
if path == "/console" and _codex_ui_reachable():
|
||||
self._send_redirect(_codex_ui_url())
|
||||
else:
|
||||
self._serve_console_classic()
|
||||
return
|
||||
if path.startswith("/oauth/"):
|
||||
# OAuth 登录/回调是 pre-auth 端点(用户还没本站 API key,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Codex 原生 UI host 入仓清单单测(见 docs/codex-native-ui-alignment.md §4 第 4 项)。
|
||||
|
||||
只做静态断言,不起服务、不调网络、不读密钥:
|
||||
- appserver-methods.json 是合法 JSON、179 个方法、无重复;
|
||||
- app-server-bridge.mjs 里 7 个仿真方法都在(读文件文本断言方法名出现);
|
||||
- 仓内无 cloud/codex-ui-host/original/ 目录(650MB 原版栈不入仓);
|
||||
- scripts/run-codex-ui.sh 存在且可执行。
|
||||
|
||||
路径一律以仓库根(本文件向上两级)为基准,不写死绝对路径。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
|
||||
REPO_ROOT = os.path.dirname(os.path.dirname(
|
||||
os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# §4 适配 A5:150 个 UI 清单方法 + 29 个实验协议方法 = 179。
|
||||
EXPECTED_METHOD_COUNT = 179
|
||||
|
||||
# §4 适配 A1–A4:桥接层仿真的 7 个缺失方法。
|
||||
SIMULATED_METHODS = [
|
||||
"thread/stop",
|
||||
"thread/rollback",
|
||||
"thread/startAeon",
|
||||
"interactive/liveSessions/list",
|
||||
"interactive/session/upload",
|
||||
"interactive/sessionSandbox/list",
|
||||
"interactive/sessionSandbox/read",
|
||||
]
|
||||
|
||||
|
||||
class TestAppserverMethodsJson(unittest.TestCase):
|
||||
def test_valid_json_179_methods_no_duplicates(self):
|
||||
path = os.path.join(
|
||||
REPO_ROOT, "cloud", "codex-ui-host", "host",
|
||||
"appserver-methods.json")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
methods = json.load(f)
|
||||
self.assertIsInstance(methods, list)
|
||||
self.assertEqual(len(methods), EXPECTED_METHOD_COUNT)
|
||||
self.assertTrue(all(isinstance(m, str) for m in methods))
|
||||
self.assertEqual(len(set(methods)), len(methods),
|
||||
"appserver-methods.json must not list a method twice")
|
||||
|
||||
|
||||
class TestAppServerBridgeSimulations(unittest.TestCase):
|
||||
def test_seven_simulated_methods_present(self):
|
||||
path = os.path.join(
|
||||
REPO_ROOT, "cloud", "codex-ui-host", "host",
|
||||
"app-server-bridge.mjs")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
for name in SIMULATED_METHODS:
|
||||
self.assertIn(name, text,
|
||||
"%s must be simulated in app-server-bridge.mjs"
|
||||
% name)
|
||||
|
||||
|
||||
class TestOriginalNotVendored(unittest.TestCase):
|
||||
def test_no_original_directory_in_repo(self):
|
||||
self.assertFalse(
|
||||
os.path.exists(os.path.join(
|
||||
REPO_ROOT, "cloud", "codex-ui-host", "original")),
|
||||
"cloud/codex-ui-host/original/ must not be vendored "
|
||||
"(650MB original stack stays out of the repo)")
|
||||
|
||||
|
||||
class TestRunScript(unittest.TestCase):
|
||||
def test_run_script_exists_and_is_executable(self):
|
||||
path = os.path.join(REPO_ROOT, "scripts", "run-codex-ui.sh")
|
||||
self.assertTrue(os.path.isfile(path),
|
||||
"scripts/run-codex-ui.sh must exist")
|
||||
self.assertTrue(os.access(path, os.X_OK),
|
||||
"scripts/run-codex-ui.sh must be executable")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,8 +1,14 @@
|
||||
"""命令台 /console 单测:只验页面壳与路由,不跑浏览器 JS。
|
||||
|
||||
页面本身不走鉴权(只是个壳),数据一律凭 X-API-Key 调接口,
|
||||
所以 /console 无 key 也应 200 出 HTML;真正的鉴权由各 API
|
||||
端点负责(已有单测覆盖)。
|
||||
/console 是原生 UI 入口(见 docs/codex-native-ui-alignment.md §3):
|
||||
UI host 可达时 302 到 CODEX_UI_URL,不可达时回落旧控制台;
|
||||
/console-classic 永远 serve 旧控制台。两者都不走鉴权(只是个
|
||||
壳),数据一律凭 X-API-Key 调接口,所以无 key 也应 200 出 HTML;
|
||||
真正的鉴权由各 API 端点负责(已有单测覆盖)。
|
||||
|
||||
默认测试环境没有 UI host 在跑,所以 /console 的断言是回落行为;
|
||||
302 行为由 test_console_redirects_to_ui_host_when_reachable 用
|
||||
测试内起的假 UI host 单独覆盖。
|
||||
"""
|
||||
import http.client
|
||||
import os
|
||||
@@ -10,7 +16,9 @@ import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
try:
|
||||
from cloud.auth import AuthStore
|
||||
@@ -32,6 +40,13 @@ class ConsoleTestCase(unittest.TestCase):
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
# 回落断言必须与环境无关:显式把 UI host 指到一个不可能有
|
||||
# 监听的端口(127.0.0.1:1 立即 connection refused),而不是
|
||||
# 赌测试机 :8095 上恰好没服务;302 测试在内层再覆盖它。
|
||||
self._env_patch = mock.patch.dict(
|
||||
os.environ, {"CODEX_UI_URL": "http://127.0.0.1:1/"})
|
||||
self._env_patch.start()
|
||||
self.addCleanup(self._env_patch.stop)
|
||||
|
||||
def tearDown(self):
|
||||
self.server.shutdown()
|
||||
@@ -56,10 +71,47 @@ class ConsoleTestCase(unittest.TestCase):
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_console_serves_html_without_key(self):
|
||||
def test_console_falls_back_to_classic_when_ui_host_unreachable(self):
|
||||
# UI host 不可达时 /console 回落旧控制台:无 key 200 出 HTML。
|
||||
status, headers, body = self._get("/console")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("text/html", headers.get("Content-Type", ""))
|
||||
self.assertIn("Codex 命令台", body.decode("utf-8"))
|
||||
|
||||
def test_console_redirects_to_ui_host_when_reachable(self):
|
||||
# 假 UI host:探测只看 GET / 是否 200,内容不重要。
|
||||
class _FakeUIHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
body = b"ok"
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
fake = ThreadingHTTPServer(("127.0.0.1", 0), _FakeUIHandler)
|
||||
thread = threading.Thread(
|
||||
target=fake.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
ui_url = "http://127.0.0.1:{}/".format(
|
||||
fake.server_address[1])
|
||||
with mock.patch.dict(os.environ, {"CODEX_UI_URL": ui_url}):
|
||||
status, headers, _body = self._get("/console")
|
||||
self.assertEqual(status, 302)
|
||||
self.assertEqual(headers.get("Location"), ui_url)
|
||||
finally:
|
||||
fake.shutdown()
|
||||
fake.server_close()
|
||||
thread.join(timeout=5)
|
||||
|
||||
def test_console_classic_serves_html_without_key(self):
|
||||
status, headers, body = self._get("/console-classic")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("text/html", headers.get("Content-Type", ""))
|
||||
text = body.decode("utf-8")
|
||||
self.assertIn("Codex 命令台", text)
|
||||
# 壳必须引用它实际调的接口,防 JS 与服务端路由漂移。
|
||||
@@ -95,9 +147,10 @@ class ConsoleTestCase(unittest.TestCase):
|
||||
|
||||
def test_console_does_not_leak_data_without_key(self):
|
||||
# 壳里只有静态文本,没有任何任务/项目数据(数据须凭 key 调 API)。
|
||||
_status, _headers, body = self._get("/console")
|
||||
text = body.decode("utf-8")
|
||||
self.assertNotIn(self.owner_id, text)
|
||||
for path in ("/console", "/console-classic"):
|
||||
_status, _headers, body = self._get(path)
|
||||
text = body.decode("utf-8")
|
||||
self.assertNotIn(self.owner_id, text)
|
||||
|
||||
def test_console_init_loads_relay_models(self):
|
||||
# 回访用户 key 已在 localStorage 里:初始化序列必须含
|
||||
@@ -106,8 +159,9 @@ class ConsoleTestCase(unittest.TestCase):
|
||||
# React 版(2026-10-29 起):旧单页的 setKeyState/loadHealth
|
||||
# 序列已不存在,初始化在 useConsoleStore 的 useEffect 里,
|
||||
# 有 key 时调 loadRelayModels;断言意图不变,只换成
|
||||
# React 构建产物里实际存在的标记。
|
||||
_status, _headers, body = self._get("/console")
|
||||
# React 构建产物里实际存在的标记。断言对象是旧控制台壳,
|
||||
# 所以走 /console-classic(/console 只在回落时才出这个壳)。
|
||||
_status, _headers, body = self._get("/console-classic")
|
||||
text = body.decode("utf-8")
|
||||
self.assertIn("loadRelayModels", text)
|
||||
self.assertIn("/relay/models", text)
|
||||
|
||||
@@ -148,7 +148,10 @@ class TestMethodAccounting(unittest.TestCase):
|
||||
# 增量全是 main 侧方法(_routing_shadow、_resolve_create_
|
||||
# agent_definition、_validate_create_audit_loop),源线方法
|
||||
# 零丢失、mixin 侧零增减;此为源线 34b9a34 同款 re-pin。
|
||||
def test_total_247_no_duplicates(self):
|
||||
# 补漏注:codex 原生 UI 接线新增 2 个 server.py 自有方法
|
||||
# (_send_redirect、_serve_console_classic),总数 247 -> 249、
|
||||
# server.py 自有 153 -> 155;mixin 侧零增减,源线方法零丢失。
|
||||
def test_total_249_no_duplicates(self):
|
||||
seen = {}
|
||||
dups = []
|
||||
classes = [CloudControlPlaneHandler]
|
||||
@@ -163,9 +166,9 @@ class TestMethodAccounting(unittest.TestCase):
|
||||
dups.append((seen[name], cls.__name__, name))
|
||||
seen[name] = cls.__name__
|
||||
self.assertEqual(dups, [])
|
||||
self.assertEqual(total, 247)
|
||||
self.assertEqual(total, 249)
|
||||
self.assertEqual(len(_own_methods(CloudControlPlaneHandler)),
|
||||
247 - 94)
|
||||
249 - 94)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Codex 原生 UI ↔ codex-app-server 双向对齐表
|
||||
|
||||
- 基线:Gitea main `37ba6876bf326ebd6b1e01ae7d2c5684dafe439b`(2026-11-06,线上 code.tose.top 生产基线)
|
||||
- 工作区:`~/workspace/codex-app-server-codex-ui`,分支 `feat/codex-native-ui`
|
||||
- UI 栈:`~/workspace/user/downloads/codex-ui-stack/`(zip sha256 `668bcd82…3125e`);`original/` 2026-10-27 自 `/tmp/codex-asar/webview` 原样 vendored(20918 文件,`index.html` sha `85e5c5b4…`),**必须保持字节一致**,改动只发生在 `host/` 响应层与 app-server 侧
|
||||
- 清点依据:A(UI host 清点)、C(codex 0.164.0-alpha.9 全量 150 方法实测探活)、D(37ba687 基线重核:控制面 **179 个端点**,控制台为 `cloud/console-react/` 源码构建的 353KB React 包)。B 路按旧分支 5ba4d35 清点的数字(60 端点、820 行 console、resident 为 0)**在本基线全部作废**,未采用。
|
||||
- codex 二进制:`codex-cli 0.164.0-alpha.9`(`/opt/hatch-image/bin/codex`)
|
||||
- 约定:凡原生 UI 没有对应表面可承载某功能时,本表如实写“原生无对应表面”,不自造页面/组件。
|
||||
|
||||
## 集成架构(结论先行)
|
||||
|
||||
1. **线程共享是集成点**:`host.py` 一个 app-server 进程托管多 thread,线程状态持久化在 `CODEX_HOME`(默认 `~/.codex`)。UI host 的 `app-server-bridge.mjs` 自己 spawn 一个 app-server,但因共享同一 CODEX_HOME,`thread/list` 能看到 host.py 任务的 thread(C 实测:`thread/list` 返回了既有 thread)。因此 UI 不需要直连 host.py 的 app-server 进程,也不需要新增 socket/ws 附着逻辑。
|
||||
2. **入口替换方式**:`GET /console` 改为按需 302 到 UI host(默认 `http://127.0.0.1:8095/`,可配 `CODEX_UI_URL`);UI host 不可达时回落到旧控制台。旧 React 控制台**保留**,新路由 `GET /console-classic` 继续 serve `cloud/console.html`(理由见 §3)。
|
||||
3. **original/ 不进仓**:解压后 650MB / 20918 文件,不可提交。入仓的只有 `host/` 适配层(约 104KB)与启动脚本;`original/` 由 `CODEX_UI_ORIGINAL_ROOT` 指向已下载的栈目录,部署时以 zip sha256 校验(见 §4)。
|
||||
4. **模型走 relay,不走 mock**:栈自带的 `relay-config.json` 里 `baseUrl` 指向本地 mock(`http://127.0.0.1:8095/mockrelay/v1`),真实 Bumian 地址只写在 JSON 里、host 代码无引用,`apiKey` 为空。当前配置下模型调用走确定性 mock 替身。接真实 relay 须把 `baseUrl` 改为 `relayBaseUrl` 并经环境变量给 key(只写 envKey 名,key 值绝不入文件)。
|
||||
|
||||
---
|
||||
|
||||
## A 表——UI 要、codex-app-server / 官方 app-server 缺的
|
||||
|
||||
| # | UI 需要的方法/功能 | 缺口现状(实测) | 补齐方案与落点 |
|
||||
|---|---|---|---|
|
||||
| A1 | `thread/stop` | 官方 0.164.0-alpha.9 不存在(`unknown variant`,C 实测;实验 schema 枚举 172 方法里也没有它) | 桥接层仿真:`app-server-bridge.mjs` 拦截 `thread/stop`,转调 `turn/interrupt`(host.py:939 已确认:对齐 Codex App 停止按钮就是发 `turn/interrupt`)。落点:入仓的 `cloud/codex-ui-host/host/app-server-bridge.mjs` |
|
||||
| A2 | `thread/rollback` | 同上,不存在 | 桥接层仿真:转调 `thread/revert`(实测存在)。同上落点 |
|
||||
| A3 | `thread/startAeon` | 同上,不存在 | 桥接层仿真:转调 `thread/start`(Aeon 为实验模式,本二进制无此入口;降级为普通 start,并在方法注释里写明)。同上落点 |
|
||||
| A4 | `interactive/liveSessions/list`、`interactive/session/upload`、`interactive/sessionSandbox/list`、`interactive/sessionSandbox/read`(4 个) | 同上,均不存在 | 桥接层仿真:`list` 类回空列表、`upload`/`read` 回结构化 `-32601 method not found (interactive sessions not available in codex 0.164.0-alpha.9)`。不伪造会话数据。UI 的 live-session 面若调用,即表现为“无 live 会话”,不静默成功。落点同上 |
|
||||
| A5 | 实验协议 29 个方法(UI 清单漏列,二进制实测 schema 里有) | UI 清单只有 150 个;`local-host.js` 按清单动态生成 services,漏列的 29 个 UI 调不到。29 个:`account/bedrock/checkGovCloudRequirements`、`account/sendAddCreditsNudgeEmail`、`account/workspaceMessages/read`、`command/exec/resize|terminate|write`、`environment/add`、`environment/status`、`externalAgentConfig/import/recordHistory`、`plugin/share/checkout`、`server/diagnostics`、`thread/backgroundTerminals/list|terminate`、`thread/decrement_elicitation|increment_elicitation`、`thread/items/read`、`thread/prediction/request`、`thread/readState/update`、`thread/realtime/appendAudio|appendSpeech|appendText|listVoices|start|stop`、`userVerification/cancel|delete|enroll|status|verify` | 把 29 个追加进 `appserver-methods.json`(150 → 179 个,与控制面端点数巧合相同,无关联)。只加清单、不改 `original/`。其中 `realtime/*` 与 `userVerification/*` 在本机未逐个探活,标注“仅 schema 有名,未探活”。落点:入仓的 `cloud/codex-ui-host/host/appserver-methods.json` |
|
||||
| A6 | 桥接在 Linux 上找到 codex 二进制 | `app-server-bridge.mjs` 的默认候选只有 `$CODEX_APP_SERVER_BIN` 与两个 macOS ChatGPT 内路径;在 Linux 上不设环境变量即报 `codex binary not found`(C 实测) | 入仓的 bridge 增加候选:`$CODEX_BIN`(host.py 同款环境变量名)→ `/opt/hatch-image/bin/codex` → PATH 里的 `codex`。落点同 A1 |
|
||||
| A7 | UI 要 `git.request("stable-metadata")`(Send 门控靠它非空) | `local-host.js` 里 git 是 stub,固定回 `{available:false, …}`,其余回 null | 桥接层补只读 git 能力:`local-host.js` 的 `git.request` 改为调 host 新增端点 `POST /__codex_git`(只接受 `stable-metadata` / `branch` / `status` 三种只读 op,在给定 cwd 内跑 `git` 只读命令,拒绝写操作与 cwd 外路径)。落点:入仓的 `cloud/codex-ui-host/host/local-host.js` + `web-server.mjs` |
|
||||
| A8 | UI 要终端面 | `local-host.js:274` 把 `terminal` 服务显式置 null;进程类能力只剩 app-server 的 `process/*` / `thread/shellCommand` | 不补终端面:原生 UI 的终端服务在本栈是 stub,app-server 的 `process/spawn|writeStdin|resizePty|kill` 实测存在(缺参但方法存在),UI 若经 `process/*` 调用即走真实进程。实现时核实 UI 是否调用 `terminal` 服务:若调用,记为“原生终端面在本栈不可用”,不造终端组件。落点:验证阶段核实,不写代码 |
|
||||
| A9 | UI 要账号 / 用量 / 远控 | `account/rateLimits/read`、`account/usage/read`、`plugin/share/list`、`remoteControl/enable|disable|pairing/start` 需 ChatGPT 账号登录(C 实测);当前 provider(relay/apikey)不用 gateway OAuth,`account/gatewayOAuth/login` 不可用 | **不可补**:这是 provider/账号限制,不是代码缺口。UI 的 `mode-decide` 改写已强制跳过登录直进 app,本栈账号面实际不可达。实现不改动,验收时以实测报错为准 |
|
||||
| A10 | UI host 无鉴权 vs 控制面要求 `X-API-Key` | UI host 全部端点同源、无鉴权,只绑 `127.0.0.1:8095`;控制面除 `/health`、`/console`、`/oauth/*`、`/webhooks/*` 外一律要求 key | 不给 UI host 加自造登录面:保持 loopback-only 绑定;`/console` 的 302 只发生在控制面已鉴权(或本机回环)的请求之后——实现方案:`cloud/server.py` 的 `/console` 在 302 前先做一次 UI host 健康探测(`GET /` 2 秒超时),探测失败则回落旧控制台;UI host 本身不暴露到外网。密钥不参与 UI host 鉴权 |
|
||||
| A11 | UI 要 `config/read` 含 relay 覆盖、`model/list` | C 实测均 ok;`config/read` 返回 `model=gpt-6.1-sol`、`model_provider=openkun`(与本机 `~/.codex/config.toml` 一致);`model/list` 仅一条 | 无缺口:桥接的 `-c` 覆盖(`relayArgs`)保持原样;`verify.mjs --deep` 第 13 项即校验此点。实现不改动 |
|
||||
|
||||
## B 表——app-server 有、UI 缺的(控制面 179 个端点 → 原生 UI 表面)
|
||||
|
||||
控制面分组(D 实测,179 = 任务 66 + 项目/环境/上传 17 + 租户配额/管理 11 + OAuth/身份/key 14 + GitHub App/webhook 7 + resident 7 + automations 10 + 记忆/KB/成本/推送 23 + worker/监控/模型 10 + 其它 full-auto 9 + todos 5):
|
||||
|
||||
| # | 控制面功能(端点) | 原生 UI 对应表面 | 结论 |
|
||||
|---|---|---|---|
|
||||
| B1 | 任务创建 / 列表 / 详情(`POST /tasks`、`GET /tasks`、`GET /tasks/{id}`) | 线程列表(`thread/list`)与线程阅读(`thread/read`、`thread/items/list`)是**线程**,不是**任务**;任务含 prompt/执行器/模型/goal/分支/worktree 等宿主字段,原生 UI 无任务队列面 | 原生无对应表面(任务列表、建任务表单)。任务的线程内容可经 `thread/read`、`thread/items/list` 看到,属部分映射,不等同 |
|
||||
| B2 | 任务操作:取消/暂停/重试/合并/发布/提 PR/分叉/继续(`POST /tasks/{id}/{cancel,pause,retry,merge,release,pr,fork,continue}`) | 取消→`turn/interrupt`(+ A1 的 `thread/stop` 仿真);重试/继续→`thread/resume`、`thread/start`;分叉→`thread/fork` | 部分映射:cancel/retry/fork/continue 有方法级对应;pause/release/merge/pr **原生无对应表面**(合并与 PR 是宿主 Git 流程,原生 UI 无此概念) |
|
||||
| B3 | 任务 trace(`GET /tasks/{id}/trace`、`/trace/stream` SSE)、时间线、成本、结果全文、agents、文件/目录树(`/files`、`/tree`) | `thread/timeline/list`(trace 的部分映射)、`thread/items/list`(内容)、`fs/readDirectory`、`fs/readFile`(文件浏览的部分映射) | 部分映射:trace/时间线/文件有近似方法;成本(`GET /tasks/{id}/cost`)、agents 分工、SSE trace 流 **原生无对应表面** |
|
||||
| B4 | 任务线程管理(`/threads`、`/thread`、`/thread/{rename,archive,unarchive,compact,message}`、`/chat-history`、`/messages`、followups、reviews、approvals、handoff、refs、worktree、audit-loop、goal、sleep/wait) | `thread/name/set`、`thread/archive|unarchive`、`thread/compact/start`、`thread/items/list`、`review/start`、`thread/approveGuardianDeniedAction` | 部分映射:改名/归档/压缩/评审启动/审批有方法级对应;followups、handoff、refs、worktree 快照、audit-loop 裁决/修复、goal 续跑、sleep/wait、任务间 messages **原生无对应表面** |
|
||||
| B5 | 浏览器远控(`/tasks/{id}/browser/{state,stream,input,control}`) | 无。原生 UI 的浏览器面是它自己的渲染面,不接管外部浏览器 | 原生无对应表面 |
|
||||
| B6 | 项目与成员(`POST/GET /projects`、`PATCH /projects/{id}`、`/projects/{id}/members`) | `project/list|read|create|import|update|delete|move` 是 codex 本地工程目录,不是云端多租户项目;成员/角色模型(viewer/contributor/maintainer/owner)原生 UI 没有 | 原生无对应表面(云端项目制与成员管理) |
|
||||
| B7 | 环境(`POST/GET /environments`、`/environments/{id}/archive`) | `environment/info` 是单机执行环境信息,不是“项目仓库命名基准检出” | 原生无对应表面 |
|
||||
| B8 | 租户配额 / 管理(`GET /users/me/quota`、`/admin/quotas`、`/admin/sweep`、`/admin/backup`、`/admin/audit-ledger/*`、`/admin/push/dispatch`、`/admin/cost-by-creator`、`/admin/ops/approval-timeout`、`/users/me/cost`) | `account/usage/read`、`account/rateLimits/read` 需 ChatGPT 登录且不可用(A9);其余无 | 原生无对应表面 |
|
||||
| B9 | OAuth / 身份 / API key(`/oauth/{provider}/*`、`/users/me/keys`、`/users/me/profile`、`/users/me/gateway/rotate`、`/github-app/*`) | `account/login/start`、`account/logout` 存在,但本栈 `mode-decide` 改写跳过登录直进 app,账号面实际不可达;key 管理无 | 原生无对应表面(OAuth 与本站 key 管理) |
|
||||
| B10 | GitHub webhook / Slack webhook(`POST /webhooks/github|slack`)、GitHub 仓库列表(`GET /github/repos`) | 无 | 原生无对应表面 |
|
||||
| B11 | resident 常驻监听(7 个端点,`POST/GET/PATCH /projects/{id}/resident-watches`、pause/resume/end) | 无。控制台 `console-react/src/` 全文 0 处 `resident`,连旧控制台都没有这个面 | 原生无对应表面 |
|
||||
| B12 | automations 与分诊(10 个端点,`/projects/{id}/automations`、bind/heartbeat/cancel、`/triage`) | 无。`automations.py` 的自动化是云端定时建任务;原生 UI 无自动化面(host.py 的本地 automations 是另一套) | 原生无对应表面(bind/heartbeat 连旧控制台都没调) |
|
||||
| B13 | full-auto(9 个端点,`/full-auto/{status,config-plan,validate-action,merge-gate,ci-fix,release-plan,rollback-plan,execute,audit}`) | 无 | 原生无对应表面 |
|
||||
| B14 | 项目记忆 / KB / 成本 / 推送(23 个端点,`/projects/{id}/{memory,kb,cost,push/subscriptions}`、`/users/me/memories`、`/users/me/identity-context`、`/users/me/auto-memory*`) | `memory/status|reset` 是 codex 本地记忆状态,不是项目记忆库;`skills/list` 与项目技能清单(`GET /projects/{id}/skills`)部分对应 | 部分映射:项目技能→`skills/list`;其余(项目记忆/KB/成本/推送订阅/自动记忆)**原生无对应表面** |
|
||||
| B15 | 技能 / 应用 / MCP(`GET /projects/{id}/{skills,apps,mcp-servers}`、`POST …/mcp-servers/reload|oauth-login`) | `skills/list`、`plugin/list|installed`、`app/list|read`、`mcpServerStatus/list`、`mcpServer/tool/call`、`config/mcpServer/reload` | 覆盖:技能/插件/应用/MCP 有方法级对应(`plugin/read` 失败时 host 有离线回落) |
|
||||
| B16 | worker / 监控 / 模型(`GET /workers`、`/workers/heartbeat`、`/metrics`、`/metrics-report`、`/audit-scorecard`、`/routing-shadow`、`/models`、`/relay/models`) | `model/list`、`modelProvider/capabilities/read` 覆盖模型面;其余无 | 部分映射:模型列表有对应;worker 池、指标、评分卡、路由影子 **原生无对应表面** |
|
||||
| B17 | 待办(5 个端点,`/projects/{id}/todos`) | 无 | 原生无对应表面 |
|
||||
| B18 | 模型总览与中转模型列表(`GET /models`、`GET /relay/models`) | `model/list`(C 实测仅一条 `gpt-6.1-sol`) | 部分映射:单模型列表有对应;中转站多模型下拉无原生面(旧控制台用 `/relay/models` 做模型下拉,新 UI 用 `model/list`) |
|
||||
|
||||
## §3 旧 console.html 的去留决定
|
||||
|
||||
**决定:保留旧控制台,改路由为 `GET /console-classic`,`GET /console` 改为新 UI 入口(302 到 UI host,不可达时回落旧控制台)。**
|
||||
|
||||
理由:
|
||||
1. 旧控制台是 React 构建产物(`cloud/console-react/`,46 个源码文件),承载 B 表里大量“原生无对应表面”的管理面(配额/管理、resident、automations、full-auto、审计、成员、待办等)。悄悄删除等于删除功能,违反“不删除较新工作”。
|
||||
2. 原生 UI 是单用户线程界面,无法承载多租户控制面;“替换”指的是默认入口,不是不留后路。
|
||||
3. `test_console.py`(5 个测试)只验壳与路由,不跑浏览器 JS;路由改动须同步更新这 5 个测试(`/console` 无 key 200 出 HTML 的断言要改为 302/回落行为,`/console-classic` 承接原断言),并新增 UI host 相关测试。
|
||||
|
||||
## §4 入仓清单(实现范围)
|
||||
|
||||
1. `cloud/codex-ui-host/`:自 `~/workspace/user/downloads/codex-ui-stack/` 拷入 `host/` 全部 12 个文件 + `start.sh` + `verify.mjs`(原样,改动只在下述适配处;`original/` 不入仓)。
|
||||
- 适配 A1–A4:`app-server-bridge.mjs` 增加 7 个缺失方法的仿真分支。
|
||||
- 适配 A5:`appserver-methods.json` 追加 29 个方法(150 → 179)。
|
||||
- 适配 A6:`app-server-bridge.mjs` 增加 `$CODEX_BIN`、`/opt/hatch-image/bin/codex`、PATH 候选。
|
||||
- 适配 A7:`local-host.js` 的 `git.request` 走新增 `POST /__codex_git`(只读三 op);`web-server.mjs` 增加该端点。
|
||||
- 适配:`relay-config.json` 保持栈自带 Bumian 配置原样(`apiKey` 为空),另加 `relay-config.README.md` 说明 mock 现状与接真实 relay 的两步(`baseUrl` 改为 `relayBaseUrl` + 设 `BUMIAN_API_KEY` 环境变量);不换 provider、不写 key。
|
||||
- 适配:`projectless.mjs` 的默认工作区根在 Linux 上改为 `$CODEX_WEB_WORKSPACE_ROOT` 或 `~/Codex`(原 `~/Documents/Codex` 在服务器上不存在)。
|
||||
2. `cloud/server.py`:`GET /console` 改为 302 到 `CODEX_UI_URL`(默认 `http://127.0.0.1:8095/`),2 秒探测失败回落旧控制台;新增 `GET /console-classic` serve `cloud/console.html`(原 `/console` 行为整体平移)。
|
||||
3. `scripts/run-codex-ui.sh`(新建):启动 UI host 的唯一入口,设置 `CODEX_APP_SERVER_BIN`、`CODEX_UI_ORIGINAL_ROOT`(默认已下载的栈目录),`exec node cloud/codex-ui-host/host/web-server.mjs --port ${UI_PORT:-8095}`。
|
||||
4. 测试:`cloud/tests/test_console.py` 按 §3 更新;新增 `cloud/tests/test_codex_ui_host.py`(Python 侧:方法表计数 179、7 个仿真方法在 bridge 里存在、original/ 未被入仓——即仓内无 `cloud/codex-ui-host/original/`、启动脚本存在且可执行)。
|
||||
5. 验证:`python3 run_tests.py cloud` 相关子集 + `node verify.mjs`(shallow)+ `node verify.mjs --deep`(真实 app-server 可用时)+ 真实联调冒烟(起 :8095,`thread/list` 与等价调用,日志为证)。
|
||||
|
||||
## §4.1 实现注记(实现后补记,与规范同等效力)
|
||||
|
||||
- A5:`appserver-methods.json` 是纯 JSON,加文件头注释会破坏合法性,故注释未写进 JSON 本身;“`realtime/*` 与 `userVerification/*` 仅 schema 有名、未逐个探活”记在这里(本节),与 A5 表格同效。
|
||||
- A4 口径:以本表 A4 为准——`list` 类(`interactive/liveSessions/list` 与 `interactive/sessionSandbox/list`)回空列表;`upload` 与 `sessionSandbox/read` 回结构化 `-32601`。实现初版曾把 `sessionSandbox/list` 划入 `-32601` 组,协调员已按本表纠正。
|
||||
- `web-server.mjs` 的 `--root` 默认值增加了 `CODEX_UI_ORIGINAL_ROOT` 回落(1 行):不加则启动脚本设该变量无效;接受该改动,启动脚本无需改传 `--root`。
|
||||
|
||||
## §5 剩余缺口(实现后仍存在,如实记录)
|
||||
|
||||
- B 表里标记“原生无对应表面”的功能(B1 任务队列、B6 项目制、B7 环境、B8 配额/管理、B9 OAuth/key、B10 webhook、B11 resident、B12 automations、B13 full-auto、B14 项目记忆/KB/成本/推送大部、B17 待办、B5 浏览器远控)**没有**原生界面承载,仍走 `/console-classic`(旧 React 控制台)。这是按用户要求不自造界面的直接结果,不是遗漏。
|
||||
- A9(ChatGPT 登录类方法)不可补;A8 终端面待验证阶段核实。
|
||||
- `original/` 不进仓:部署时须另行提供与 zip sha256 一致的 `original/` 目录,否则 UI host 无法启动;这一步属于部署(须用户点头),不在本轮本地完成范围内。
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env bash
|
||||
# Start the Codex native UI host — the only entry point for the UI host.
|
||||
#
|
||||
# scripts/run-codex-ui.sh UI on :8095
|
||||
# UI_PORT=9000 scripts/run-codex-ui.sh custom port
|
||||
#
|
||||
# Env (all optional, no secrets in this file):
|
||||
# CODEX_APP_SERVER_BIN codex binary for the app-server bridge
|
||||
# (default /opt/hatch-image/bin/codex)
|
||||
# CODEX_UI_ORIGINAL_ROOT vendored original/ webview directory
|
||||
# (default ~/workspace/user/downloads/codex-ui-stack/original;
|
||||
# original/ is never committed — see docs/codex-native-ui-alignment.md §4)
|
||||
# UI_PORT port to listen on (default 8095)
|
||||
# BUMIAN_API_KEY real-relay key, only when relay-config.json points
|
||||
# at the real relay (see host/relay-config.README.md)
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
UI_PORT="${UI_PORT:-8095}"
|
||||
|
||||
export CODEX_APP_SERVER_BIN="${CODEX_APP_SERVER_BIN:-/opt/hatch-image/bin/codex}"
|
||||
export CODEX_UI_ORIGINAL_ROOT="${CODEX_UI_ORIGINAL_ROOT:-$HOME/workspace/user/downloads/codex-ui-stack/original}"
|
||||
export CODEX_WEB_PORT="$UI_PORT"
|
||||
|
||||
echo "[codex-ui] UI http://127.0.0.1:${UI_PORT}/"
|
||||
echo "[codex-ui] codex ${CODEX_APP_SERVER_BIN}"
|
||||
echo "[codex-ui] root ${CODEX_UI_ORIGINAL_ROOT}"
|
||||
|
||||
exec node "$REPO_ROOT/cloud/codex-ui-host/host/web-server.mjs" --port "${UI_PORT}"
|
||||
Reference in new issue
Block a user