ci / test (push) Successful in 45m25s
- Vendor codex-ui-stack host/ as cloud/codex-ui-host/ (original/ not vendored, 650MB) - Bridge emulates 7 methods missing in codex 0.164.0-alpha.9, exposes 29 experimental-protocol methods (150 -> 179), Linux binary candidates, read-only POST /__codex_git - GET /console 302s to the UI host (:8095) when reachable, else falls back to the classic React console; GET /console-classic always serves it - Alignment tables: docs/codex-native-ui-alignment.md - Tests: test_console 7, new test_codex_ui_host 4, handler method pin 247 -> 249
719 lines
28 KiB
JavaScript
719 lines
28 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Local web host for the vendored Codex webview.
|
|
*
|
|
* `original/` stays byte-identical — every adaptation happens in the HTTP
|
|
* response layer, so the vendored tree remains a clean reference.
|
|
*
|
|
* The desktop renderer is not a plain web app; three pieces of the Electron
|
|
* shell are load-bearing and are replaced here:
|
|
*
|
|
* 1. CSP. The shipped policy is `default-src 'none'` with a connect-src that
|
|
* only whitelists `wss://chatgpt.com`. A browser tab could not reach
|
|
* anything, and we do not want it to: the policy is rewritten to `'self'`
|
|
* so the page is fully local.
|
|
* 2. The app host. `connect-app-host` creates a MessageChannel and posts one
|
|
* port to the preload, which hands it to the main process. Here the port is
|
|
* never used: `__codexLocalHost` answers instead (host/local-host.js).
|
|
* 3. Host services. The bootstrap awaits `appHost.services`, which the main
|
|
* process resolves with its service layer. Our local host resolves it with
|
|
* a proxy onto the real `codex app-server` (host/app-server-bridge.mjs).
|
|
*
|
|
* Usage:
|
|
* node host/web-server.mjs [--port 8095] [--root original]
|
|
*/
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import http from "node:http";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { AppServerBridge } from "./app-server-bridge.mjs";
|
|
import { serveMockRelay } from "./mock-model.mjs";
|
|
import { createProjectlessPaths, readWorkspaceRoot, WORKSPACE_ROOT } from "./projectless.mjs";
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const PROJECT = path.resolve(HERE, "..");
|
|
|
|
const argv = process.argv.slice(2);
|
|
const argOf = (name, fallback) => {
|
|
const i = argv.indexOf(name);
|
|
return i !== -1 && argv[i + 1] ? argv[i + 1] : fallback;
|
|
};
|
|
const PORT = Number(argOf("--port", process.env.CODEX_WEB_PORT || "8095"));
|
|
// --root wins; otherwise CODEX_UI_ORIGINAL_ROOT (set by scripts/run-codex-ui.sh),
|
|
// otherwise the in-repo `original/` directory (never committed — see §4).
|
|
const ROOT = path.resolve(PROJECT, argOf("--root", process.env.CODEX_UI_ORIGINAL_ROOT || "original"));
|
|
|
|
if (!fs.existsSync(path.join(ROOT, "index.html"))) {
|
|
console.error(`[web] missing index.html under ${ROOT}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const METHODS = JSON.parse(fs.readFileSync(path.join(HERE, "appserver-methods.json"), "utf8"));
|
|
const bridge = new AppServerBridge();
|
|
|
|
// --------------------------------------------------------------- CSP rewrite
|
|
// Fully local: nothing outside this origin is reachable. Blob/data stay allowed
|
|
// because the app builds workers and assets in memory.
|
|
const WEB_CSP = [
|
|
"default-src 'self'",
|
|
"img-src 'self' data: blob:",
|
|
"script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' blob:",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"font-src 'self' data:",
|
|
"connect-src 'self' data: blob:",
|
|
"media-src 'self' data: blob:",
|
|
"worker-src 'self' blob:",
|
|
"frame-src 'self' blob:",
|
|
"child-src 'self' blob:",
|
|
].join("; ");
|
|
|
|
const MIME = {
|
|
".html": "text/html; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".mjs": "text/javascript; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".svg": "image/svg+xml",
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".webp": "image/webp",
|
|
".gif": "image/gif",
|
|
".ico": "image/x-icon",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
".ttf": "font/ttf",
|
|
".otf": "font/otf",
|
|
".wasm": "application/wasm",
|
|
".map": "application/json; charset=utf-8",
|
|
".txt": "text/plain; charset=utf-8",
|
|
".wav": "audio/wav",
|
|
".mp3": "audio/mpeg",
|
|
};
|
|
|
|
/** Rewrite rules applied on the fly; `original/` on disk is never touched. */
|
|
const JS_PATCHES = [
|
|
{
|
|
name: "local-app-host",
|
|
// `connect-app-host` opens a MessageChannel and posts one port to the
|
|
// preload, which forwards it to the Electron main process. There is no main
|
|
// process here, so answer with the local host instead.
|
|
find: /function (\w+)\((\w+)\)\{let\{port1:(\w+),port2:(\w+)\}=new MessageChannel;return window\.postMessage\(\{type:`connect-app-host`/g,
|
|
replace: (_m, fn, arg, p1, p2) =>
|
|
`function ${fn}(${arg}){if(globalThis.__codexLocalHost)return globalThis.__codexLocalHost(${arg});` +
|
|
`let{port1:${p1},port2:${p2}}=new MessageChannel;return window.postMessage({type:\`connect-app-host\``,
|
|
},
|
|
{
|
|
name: "bootstrap-guard",
|
|
// The original bootstrap wires up seven main-process facilities: remote
|
|
// connection discovery, an execution host, an RPC-broken hook, thread read
|
|
// state, Statsig, client coordination and the terminal. Only the first two
|
|
// affect the shell; the rest are Electron-only and would abort the whole
|
|
// sequence on the first rejection. Keep the parts that matter, guard them,
|
|
// and drop the rest.
|
|
find: /async function pL\(\)\{[\s\S]*?\}function mL\(\)/g,
|
|
replace: () =>
|
|
`async function pL(){` +
|
|
`const T=(ms)=>new Promise(r=>setTimeout(r,ms));` +
|
|
`const guard=async(f,ms)=>{try{return await Promise.race([f(),T(ms)])}catch(e){console.warn("[codex-web] init step skipped:",e&&e.message)}};` +
|
|
`await guard(async()=>{$t(kS(lL))},5000);` +
|
|
`await guard(async()=>{dr(await Pt.services)},10000);` +
|
|
`await guard(async()=>{await ql(H.remoteConnections)},2000);` +
|
|
`await guard(async()=>{di(await H.executionHosts?.getDefaultHost())},2000);` +
|
|
`globalThis.__codexBootstrapped=!0` +
|
|
`}function mL()`,
|
|
},
|
|
{
|
|
name: "skip-shell-gate",
|
|
// The shell waits for auth, app-info, os-info and one more readiness signal
|
|
// before rendering anything. In the desktop app the main process supplies
|
|
// all four; here auth in particular only settles after the app server has
|
|
// been asked for an account, which cannot happen until the shell exists.
|
|
// Render the shell immediately and let the data arrive underneath it.
|
|
find: /if\((\w+)\.isLoading\|\|(\w+)\|\|(\w+)\|\|(\w+)\)\{/g,
|
|
replace: (_m, r, a, s, c) =>
|
|
`if(console.info("[codex-web] shell gate",JSON.stringify({rLoad:${r}&&${r}.isLoading,a:${a},s:${s},c:${c}})),!1){`,
|
|
},
|
|
{
|
|
name: "mode-probe",
|
|
// `Ck` picks the window mode (`app` / `onboarding` / `login`) from a pile of
|
|
// readiness signals and falls back to the loading blossom when it comes back
|
|
// empty. The guard is a comma expression inside a single `if`, so the anchor
|
|
// is the tail of that expression rather than the `if(` itself. Log the
|
|
// decision so the missing input is identifiable.
|
|
find: /,!(\w+)\|\|(\w+)===\x60loading\x60\)\{/g,
|
|
replace: (_m, mode, status) =>
|
|
`,console.info("[codex-web] mode",JSON.stringify({mode:${mode},status:${status}})),!${mode}||${status}===\x60loading\x60){`,
|
|
},
|
|
{
|
|
name: "mode-inputs",
|
|
// Bind the mode-input object to the global scope on its way into the
|
|
// decision function, so a probe can read exactly what the shell decided on.
|
|
find: /,(\w+)=(\w+)\(\{bedrockLoginPending:/g,
|
|
replace: (_m, out, fn) => `,${out}=${fn}(globalThis.__modeIn={bedrockLoginPending:`,
|
|
},
|
|
{
|
|
name: "mode-decide",
|
|
// The window-mode decision gates on two desktop-only concepts:
|
|
//
|
|
// gatewayOAuthReadiness — an OpenAI gateway handshake that stays `loading`
|
|
// forever here because there is no gateway: this build talks to an
|
|
// API-key relay, so there is nothing to wait for.
|
|
// onboarding completion — the desktop onboarding flow needs a ChatGPT
|
|
// account and a backend, neither of which this build has.
|
|
//
|
|
// Collapse both: honour an explicit "login required", otherwise go straight
|
|
// to the app. The relay authenticates with a key, so there is no login step.
|
|
find: /(\w+)===\x60loading\x60\?null:\1===\x60required\x60\?\x60login\x60:[\s\S]{0,200}?:\x60welcome\x60\):\x60app\x60\}/g,
|
|
replace: (_m, readiness) =>
|
|
`${readiness}===\x60required\x60?\x60login\x60:\x60app\x60:\x60app\x60}`,
|
|
},
|
|
{
|
|
name: "config-effective-preseed",
|
|
// The scope-query bridge fetches only from mounted React readers. In a
|
|
// browser fallback nothing mounts the config/effective query, so `ofo()`
|
|
// would read `isPending: true` forever and keep `isAgentModeLoading` true.
|
|
// Short-circuit to the preseeded app-server config (host/shim.js).
|
|
find: /let\{data:(\w+),isPending:(\w+)\}=ixn\((\w+)===`~`\?null:\3,(\w+)\),/,
|
|
replace: (_m, w, T, p, C) =>
|
|
`let{data:${w},isPending:${T}}=globalThis.__codexCfg?{data:{config:globalThis.__codexCfg.config,origins:globalThis.__codexCfg.origins??{},layers:null},isPending:!1}:(ixn(${p}===\`~\`?null:${p},${C}),{data:void 0,isPending:!0}),`,
|
|
},
|
|
{
|
|
name: "permission-profiles-preseed",
|
|
// Same mount requirement for the permission-profiles query read inside the
|
|
// agent-mode hook (`sfo`); short-circuit to the preseeded profile list.
|
|
find: /let (\w+)=ns\(kdo,(\w+),(\w+)\),(\w+)=\w+\|\|\w+\|\|\1\.isPending,(\w+)=\4\|\|\1\.isError,/,
|
|
replace: (_m, O, T2, D2, k, A) =>
|
|
`let ${O}=globalThis.__codexPerms?{data:globalThis.__codexPerms,isPending:!1,isError:!1}:ns(kdo,${T2},${D2}),${k}=h||g||${O}.isPending,${A}=${k}||${O}.isError,`,
|
|
},
|
|
{
|
|
name: "guardian-off",
|
|
// The guardian-approvals feature flag derives from a Statsig dynamic
|
|
// config the desktop main process evaluates; with a null evaluations
|
|
// payload (this build has no ChatGPT account) the guard reads as
|
|
// "unavailable" and `ofo()` treats guardian mode as pending forever.
|
|
// This build has no guardian capability at all, so pin it off.
|
|
find: /N=(\w+)&&oee\(\w+\?\?void 0\)!==!1&&\(\w+===void 0\|\|\w+\),/,
|
|
replace: (_m, e) => `N=!1,`,
|
|
},
|
|
{
|
|
name: "statsig-bootstrap-local",
|
|
// The renderer fetches its Statsig bootstrap from the ChatGPT backend
|
|
// (`POST /wham/statsig/bootstrap`). Without a ChatGPT token that request
|
|
// is rejected before it is ever sent, and the SDK retries forever with
|
|
// "SDK initialization has not completed" warnings. Serve the bootstrap
|
|
// from the local host instead (see /__codex_statsig_bootstrap).
|
|
find: /function (\w+)\((\w+),(\w+)\)\{return (\w+)\.safePost\(`\/wham\/statsig\/bootstrap`,\{requestBody:\2,retry:!1,signal:\3\}\)\}/g,
|
|
replace: (_m, fn, reqBody, signal) =>
|
|
`async function ${fn}(${reqBody},${signal}){` +
|
|
`const r=await fetch("/__codex_statsig_bootstrap",{method:"POST",headers:{"content-type":"application/json"},body:JSON.stringify(${reqBody}??{}),signal:${signal}});` +
|
|
`return r.json()}`,
|
|
},
|
|
{
|
|
name: "expose-app-host",
|
|
// setAppHost / setAppServices only assign module-scoped variables, which
|
|
// makes the wiring invisible from the outside. Mirror them for diagnostics.
|
|
find: /function hSt\((\w+)\)\{_St=\1\}/g,
|
|
replace: (_m, v) => `function hSt(${v}){_St=${v};globalThis.__appHost=${v}}`,
|
|
},
|
|
{
|
|
name: "expose-app-services",
|
|
find: /function gSt\((\w+)\)\{f\$=\1\}/g,
|
|
replace: (_m, v) => `function gSt(${v}){f$=${v};globalThis.__appServices=${v}}`,
|
|
},
|
|
];
|
|
|
|
function patchJs(source) {
|
|
let out = source;
|
|
const applied = [];
|
|
for (const rule of JS_PATCHES) {
|
|
if (!rule.find.test(out)) continue;
|
|
rule.find.lastIndex = 0;
|
|
out = out.replace(rule.find, rule.replace);
|
|
applied.push(rule.name);
|
|
}
|
|
return { out, applied };
|
|
}
|
|
|
|
function patchHtml(source) {
|
|
let out = source;
|
|
|
|
out = out.replace(
|
|
/<meta http-equiv="Content-Security-Policy" content="[\s\S]*?"\s*\/?>/i,
|
|
`<meta http-equiv="Content-Security-Policy" content="${WEB_CSP.replace(/"/g, """)}" />`,
|
|
);
|
|
|
|
// Both shims must run before the module entry.
|
|
if (!out.includes("__codex_shim.js")) {
|
|
out = out.replace(
|
|
/<script type="module"/i,
|
|
`<script src="/__codex_local_host.js"></script>\n <script src="/__codex_shim.js"></script>\n <script type="module"`,
|
|
);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const LOCAL_HOST_JS = fs
|
|
.readFileSync(path.join(HERE, "local-host.js"), "utf8")
|
|
.replace("/*@METHODS@*/ []", JSON.stringify(METHODS));
|
|
|
|
const SHIM = fs.readFileSync(path.join(HERE, "shim.js"), "utf8");
|
|
// ------------------------------------------------------------- mock backend
|
|
// Local stand-in for the ChatGPT backend. The app server runs with
|
|
// chatgpt_base_url pointed at /mock, so every call that would reach OpenAI's
|
|
// cloud — plugin catalog refresh, account endpoints, telemetry — is answered
|
|
// from this process with well-formed local JSON. Nothing leaves the machine
|
|
// and no caller ever sees a connection error.
|
|
const MOCK_LOG = [];
|
|
const nowIso = () => new Date().toISOString();
|
|
|
|
/** Valid, empty Statsig initialize payload — every gate resolves to "off". */
|
|
const statsigBootstrapPayload = (stableId = "codex-web-local") => ({
|
|
has_updates: true,
|
|
hash_used: "djb2",
|
|
time: Date.now(),
|
|
feature_gates: {},
|
|
dynamic_configs: {},
|
|
layer_configs: {},
|
|
param_stores: {},
|
|
user: {
|
|
userID: null,
|
|
email: null,
|
|
custom: { auth_method: null },
|
|
customIDs: { stableID: stableId, WebAnonymousCookieID: stableId, DeviceId: stableId },
|
|
},
|
|
});
|
|
|
|
/** A remote-catalog plugin descriptor the renderer can render without data. */
|
|
const mockRemotePluginRead = (pluginName) => ({
|
|
plugin: {
|
|
summary: {
|
|
id: pluginName,
|
|
name: String(pluginName).replace(/^plugin_[^_]*_/, ""),
|
|
remotePluginId: pluginName,
|
|
version: null,
|
|
installed: false,
|
|
enabled: false,
|
|
installPolicy: "NOT_AVAILABLE",
|
|
installPolicySource: null,
|
|
mustShowInstallationInterstitial: false,
|
|
authPolicy: "ON_USE",
|
|
availability: "NOT_AVAILABLE",
|
|
disabledReason: "unavailable_offline",
|
|
interface: {
|
|
displayName: pluginName,
|
|
shortDescription: "Cloud plugin (offline build)",
|
|
longDescription: "This plugin belongs to the ChatGPT cloud catalog and is not available in a fully local build.",
|
|
developerName: null,
|
|
category: null,
|
|
capabilities: [],
|
|
websiteUrl: null,
|
|
privacyPolicyUrl: null,
|
|
termsOfServiceUrl: null,
|
|
defaultPrompt: [],
|
|
brandColor: "#6B7280",
|
|
composerIcon: null,
|
|
composerIconUrl: null,
|
|
logo: null,
|
|
logoDark: null,
|
|
logoUrl: null,
|
|
logoUrlDark: null,
|
|
screenshots: [],
|
|
screenshotUrls: [],
|
|
},
|
|
keywords: [],
|
|
},
|
|
skills: [],
|
|
onboardingSkill: null,
|
|
},
|
|
});
|
|
|
|
const serveMock = (req, res, urlPath) => {
|
|
let search = "";
|
|
try {
|
|
search = new URL(req.url, "http://localhost").search;
|
|
} catch {
|
|
/* keep empty */
|
|
}
|
|
MOCK_LOG.push(`${req.method} ${urlPath}${search}`);
|
|
if (MOCK_LOG.length > 200) MOCK_LOG.shift();
|
|
|
|
const json = (obj, status = 200) => {
|
|
res.writeHead(status, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
res.end(JSON.stringify(obj));
|
|
};
|
|
const p = urlPath.replace(/^\/mock/, "") || "/";
|
|
|
|
// Cloud MCP endpoint (ChatGPT-hosted app connectors). Answer 404 so the app
|
|
// server marks these connectors unavailable immediately — with a semantically
|
|
// empty 200 it would retry and block thread startup on MCP enumeration.
|
|
if (/^\/api\/codex\/ps\/mcp/.test(p)) {
|
|
res.writeHead(404, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ error: { message: "cloud connectors are unavailable in a local build" } }));
|
|
return;
|
|
}
|
|
|
|
// ---- known wham (ChatGPT backend) shapes -------------------------------
|
|
// Cloud tasks: the renderer dereferences `.items` and paginates on `.cursor`.
|
|
if (/^\/wham\/tasks\/list/.test(p)) {
|
|
json({ items: [], cursor: null });
|
|
return;
|
|
}
|
|
// Statsig bootstrap: `{ statsigPayload: <string> }`, exactly as the desktop
|
|
// main process hands it over.
|
|
if (/^\/wham\/statsig\/bootstrap/.test(p)) {
|
|
json({ statsigPayload: JSON.stringify(statsigBootstrapPayload()) });
|
|
return;
|
|
}
|
|
|
|
// Plugin catalog refresh: the app server persists this under
|
|
// ~/.codex/cache/remote_plugin_catalog. The truthful local answer is an
|
|
// empty catalog — no cloud plugins exist in this build.
|
|
if (/plugin/i.test(p)) {
|
|
json({ schema_version: 1, fetched_at: nowIso(), plugins: [] });
|
|
return;
|
|
}
|
|
// Generic empty-but-valid JSON: callers parse it, find nothing to do, and
|
|
// move on without errors.
|
|
json({});
|
|
};
|
|
|
|
// ------------------------------------------------------- mock model provider
|
|
// The model wire lives in ./mock-model.mjs so the local model is defined once
|
|
// and every caller gets the identical Responses-API stream.
|
|
const logMockRequest = (line) => {
|
|
MOCK_LOG.push(line);
|
|
if (MOCK_LOG.length > 200) MOCK_LOG.shift();
|
|
};
|
|
|
|
// ------------------------------------------------------------------- server
|
|
// Read-only git ops accepted by POST /__codex_git (A7). Anything else is
|
|
// rejected; there is intentionally no write op in this set.
|
|
const GIT_OPS = new Set(["stable-metadata", "branch", "status"]);
|
|
|
|
const readJsonBody = (req) =>
|
|
new Promise((resolve, reject) => {
|
|
let raw = "";
|
|
req.setEncoding("utf8");
|
|
req.on("data", (c) => {
|
|
raw += c;
|
|
if (raw.length > 4_000_000) reject(new Error("body too large"));
|
|
});
|
|
req.on("end", () => {
|
|
try {
|
|
resolve(raw ? JSON.parse(raw) : {});
|
|
} catch (err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
req.on("error", reject);
|
|
});
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
let urlPath;
|
|
try {
|
|
urlPath = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
|
|
} catch {
|
|
res.writeHead(400).end("bad request");
|
|
return;
|
|
}
|
|
|
|
if (urlPath === "/__codex_shim.js") {
|
|
res.writeHead(200, { "content-type": MIME[".js"], "cache-control": "no-store" });
|
|
res.end(SHIM);
|
|
return;
|
|
}
|
|
|
|
if (urlPath === "/__codex_local_host.js") {
|
|
res.writeHead(200, { "content-type": MIME[".js"], "cache-control": "no-store" });
|
|
res.end(LOCAL_HOST_JS);
|
|
return;
|
|
}
|
|
|
|
// Filesystem work the Electron main process would do: allocate a directory for
|
|
// a conversation that is not attached to a project.
|
|
if (urlPath === "/__codex_projectless") {
|
|
if (req.method !== "POST") {
|
|
res.writeHead(405).end("method not allowed");
|
|
return;
|
|
}
|
|
try {
|
|
const { op, args } = await readJsonBody(req);
|
|
const result = op === "readWorkspaceRoot" ? readWorkspaceRoot() : createProjectlessPaths(args ?? {});
|
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ result }));
|
|
} catch (err) {
|
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ error: { message: err?.message ?? "workspace error" } }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Read-only git (A7). The renderer gates the composer on
|
|
// `git.request("stable-metadata")`, which the desktop main process answers.
|
|
// Only three read-only ops are accepted; write operations cannot be
|
|
// expressed, and no caller-supplied path ever reaches argv — `cwd` is
|
|
// passed to execFile as the working-directory option only, and every git
|
|
// invocation runs strictly inside that directory. A missing cwd or a
|
|
// non-repository cwd gets a structured error, not a silent stub.
|
|
if (urlPath === "/__codex_git") {
|
|
if (req.method !== "POST") {
|
|
res.writeHead(405).end("method not allowed");
|
|
return;
|
|
}
|
|
const gitError = (message) => {
|
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ error: { message } }));
|
|
};
|
|
try {
|
|
const { op, cwd } = await readJsonBody(req);
|
|
if (!GIT_OPS.has(op)) {
|
|
gitError(`unsupported git op: ${String(op)}`);
|
|
return;
|
|
}
|
|
if (typeof cwd !== "string" || !cwd) {
|
|
gitError("git request needs a cwd");
|
|
return;
|
|
}
|
|
const dir = path.resolve(cwd);
|
|
let st;
|
|
try {
|
|
st = fs.statSync(dir);
|
|
} catch {
|
|
gitError(`cwd does not exist: ${dir}`);
|
|
return;
|
|
}
|
|
if (!st.isDirectory()) {
|
|
gitError(`cwd is not a directory: ${dir}`);
|
|
return;
|
|
}
|
|
const runGit = (args) => execFileSync("git", args, { cwd: dir, encoding: "utf8", timeout: 10_000 }).trim();
|
|
let toplevel;
|
|
try {
|
|
toplevel = runGit(["rev-parse", "--show-toplevel"]);
|
|
} catch {
|
|
gitError(`not a git repository: ${dir}`);
|
|
return;
|
|
}
|
|
let branch = null;
|
|
try {
|
|
const raw = runGit(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
branch = raw === "HEAD" ? null : raw; // detached HEAD, or no commits yet
|
|
} catch {
|
|
branch = null;
|
|
}
|
|
let upstream = null;
|
|
try {
|
|
upstream = runGit(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
} catch {
|
|
/* no upstream configured */
|
|
}
|
|
const porcelain = runGit(["status", "--porcelain=v1"]);
|
|
const clean = porcelain === "";
|
|
// stable-metadata keeps the six fields the old stub returned, now
|
|
// populated for real; branch/status return the subset they need.
|
|
const result =
|
|
op === "branch"
|
|
? { branch, upstream }
|
|
: op === "status"
|
|
? { branch, upstream, clean, files: clean ? [] : porcelain.split("\n") }
|
|
: {
|
|
available: true,
|
|
branch,
|
|
upstream,
|
|
repository: toplevel,
|
|
state: clean ? "clean" : "dirty",
|
|
worktree: toplevel,
|
|
};
|
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ result }));
|
|
} catch (err) {
|
|
gitError(err?.message ?? "git error");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Diagnostic: what has the app server asked the mock backend for?
|
|
if (urlPath === "/__codex_mocklog") {
|
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ requests: MOCK_LOG }));
|
|
return;
|
|
}
|
|
|
|
// Statsig bootstrap. The renderer normally POSTs /wham/statsig/bootstrap to
|
|
// the ChatGPT backend; the `statsig-bootstrap-local` patch redirects it here.
|
|
// The response is a valid, empty initialize payload — every gate resolves to
|
|
// "off" and the SDK completes initialization instead of retrying forever.
|
|
if (urlPath === "/__codex_statsig_bootstrap") {
|
|
MOCK_LOG.push(`POST ${urlPath} (statsig bootstrap)`);
|
|
if (MOCK_LOG.length > 200) MOCK_LOG.shift();
|
|
if (req.method !== "POST") {
|
|
res.writeHead(405).end("method not allowed");
|
|
return;
|
|
}
|
|
let body = {};
|
|
try {
|
|
body = await readJsonBody(req);
|
|
} catch {
|
|
/* empty request body is fine */
|
|
}
|
|
const stableId = typeof body.stable_id === "string" ? body.stable_id : "codex-web-local";
|
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
// The renderer expects `{ statsigPayload: <string> }` — the payload as a
|
|
// JSON string, exactly as the desktop main process hands it over.
|
|
res.end(JSON.stringify({ statsigPayload: JSON.stringify(statsigBootstrapPayload(stableId)) }));
|
|
return;
|
|
}
|
|
|
|
// Same-origin Statsig event flush: the renderer posts to /ces/v1/rgstr, which
|
|
// lands here. Accept and drop — nothing is measured, nothing leaves.
|
|
if (urlPath.startsWith("/ces/")) {
|
|
res.writeHead(202, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
res.end("{}");
|
|
return;
|
|
}
|
|
|
|
// Mock model provider (see ./mock-model.mjs). The app server's
|
|
// model_provider points here, so `turn/start` completions are answered from
|
|
// this process with a well-formed Responses-API SSE stream. Nothing reaches
|
|
// the network and the reply renders exactly as a live model's would.
|
|
if (urlPath === "/mockrelay" || urlPath.startsWith("/mockrelay/")) {
|
|
serveMockRelay(req, res, urlPath, { onRequest: logMockRequest });
|
|
return;
|
|
}
|
|
|
|
// Mock ChatGPT backend (see serveMock above).
|
|
if (urlPath === "/mock" || urlPath.startsWith("/mock/")) {
|
|
serveMock(req, res, urlPath);
|
|
return;
|
|
}
|
|
|
|
// Event stream. Everything the app server emits on its own initiative —
|
|
// streaming deltas, turn progress, server-initiated approval requests — comes
|
|
// back through here. Long-polled so the renderer stays in step without a
|
|
// websocket.
|
|
if (urlPath === "/__codex_events") {
|
|
try {
|
|
await bridge.ensure();
|
|
const messages = await bridge.waitForNotifications(15_000);
|
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ events: messages }));
|
|
} catch (err) {
|
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ events: [], error: String(err?.message ?? err) }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Local JSON-RPC passthrough to the real codex app server.
|
|
if (urlPath === "/__codex_rpc") {
|
|
if (req.method !== "POST") {
|
|
res.writeHead(405).end("method not allowed");
|
|
return;
|
|
}
|
|
let method, params;
|
|
try {
|
|
({ method, params } = await readJsonBody(req));
|
|
if (typeof method !== "string") {
|
|
res.writeHead(400, { "content-type": MIME[".json"] }).end('{"error":{"message":"missing method"}}');
|
|
return;
|
|
}
|
|
// The desktop main process strips `originator` filtering before it
|
|
// forwards `thread/list` to the app server — a local app-server rejects
|
|
// the parameter with -32602. Mirror the sanitisation here.
|
|
if (method === "thread/list" && params && typeof params === "object") {
|
|
const strip = (obj) => {
|
|
if (!obj || typeof obj !== "object") return;
|
|
for (const key of Object.keys(obj)) {
|
|
if (key === "originator" || key === "originators") delete obj[key];
|
|
else if (obj[key] && typeof obj[key] === "object") strip(obj[key]);
|
|
}
|
|
};
|
|
strip(params);
|
|
}
|
|
await bridge.ensure();
|
|
const result = await bridge.request(method, params);
|
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ result: result ?? null }));
|
|
} catch (err) {
|
|
// A cloud-catalog plugin cannot be fetched in a local build; answer with
|
|
// a well-formed offline descriptor instead of an error the renderer
|
|
// would surface as a toast.
|
|
if (method === "plugin/read" && params && typeof params.pluginName === "string") {
|
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ result: mockRemotePluginRead(params.pluginName) }));
|
|
return;
|
|
}
|
|
const message = err?.rpc?.message ?? err?.message ?? "rpc failed";
|
|
res.writeHead(200, { "content-type": MIME[".json"], "cache-control": "no-store" });
|
|
res.end(JSON.stringify({ error: { code: err?.rpc?.code ?? -32000, message } }));
|
|
}
|
|
return;
|
|
}
|
|
|
|
const rel = urlPath === "/" ? "index.html" : urlPath.replace(/^\/+/, "");
|
|
const abs = path.join(ROOT, rel);
|
|
|
|
if (!abs.startsWith(ROOT)) {
|
|
res.writeHead(403).end("forbidden");
|
|
return;
|
|
}
|
|
|
|
let file = abs;
|
|
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) {
|
|
file = path.join(ROOT, "index.html"); // SPA fallback
|
|
if (!fs.existsSync(file)) {
|
|
res.writeHead(404).end("not found");
|
|
return;
|
|
}
|
|
}
|
|
|
|
const ext = path.extname(file).toLowerCase();
|
|
const type = MIME[ext] || "application/octet-stream";
|
|
|
|
try {
|
|
let body = fs.readFileSync(file);
|
|
let patched = null;
|
|
|
|
if (ext === ".html") {
|
|
body = Buffer.from(patchHtml(body.toString("utf8")), "utf8");
|
|
} else if (ext === ".js") {
|
|
const { out, applied } = patchJs(body.toString("utf8"));
|
|
if (applied.length) {
|
|
body = Buffer.from(out, "utf8");
|
|
patched = applied.join(",");
|
|
}
|
|
}
|
|
|
|
res.writeHead(200, {
|
|
"content-type": type,
|
|
"cache-control": "no-store",
|
|
...(patched ? { "x-codex-patched": patched } : {}),
|
|
});
|
|
res.end(body);
|
|
} catch (err) {
|
|
console.error("[web] read error", file, err.message);
|
|
res.writeHead(500).end("read error");
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, "127.0.0.1", () => {
|
|
console.log(`[web] Codex webview served from ${ROOT}`);
|
|
console.log(`[web] http://127.0.0.1:${PORT}/`);
|
|
console.log(`[web] app server: ${bridge.binary ?? "not found"}`);
|
|
console.log(`[web] workspace root: ${WORKSPACE_ROOT}`);
|
|
console.log(`[web] mock backend: http://127.0.0.1:${PORT}/mock`);
|
|
});
|
|
|
|
// A long-running local host must survive its own bugs: log loudly, keep
|
|
// serving. Without this one bad request would take the whole app down.
|
|
process.on("uncaughtException", (err) => {
|
|
console.error("[web] uncaught:", err?.stack ?? err);
|
|
});
|
|
process.on("unhandledRejection", (err) => {
|
|
console.error("[web] unhandled rejection:", err?.stack ?? err);
|
|
});
|