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