diff --git a/cloud/codex-ui-host/host/app-server-bridge.mjs b/cloud/codex-ui-host/host/app-server-bridge.mjs new file mode 100644 index 0000000..77328b1 --- /dev/null +++ b/cloud/codex-ui-host/host/app-server-bridge.mjs @@ -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; + } +} diff --git a/cloud/codex-ui-host/host/appserver-methods.json b/cloud/codex-ui-host/host/appserver-methods.json new file mode 100644 index 0000000..5e71128 --- /dev/null +++ b/cloud/codex-ui-host/host/appserver-methods.json @@ -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" +] diff --git a/cloud/codex-ui-host/host/cdp-load.mjs b/cloud/codex-ui-host/host/cdp-load.mjs new file mode 100755 index 0000000..f7eb527 --- /dev/null +++ b/cloud/codex-ui-host/host/cdp-load.mjs @@ -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(); diff --git a/cloud/codex-ui-host/host/local-host.js b/cloud/codex-ui-host/host/local-host.js new file mode 100644 index 0000000..db9d4f2 --- /dev/null +++ b/cloud/codex-ui-host/host/local-host.js @@ -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"); +})(); diff --git a/cloud/codex-ui-host/host/mock-model.mjs b/cloud/codex-ui-host/host/mock-model.mjs new file mode 100644 index 0000000..627dcd2 --- /dev/null +++ b/cloud/codex-ui-host/host/mock-model.mjs @@ -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({}); +}; diff --git a/cloud/codex-ui-host/host/projectless.mjs b/cloud/codex-ui-host/host/projectless.mjs new file mode 100644 index 0000000..a034b20 --- /dev/null +++ b/cloud/codex-ui-host/host/projectless.mjs @@ -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: // + */ + +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() }; +} diff --git a/cloud/codex-ui-host/host/relay-config.README.md b/cloud/codex-ui-host/host/relay-config.README.md new file mode 100644 index 0000000..66e4af8 --- /dev/null +++ b/cloud/codex-ui-host/host/relay-config.README.md @@ -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 后端调用继续由本地替身回答。 diff --git a/cloud/codex-ui-host/host/relay-config.json b/cloud/codex-ui-host/host/relay-config.json new file mode 100644 index 0000000..952f57b --- /dev/null +++ b/cloud/codex-ui-host/host/relay-config.json @@ -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" +} diff --git a/cloud/codex-ui-host/host/shim.js b/cloud/codex-ui-host/host/shim.js new file mode 100644 index 0000000..39a67be --- /dev/null +++ b/cloud/codex-ui-host/host/shim.js @@ -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"); + } + }); +})(); diff --git a/cloud/codex-ui-host/host/vendor-manifest.json b/cloud/codex-ui-host/host/vendor-manifest.json new file mode 100644 index 0000000..36eab6f --- /dev/null +++ b/cloud/codex-ui-host/host/vendor-manifest.json @@ -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" +} \ No newline at end of file diff --git a/cloud/codex-ui-host/host/vendor-original.mjs b/cloud/codex-ui-host/host/vendor-original.mjs new file mode 100644 index 0000000..9777977 --- /dev/null +++ b/cloud/codex-ui-host/host/vendor-original.mjs @@ -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. + * /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}`); diff --git a/cloud/codex-ui-host/host/web-server.mjs b/cloud/codex-ui-host/host/web-server.mjs new file mode 100644 index 0000000..e0e8cf8 --- /dev/null +++ b/cloud/codex-ui-host/host/web-server.mjs @@ -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( + //i, + ``, + ); + + // Both shims must run before the module entry. + if (!out.includes("__codex_shim.js")) { + out = out.replace( + /\n \n