/** * 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"); })();