Files
codex-app-server/cloud/codex-ui-host/host/shim.js
T
host 3e0ff898e0
ci / test (push) Successful in 45m25s
feat: wire native Codex UI as /console entry, keep classic console at /console-classic
- 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
2026-11-06 19:03:35 +00:00

324 lines
13 KiB
JavaScript

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