feat: wire native Codex UI as /console entry, keep classic console at /console-classic
ci / test (push) Successful in 45m25s
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
This commit is contained in:
1 parent
41f3985248
commit
3e0ff898e0
20 files changed
+3018
-24
No files matched your search
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* 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");
|
||||
})();
|
||||
Reference in new issue
Block a user