#!/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