/** Connect to one shared Host; native Launcher starts it on demand without Desktop. * This gateway never owns a Mapping Store or spawns a Harness. Explicit CDP is legacy-only. */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { CdpClient, listCdpTargets, HostClientChannel, discoverHostClientChannel, ensureHostService, isHostClientChannelOnline, type HostServiceLaunch, type HostClientUpdate, } from "@codexhost/desktop-control"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { clientThreadPinsSchema, type ClientThreadSnapshot, type ClientWorkspaceSnapshot, type ClientChannelResponse, type ClientChannelCursor, type ClientSnapshotOptions, } from "@codexhost/shared-contracts"; import { RpcError } from "./transport.ts"; export interface ChHostClient { request(method: string, params: Record): Promise; projects(threadIds: string[]): Promise; /** Shared Host native sections; returns the confirmed official and External pin order. */ pins(change?: ChPinChange): Promise; close(): void; readonly realtime?: { subscribe(listener: (event: HostClientUpdate) => void, after?: ClientChannelCursor): () => void; request(method: string, params: Record): Promise; snapshot(threadId: string, options?: ClientSnapshotOptions): Promise; respond(input: ClientChannelResponse): Promise<{ resolved: boolean }>; }; } export interface ChPinChange { threadId: string; pinned: boolean; } export interface ChProjectSnapshot { projects: Array<{ id: string; name: string; rootPaths: string[] }>; assignments: Record; projectless: string[]; } const exec = promisify(execFile); const METHODS = new Set([ "codexhost/thread/pins/list", "codexhost/thread/pins/set", "codexhost/harness/display-settings/get", "codexhost/harness/display-settings/set", "codexhost/harness/display-settings/capabilities", "codexhost/harness/preferences/get", "codexhost/harness/preferences/patch", "thread/list", "thread/read", "thread/turns/list", "thread/revert", "thread/start", "thread/resume", "thread/fork", "thread/name/set", "thread/archive", "thread/unarchive", "turn/start", "turn/interrupt", "turn/steer", "thread/queue/list", "thread/queue/add", "thread/queue/update", "thread/queue/delete", "thread/queue/reorder", "thread/queue/start", "codexhost/thread/queue/send-now", "codexhost/harness/plugins/list", "codexhost/harness/inspect", "codexhost/thread/inspect", "codexhost/thread/usage/inspect", "codexhost/thread/ownership/list", "codexhost/thread/catalog/read", "codexhost/thread/model/select", "codexhost/thread/thinking/select", "codexhost/thread/permission-mode/select", "codexhost/thread/commands/inspect", "codexhost/skills/list", "codexhost/thread/command/execute", "codexhost/thread/fork", "codexhost/harness/session-import/sources", "codexhost/harness/session-import/list", "codexhost/harness/session-import/import", ]); async function requestPins( request: ChHostClient["request"], change?: ChPinChange, ): Promise { const value = await request( change ? "codexhost/thread/pins/set" : "codexhost/thread/pins/list", change ? { ...change } : {}, ); const parsed = clientThreadPinsSchema.safeParse(value); if (!parsed.success) throw new RpcError("host/pins-invalid", "Host returned an invalid native pin snapshot."); if (change && parsed.data.threadIds.includes(change.threadId) !== change.pinned) throw new RpcError("host/pins-unconfirmed", "Host did not confirm the requested pin state."); return [...new Set(parsed.data.threadIds)]; } /** Discover only an existing Codex process's debugger flag; no process is launched or signalled. */ async function desktopEndpoints(explicit?: string): Promise { if (explicit) return [explicit]; if (process.platform === "win32") throw new RpcError( "host/endpoint-required", "Specify --ch-cdp for the running Desktop on Windows.", ); const { stdout } = await exec("ps", ["-axo", "command="], { maxBuffer: 4 * 1024 * 1024 }); const endpoints = new Set(); for (const line of stdout.split("\n")) { if (!/\/(?:Codex|ChatGPT)\.app\//u.test(line)) continue; const port = /--remote-debugging-port(?:=|\s+)(\d+)/u.exec(line)?.[1]; if (port) endpoints.add(`http://127.0.0.1:${port}`); } return [...endpoints]; } /** Connection loss does not retry a write: its outcome may already have applied. */ export class DesktopChHostClient implements ChHostClient { private client: CdpClient | undefined; private connecting: Promise | undefined; private closed = false; constructor(private readonly endpoint?: string) {} private connect(): Promise { if (this.closed) return Promise.reject(new RpcError("host/offline", "The CH connection is closed.")); if (this.client) return Promise.resolve(this.client); this.connecting ??= this.discover().finally(() => { this.connecting = undefined; }); return this.connecting; } private async discover(): Promise { for (const endpoint of await desktopEndpoints(this.endpoint)) { const targets = await listCdpTargets(endpoint).catch(() => []); for (const target of targets.filter((value) => value.type === "page")) { const client = await CdpClient.connect(target.webSocketDebuggerUrl, { commandTimeoutMs: 120_000, }).catch(() => null); if (!client) continue; try { const ready = await client.evaluate( "!!window.__codexhostRendererBindingProbeV1 && (window.__codexhostHostRoutingV1?.knownHostIds?.() ?? []).includes('local')", ); if (ready && !this.closed) { this.client = client; return client; } } catch { /* A detached or unrelated window is not the local Host. */ } client.close(); } } throw new RpcError( "host/offline", "The local codexhost Desktop Host is unavailable. Start Codex through codexhost; Web will not create an independent session instead.", ); } async request(method: string, params: Record): Promise { if (!METHODS.has(method)) throw new RpcError("host/method-unavailable", `Unsupported CH method: ${method}`); const client = await this.connect(); // JSON literals only, never interpolate a path, model ID or user message as executable code. const expression = `(async () => { const route = window.__codexhostHostRoutingV1?.forHost('local'); if (!route) return {ok:false,error:'Local Host unavailable'}; try { return {ok:true,value:await route.manager.sendRequest(${JSON.stringify(method)},${JSON.stringify(params)})}; } catch(error) { return {ok:false,error:String(error?.message ?? error),code:error?.code}; } })()`; try { const reply = await client.evaluate<{ ok: boolean; value?: T; error?: string; code?: number; }>(expression); if (!reply.ok) throw new RpcError( "host/rejected", reply.error ?? "CH rejected the operation.", reply.code === undefined ? {} : { hostCode: reply.code }, ); return reply.value as T; } catch (error) { if (!(error instanceof RpcError)) { this.client = undefined; client.close(); } throw error instanceof RpcError ? error : new RpcError( "host/disconnected", "CH connection lost; the operation outcome may be unknown. Refresh before retrying.", ); } } /** Read existing native project metadata; never save assignments or create GUI projects. */ async projects(threadIds: string[]): Promise { const client = await this.connect(); const expression = `(async () => { const manager = window.__codexhostHostRoutingV1?.forHost('local')?.manager; const storage = manager?.threadWorkspaceStorage?.storage; if (!storage?.readGlobalState) throw new Error('Native project metadata unavailable'); const [local, order, assigned, projectlessIds] = await Promise.all(['local-projects','project-order','thread-project-assignments','projectless-thread-ids'].map(key => storage.readGlobalState(key))); const ids = ${JSON.stringify(threadIds)}; const projects = Object.values(local ?? {}).filter(project => Array.isArray(order) && order.includes(project.id)).map(project => ({id:project.id,name:project.name,rootPaths:project.rootPaths})); return {projects,assignments:Object.fromEntries(ids.flatMap(id => assigned?.[id]?.projectKind === 'local' ? [[id,assigned[id].projectId]] : [])),projectless:ids.filter(id => Array.isArray(projectlessIds) && projectlessIds.includes(id))}; })()`; try { return await client.evaluate(expression); } catch { throw new RpcError( "host/projects-unavailable", "Native project metadata is unavailable; execution cwd will not be guessed as a project.", ); } } pins(change?: ChPinChange): Promise { return requestPins((method, params) => this.request(method, params), change); } close(): void { this.closed = true; this.client?.close(); this.client = undefined; } } /** Prefer the stable owner channel; explicit CDP remains the legacy/test route. * Once selected, an event connection never silently falls back after a failure. */ export async function createChHostClient( options: { cdp?: string; directory?: string; launch?: HostServiceLaunch } = {}, ): Promise { const desktop = new DesktopChHostClient(options.cdp); const directory = options.directory ?? join(options.launch?.dataDirectory ?? join(homedir(), ".codexhost"), "client-hosts"); const legacy = options.cdp && !options.directory; let endpoint = legacy ? null : await discoverHostClientChannel(directory); if (!legacy && options.launch && (!endpoint || !(await isHostClientChannelOnline(endpoint)))) { if (resolve(directory) !== join(options.launch.dataDirectory, "client-hosts")) throw new RpcError( "host/unavailable", "Independent Host control directory must be /client-hosts; specify --ch-data for startup.", ); await ensureHostService(options.launch); endpoint = await discoverHostClientChannel(directory); } if (!endpoint && (options.directory !== undefined || (!legacy && options.launch))) throw new RpcError( "host/unavailable", "CH client channel not found; start an updated local Host or check --ch-control-directory.", ); if (!endpoint) return desktop; const launch = endpoint.owner === "service" ? options.launch : undefined; const channel = new HostClientChannel( directory, endpoint, launch ? () => ensureHostService(launch) : undefined, ); try { await channel.start(); } catch (error) { channel.close(); throw error; } async function hostOperation(operation: Promise): Promise { try { return await operation; } catch (error) { const code = error instanceof Error && "code" in error ? error.code : undefined; throw new RpcError( code === -32094 ? "host/resync" : "host/rejected", error instanceof Error ? error.message : "Host request failed", code === undefined ? {} : { hostCode: code }, ); } } const request: ChHostClient["request"] = (method, params) => hostOperation(channel.request(method, params)); return { realtime: { subscribe: (listener, after) => channel.subscribe(listener, after), request, snapshot: (threadId, options) => hostOperation(channel.snapshot(threadId, options)), respond: (input) => hostOperation(channel.respond(input)), }, request, projects: (ids) => endpoint?.owner === "service" ? channel.request("codexhost/workspace/read", { threadIds: ids }) : desktop.projects(ids), pins: (change) => requestPins((method, params) => channel.request(method, params), change), close() { channel.close(); desktop.close(); }, }; }