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
324 lines
13 KiB
JavaScript
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");
|
|
}
|
|
});
|
|
})();
|