Files
codex-app-server/cloud/codex-ui-host/host/local-host.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

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