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
418 lines
17 KiB
JavaScript
418 lines
17 KiB
JavaScript
/**
|
|
* Local app host for the browser build.
|
|
*
|
|
* The desktop renderer obtains its host through `connect-app-host`, which in
|
|
* Electron hands a MessagePort to the main process; the main process answers
|
|
* with a capability-RPC object exposing `services` plus a handful of host
|
|
* lifecycle hooks. In a browser there is no main process, so this file supplies
|
|
* the same object locally:
|
|
*
|
|
* - data services are generated from the app-server method table and proxied
|
|
* to the real local `codex app-server` (see host/app-server-bridge.mjs);
|
|
* - lifecycle members that only exist inside Electron are stubbed.
|
|
*
|
|
* The generated shape matters: `services.thread.list` must resolve to a call
|
|
* that issues `thread/list`, `services.config.read` to `config/read`, and so
|
|
* on. Anything the UI probes but we do not provide is reported once on the
|
|
* console so gaps are visible instead of silent.
|
|
*
|
|
* Served as a classic script. The marker below is replaced with the app-server
|
|
* method list at response time.
|
|
*/
|
|
(() => {
|
|
const METHODS = /*@METHODS@*/ [];
|
|
|
|
// Every path prefix of every method becomes a namespace object, so feature
|
|
// detection (`if (services.terminal) ...`) sees `undefined` for members that
|
|
// genuinely do not exist.
|
|
const namespaces = new Set();
|
|
for (const method of METHODS) {
|
|
const parts = method.split("/");
|
|
for (let i = 1; i < parts.length; i += 1) namespaces.add(parts.slice(0, i).join("/"));
|
|
}
|
|
|
|
const reported = new Set();
|
|
const reportMissing = (path) => {
|
|
if (reported.has(path)) return;
|
|
reported.add(path);
|
|
const stack = (new Error().stack || "").split("\n").slice(2, 4).join(" | ");
|
|
console.info("[codex-local-host] unimplemented service member:", path, "@", stack);
|
|
};
|
|
|
|
// Permissive stub for Electron-main-only service namespaces: every unknown
|
|
// member is an inert async function, so unguarded calls do not throw. Only
|
|
// use this where the renderer never feature-detects the member itself.
|
|
const permissive = (label, members = {}) =>
|
|
new Proxy(members, {
|
|
get(target, prop) {
|
|
if (prop in target) return target[prop];
|
|
if (typeof prop === "symbol" || prop === "then" || prop === "toJSON") return undefined;
|
|
reportMissing(`${label}.${String(prop)}`);
|
|
return () => Promise.resolve();
|
|
},
|
|
});
|
|
|
|
async function rpc(method, params) {
|
|
const res = await fetch("/__codex_rpc", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ method, params: params ?? {} }),
|
|
});
|
|
if (!res.ok) throw new Error(`app-server HTTP ${res.status}`);
|
|
const body = await res.json();
|
|
if (body && body.error) {
|
|
const err = new Error(body.error.message || `app-server error: ${method}`);
|
|
err.code = body.error.code;
|
|
throw err;
|
|
}
|
|
return body ? body.result : undefined;
|
|
}
|
|
|
|
// ------------------------------------------------------- persisted state
|
|
// `r$t()` in the renderer resolves `mi.applicationState` and then calls
|
|
// `.read(key)` -> `{ value }` and `.write(key, value)` -> `{ success }`.
|
|
// The desktop build backs this with an Electron store on disk; localStorage
|
|
// gives the same semantics here, so onboarding flags, the selected project
|
|
// and the pinned-thread list survive a reload instead of erroring out.
|
|
const STATE_PREFIX = "codex.appstate.";
|
|
const stateKey = (key) => STATE_PREFIX + String(key);
|
|
|
|
const applicationState = {
|
|
read: async (key) => {
|
|
console.info("[codex-local-host] applicationState.read:", String(key).slice(0, 80));
|
|
try {
|
|
const raw = localStorage.getItem(stateKey(key));
|
|
const out = { value: raw == null ? null : JSON.parse(raw) };
|
|
console.info("[codex-local-host] applicationState.read ->", String(key).slice(0, 60), JSON.stringify(out).slice(0, 120));
|
|
return out;
|
|
} catch (e) {
|
|
console.info("[codex-local-host] applicationState.read ERROR:", String(e));
|
|
return { value: null };
|
|
}
|
|
},
|
|
write: async (key, value) => {
|
|
try {
|
|
localStorage.setItem(stateKey(key), JSON.stringify(value));
|
|
return { success: true };
|
|
} catch {
|
|
return { success: false };
|
|
}
|
|
},
|
|
remove: async (key) => {
|
|
try {
|
|
localStorage.removeItem(stateKey(key));
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
return { success: true };
|
|
},
|
|
keys: async () => {
|
|
const out = [];
|
|
for (let i = 0; i < localStorage.length; i += 1) {
|
|
const k = localStorage.key(i);
|
|
if (k && k.startsWith(STATE_PREFIX)) out.push(k.slice(STATE_PREFIX.length));
|
|
}
|
|
return out;
|
|
},
|
|
};
|
|
|
|
// ------------------------------------------------------------- http fetch
|
|
// `$_t` calls `httpFetch.fetch(id, request, onUploadProgress)` where
|
|
// `request` is `{ url, method, body, headers, keepalive, cache, ... }` and
|
|
// awaits `{ response }` or `{ error, status, ... }` — the handle (both the
|
|
// promise and the settled value) carries `Symbol.dispose`.
|
|
//
|
|
// Backend API paths (`/wham/...`, `/backend-api/...`, …) are answered by the
|
|
// local mock backend served by the web host — the same policy the app
|
|
// server's chatgpt_base_url follows. Nothing ever leaves the page.
|
|
const API_PATH_RE = /^\/(wham|backend-api|ces|v1|api|public)\//;
|
|
const httpFetch = {
|
|
fetch: (id, request, onUploadProgress) => {
|
|
const controller = new AbortController();
|
|
const dispose = () => controller.abort();
|
|
const opts = typeof request === "string" ? { url: request } : request || {};
|
|
let url = String(opts.url ?? "");
|
|
const method = String(opts.method || "GET").toUpperCase();
|
|
if (API_PATH_RE.test(url)) url = "/mock" + url;
|
|
const init = {
|
|
method,
|
|
headers: opts.headers,
|
|
signal: controller.signal,
|
|
cache: opts.cache,
|
|
keepalive: opts.keepalive,
|
|
};
|
|
if (opts.body != null && method !== "GET" && method !== "HEAD") {
|
|
init.body = typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body);
|
|
}
|
|
const pending = Promise.resolve()
|
|
.then(() => fetch(url, init))
|
|
.then(
|
|
(response) => ({ response, [Symbol.dispose]: dispose }),
|
|
(error) => ({
|
|
error: String((error && error.message) || error),
|
|
status: 0,
|
|
errorCode: null,
|
|
responseStatus: 0,
|
|
errorKind: "network",
|
|
[Symbol.dispose]: dispose,
|
|
}),
|
|
);
|
|
// `$_t` disposes the handle *and* the settled value, so both carry one.
|
|
pending[Symbol.dispose] = dispose;
|
|
return pending;
|
|
},
|
|
cancel: async () => {},
|
|
};
|
|
|
|
// ---------------------------------------------------- projectless threads
|
|
// A conversation that is not attached to a project still needs a working
|
|
// directory. The desktop main process allocates one; here the Node host does
|
|
// it (host/projectless.mjs) and this is the renderer-side handle.
|
|
//
|
|
// `createPaths` returns a cancellable resource rather than a promise: the
|
|
// caller awaits `.wait()` and disposes the handle when it aborts.
|
|
const hostCall = async (path, body) => {
|
|
const res = await fetch(path, {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
if (!res.ok) throw new Error(`host call ${path} -> HTTP ${res.status}`);
|
|
const payload = await res.json();
|
|
if (payload && payload.error) throw new Error(payload.error.message || "host call failed");
|
|
return payload ? payload.result : undefined;
|
|
};
|
|
|
|
const projectlessThreads = {
|
|
createPaths: (options) => {
|
|
const controller = new AbortController();
|
|
const settled = hostCall("/__codex_projectless", { op: "createPaths", args: options ?? {} });
|
|
return {
|
|
wait: () => settled,
|
|
[Symbol.dispose]: () => controller.abort(),
|
|
};
|
|
},
|
|
readWorkspaceRoot: () => hostCall("/__codex_projectless", { op: "readWorkspaceRoot" }),
|
|
};
|
|
|
|
// ---------------------------------------------------------------- lifecycle
|
|
// Members that live in the Electron main process rather than the app server.
|
|
// Shapes follow the call sites in the renderer's bootstrap.
|
|
const noopDisposable = { [Symbol.dispose]() {} };
|
|
|
|
const explicit = {
|
|
"startup.whenReady": () => Promise.resolve(),
|
|
// The renderer reports paint milestones through this and chains `.catch`.
|
|
"startup.reach": async () => {},
|
|
|
|
applicationState,
|
|
httpFetch,
|
|
projectlessThreads,
|
|
// `Q$` falls back to `{ values: {} }` when this is absent, which is the
|
|
// right answer for a build with no desktop settings store behind it.
|
|
settings: null,
|
|
|
|
// `remoteConnections` is consumed as `e.catalog.subscribe(fn)` and
|
|
// `e.setRemoteControlEnabled(...)`; both must exist and return promises.
|
|
remoteConnections: permissive("remoteConnections", {
|
|
catalog: { subscribe: () => noopDisposable },
|
|
setRemoteControlEnabled: async () => {},
|
|
}),
|
|
|
|
"executionHosts.getDefaultHost": async () => ({ hostId: "local", kind: "local" }),
|
|
"executionHosts.getExecutionHostKeys": () => ["local"],
|
|
|
|
"threadReadState.initialize": async () => {},
|
|
"threadReadState.dispose": () => {},
|
|
|
|
"tracing.exportTraceBatch": async () => {},
|
|
"tracing.setSampleRate": async () => ({}),
|
|
|
|
// Local-only build: no remote telemetry pipeline to feed.
|
|
performanceTelemetry: { report: () => {}, flush: async () => {} },
|
|
|
|
// ---- host-owned app metadata -----------------------------------------
|
|
// Normally answered by the Electron main process from the bundle plist.
|
|
// Values are derived from the browser environment so the shell can print a
|
|
// real platform string instead of "unknown".
|
|
"appInfo.get": async () => ({
|
|
version: "26.1021.31345",
|
|
buildFlavor: "codex",
|
|
buildNumber: "26.1021.31345",
|
|
systemVersion: navigator.userAgent,
|
|
preloadAttribution: null,
|
|
chatGptApiBaseUrl: null,
|
|
}),
|
|
"appInfo.getSystemInfo": async () => ({
|
|
platform: "darwin",
|
|
osVersion: navigator.userAgent,
|
|
arch: "arm64",
|
|
release: navigator.userAgent,
|
|
}),
|
|
|
|
// ---- thread configuration ---------------------------------------------
|
|
// The desktop main process reads two things off disk before a thread can
|
|
// start: developer instructions and the MCP block of the Codex config.
|
|
// Neither exists for a browser build. The renderer dereferences
|
|
// `.instructions` directly, so the shape matters even when it is empty.
|
|
threadConfiguration: {
|
|
readDeveloperInstructions: async () => ({ instructions: null }),
|
|
readMcpConfig: async () => ({ config: null }),
|
|
},
|
|
|
|
// The desktop shell auto-resolves pending user-input prompts after a
|
|
// timeout, and tracks whether a conversation is on screen so it only fires
|
|
// for visible chats. A local build never auto-resolves anything, but the
|
|
// renderer calls these unconditionally (only the method itself is
|
|
// optional-chained), so the namespace must exist.
|
|
requestUserInputAutoResolution: {
|
|
setConversationPresented: async () => {},
|
|
recordConversationActivity: async () => {},
|
|
snooze: async () => {},
|
|
},
|
|
|
|
// ---- git integration ----------------------------------------------------
|
|
// The desktop main process owns git queries (`RA('git')`). The UI gates the
|
|
// composer on the `['git','availability']` query, which is only ever filled
|
|
// as a side effect of a `stable-metadata` answer — so the service must
|
|
// exist and answer non-null, or Send stays disabled with "Loading…".
|
|
//
|
|
// A7: instead of the old stub (always `{available:false, …}`), forward to
|
|
// the host's POST /__codex_git, which runs read-only git commands inside
|
|
// the cwd the renderer passes in params. The renderer calls this as
|
|
// request({method, params:{cwd, …}}) (two-arg request(method, params)
|
|
// is accepted too). Without a cwd there is nothing to inspect, so fall
|
|
// back to the old stub shape for stable-metadata and null otherwise.
|
|
// stable-metadata keeps the original six-field shape, now populated.
|
|
git: permissive("git", {
|
|
request: async (arg1, arg2) => {
|
|
let method;
|
|
let params = {};
|
|
if (typeof arg1 === "string") {
|
|
method = arg1;
|
|
params = arg2 ?? {};
|
|
} else {
|
|
({ method, params = {} } = arg1 ?? {});
|
|
}
|
|
const cwd = typeof params?.cwd === "string" ? params.cwd : null;
|
|
if (method === "stable-metadata" && !cwd) {
|
|
return { available: false, branch: null, upstream: null, repository: null, state: null, worktree: null };
|
|
}
|
|
if (!cwd) return null;
|
|
try {
|
|
const res = await fetch("/__codex_git", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ op: method, cwd }),
|
|
});
|
|
if (!res.ok) return null;
|
|
const body = await res.json();
|
|
if (body && body.error) {
|
|
// Not a repo / bad cwd: report unavailable instead of throwing,
|
|
// so the composer degrades the way the old stub degraded it.
|
|
if (method === "stable-metadata") {
|
|
return { available: false, branch: null, upstream: null, repository: null, state: null, worktree: null };
|
|
}
|
|
return null;
|
|
}
|
|
return body ? body.result ?? null : null;
|
|
} catch {
|
|
if (method === "stable-metadata") {
|
|
return { available: false, branch: null, upstream: null, repository: null, state: null, worktree: null };
|
|
}
|
|
return null;
|
|
}
|
|
},
|
|
}),
|
|
|
|
// ---- statsig evaluations ----------------------------------------------
|
|
// In the desktop app the main process evaluates Statsig and pushes results
|
|
// over this service; the renderer never talks to the network for it. The
|
|
// local build pushes one truthful "no evaluations" payload: `payload: null`
|
|
// maps to `hasEvaluations: false`, feature defaults apply, and nothing
|
|
// ever polls a remote endpoint.
|
|
statsigEvaluations: {
|
|
subscribe: async (onValue) => {
|
|
queueMicrotask(() =>
|
|
onValue({
|
|
userId: null,
|
|
accountId: null,
|
|
authMethod: null,
|
|
stableId: "codex-web-local",
|
|
payload: null,
|
|
}),
|
|
);
|
|
return { [Symbol.dispose]() {} };
|
|
},
|
|
// The renderer publishes execution evaluations back to the host (which in
|
|
// Electron forwards them to the main process). There is nothing to feed
|
|
// locally; accept and drop.
|
|
publish: async () => {},
|
|
},
|
|
|
|
// Account state comes from the local app server, which reads ~/.codex.
|
|
"accountInfo.readAccountInfo": () => rpc("account/read", {}),
|
|
};
|
|
|
|
const explicitNulls = new Set(["histogramCollector", "statsig", "clientCoordination", "terminal", "devboxService"]);
|
|
|
|
function makeNamespace(path) {
|
|
const node = {};
|
|
const children = new Set();
|
|
for (const candidate of namespaces) {
|
|
if (!candidate.startsWith(path ? path + "/" : "")) continue;
|
|
const rest = path ? candidate.slice(path.length + 1) : candidate;
|
|
if (rest.includes("/")) continue;
|
|
children.add(rest);
|
|
}
|
|
for (const method of METHODS) {
|
|
if (!method.startsWith(path ? path + "/" : "")) continue;
|
|
const rest = path ? method.slice(path.length + 1) : method;
|
|
if (rest.includes("/")) continue;
|
|
node[rest] = (params) => rpc(method, params);
|
|
}
|
|
for (const child of children) {
|
|
node[child] = makeNamespace(path ? `${path}/${child}` : child);
|
|
}
|
|
return node;
|
|
}
|
|
|
|
function buildServices() {
|
|
const services = makeNamespace("");
|
|
|
|
// Overlay the host-only members, then strip the null ones.
|
|
for (const key of Object.keys(explicit)) {
|
|
const parts = key.split(".");
|
|
let cursor = services;
|
|
for (let i = 0; i < parts.length - 1; i += 1) {
|
|
cursor[parts[i]] = cursor[parts[i]] ?? {};
|
|
cursor = cursor[parts[i]];
|
|
}
|
|
cursor[parts[parts.length - 1]] = explicit[key];
|
|
}
|
|
for (const key of explicitNulls) services[key] = null;
|
|
|
|
// Report accesses to members we do not model.
|
|
return new Proxy(services, {
|
|
get(target, prop, receiver) {
|
|
if (typeof prop === "symbol") return Reflect.get(target, prop, receiver);
|
|
if (prop === "then" || prop === "toJSON") return undefined;
|
|
if (!(prop in target)) {
|
|
reportMissing(String(prop));
|
|
return undefined;
|
|
}
|
|
return Reflect.get(target, prop, receiver);
|
|
},
|
|
});
|
|
}
|
|
|
|
const servicesPromise = Promise.resolve().then(buildServices);
|
|
|
|
globalThis.__codexLocalHost = () => ({
|
|
services: servicesPromise,
|
|
onRpcBroken: () => () => {},
|
|
});
|
|
|
|
console.info("[codex-local-host] app host installed", METHODS.length, "methods");
|
|
})();
|