import { randomUUID } from "node:crypto"; import type { JsonObject, JsonValue } from "@codexhost/protocol-core"; import { clientThreadSnapshotSchema, type ClientInteraction, type ClientSnapshotOptions, type ClientThreadSnapshot, } from "@codexhost/shared-contracts"; import type { ClientChannelEvents } from "./client-channel-events.js"; import { ClientChannelRequestError } from "./client-channel-error.js"; import { compactClientSnapshot } from "./client-snapshot-compaction.js"; export type NativeClientReply = { result: JsonValue } | { error: { code: number; message: string } }; export interface NativeClientChannelDependencies { readonly events: ClientChannelEvents; request(method: string, params: JsonObject): Promise; respond(requestId: string | number, result: JsonValue): Promise; } interface PendingNativeInteraction { readonly requestId: string; readonly nativeRequestId: string | number; readonly threadId: string; readonly method: string; readonly params: JsonObject; readonly kind: "approval" | "question"; readonly interaction: ClientInteraction["interaction"]; } interface NativeConfiguration { readonly model?: string; readonly reasoningEffort?: string; } const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); const isJsonObject = (value: unknown): value is JsonObject => isRecord(value); const requestKey = (id: string | number): string => JSON.stringify([typeof id, id]); const NATIVE_REQUEST_PREFIX = "codexhost:native-interaction:"; function nativeConfigId(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function stringValue(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } function threadIdFromParams(params: Record): string | undefined { const direct = stringValue(params.threadId); if (direct) return direct; const thread = isRecord(params.thread) ? stringValue(params.thread.id) : undefined; if (thread) return thread; const turn = isRecord(params.turn) ? stringValue(params.turn.threadId) : undefined; if (turn) return turn; const item = isRecord(params.item) ? stringValue(params.item.threadId) : undefined; return item; } function resultObject(reply: NativeClientReply): JsonObject | undefined { return "result" in reply && isJsonObject(reply.result) ? reply.result : undefined; } function resultThread(reply: NativeClientReply): JsonObject | undefined { const result = resultObject(reply); return result && isJsonObject(result.thread) ? result.thread : undefined; } function availableDecisions(params: Record): string[] { if (!Array.isArray(params.availableDecisions)) return ["accept", "decline"]; return params.availableDecisions.filter((value): value is string => typeof value === "string"); } function decisionLabel(decision: string): string { switch (decision) { case "accept": return "Allow once"; case "acceptForSession": return "Allow for session"; case "acceptWithExecpolicyAmendment": return "Allow and remember"; case "decline": return "Decline"; case "cancel": return "Cancel"; default: return decision; } } function decisionEffect(decision: string): string { switch (decision) { case "accept": return "allowOnce"; case "acceptForSession": return "allowForSession"; case "acceptWithExecpolicyAmendment": return "allowAlways"; case "decline": case "cancel": return "deny"; default: return "unknown"; } } function approvalInteraction( method: string, params: JsonObject, ): Pick | undefined { if ( method !== "item/commandExecution/requestApproval" && method !== "item/fileChange/requestApproval" ) return undefined; const decisions = availableDecisions(params); const actions = decisions.map((id) => ({ id, label: decisionLabel(id), effect: decisionEffect(id), })); const command = stringValue(params.command); const reason = stringValue(params.reason); return { kind: "approval", interaction: { type: "approval", title: reason ?? (method === "item/commandExecution/requestApproval" ? "Allow command execution?" : "Allow file changes?"), ...(command ? { description: command } : {}), subject: params, actions, }, }; } function optionValue(value: unknown): string | undefined { if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return String(value); return undefined; } type ClientQuestion = Extract; type ClientQuestionItem = ClientQuestion["questions"][number]; function nativeQuestions(params: JsonObject): ClientQuestion | undefined { if (!Array.isArray(params.questions)) return undefined; const questions: ClientQuestion["questions"] = []; params.questions.forEach((value, index) => { if (!isRecord(value)) return; const id = stringValue(value.id) ?? `question-${index + 1}`; const prompt = stringValue(value.question) ?? stringValue(value.header) ?? id; const options = Array.isArray(value.options) ? value.options.flatMap((option) => { if (!isRecord(option)) return []; const optionValueText = optionValue(option.value) ?? optionValue(option.label) ?? optionValue(option.name); if (!optionValueText) return []; return [ { value: optionValueText, label: stringValue(option.label) ?? optionValueText, ...(stringValue(option.description) ? { description: stringValue(option.description) } : {}), }, ]; }) : []; const question: ClientQuestionItem = options.length ? { type: "choice", id, prompt, multiple: value.multiSelect === true, allowOther: value.allowOther === true, options, } : { type: "text", id, prompt, ...(value.secret === true ? { secret: true } : {}), ...(value.optional === true ? { optional: true } : {}), }; questions.push(question); }); return { type: "question", questions }; } function schemaQuestions(params: JsonObject): ClientQuestion | undefined { const schema = isRecord(params.requestedSchema) ? params.requestedSchema : undefined; if (!schema || !isRecord(schema.properties)) return undefined; const properties = schema.properties; for (const value of Object.values(properties)) { if (!isRecord(value)) return undefined; if (value.type !== undefined && value.type !== "string") return undefined; if (Array.isArray(value.enum) && value.enum.some((entry) => typeof entry !== "string")) return undefined; } const required = new Set( Array.isArray(schema.required) ? schema.required.filter((value): value is string => typeof value === "string") : [], ); const questions: ClientQuestion["questions"] = []; for (const [id, value] of Object.entries(properties)) { if (!isRecord(value)) continue; const prompt = stringValue(value.title) ?? stringValue(value.description) ?? id; const enumValues = Array.isArray(value.enum) ? value.enum.filter((entry): entry is string => typeof entry === "string") : []; const question: ClientQuestionItem = enumValues.length ? { type: "choice", id, prompt, multiple: false, allowOther: false, ...(required.has(id) ? {} : { optional: true }), options: enumValues.map((option) => ({ value: option, label: option })), } : { type: "text", id, prompt, ...(required.has(id) ? {} : { optional: true }), }; questions.push(question); } return { type: "question", questions }; } function nativeInteraction( method: string, params: JsonObject, ): Pick | undefined { const approval = approvalInteraction(method, params); if (approval) return approval; if (method === "item/tool/requestUserInput") { const interaction = nativeQuestions(params); return interaction ? { kind: "question", interaction } : undefined; } if (method === "mcpServer/elicitation/request") { // A form-shaped elicitation can be answered through the existing Web question card. // Permission grants are deliberately not treated as this generic approval shape. const interaction = schemaQuestions(params); return interaction ? { kind: "question", interaction } : undefined; } if (method === "item/permissions/requestApproval") return undefined; return undefined; } function answersContent(value: unknown): JsonObject { if (!isRecord(value)) return {}; const answers = isRecord(value.answers) ? value.answers : value; return Object.fromEntries( Object.entries(answers).flatMap(([id, answer]) => { if (isRecord(answer) && Array.isArray(answer.answers)) { return [[id, answer.answers.length === 1 ? answer.answers[0] : answer.answers]]; } return [[id, answer]]; }), ) as JsonObject; } function validMcpContent(params: JsonObject, content: JsonObject): boolean { const schema = isRecord(params.requestedSchema) ? params.requestedSchema : undefined; if (!schema || !isRecord(schema.properties)) return false; const properties = schema.properties; const required = Array.isArray(schema.required) ? schema.required.filter((entry): entry is string => typeof entry === "string") : []; if (required.some((key) => !Object.hasOwn(content, key))) return false; for (const [key, value] of Object.entries(content)) { const definition = properties[key]; if (!isRecord(definition)) return false; if (definition.type === "string" && typeof value !== "string") return false; if (definition.type === "number" && typeof value !== "number") return false; if (definition.type === "integer" && (!Number.isInteger(value) || typeof value !== "number")) return false; if (definition.type === "boolean" && typeof value !== "boolean") return false; if (Array.isArray(definition.enum) && !definition.enum.some((entry) => Object.is(entry, value))) return false; } return true; } function validatedDecision(pending: PendingNativeInteraction, value: JsonValue): JsonObject { const allowed = availableDecisions(pending.params); if (!isRecord(value)) throw new Error("Native approval response must be an object"); if (typeof value.decision === "string") { if (!allowed.includes(value.decision)) throw new Error(`Native approval decision '${value.decision}' is not available`); return value as JsonObject; } const action = typeof value.action === "string" ? value.action : undefined; const content = isRecord(value.content) ? value.content : undefined; const actionId = content && stringValue(content.actionId); const decision = actionId ?? action; if (!decision || !allowed.includes(decision)) throw new Error("Native approval response does not select an available decision"); return { decision }; } function nativeResponse(pending: PendingNativeInteraction, value: JsonValue): JsonValue { if (pending.method === "item/commandExecution/requestApproval") { return validatedDecision(pending, value); } if (pending.method === "item/fileChange/requestApproval") { return validatedDecision(pending, value); } if (pending.method === "mcpServer/elicitation/request" && isRecord(value)) { if (typeof value.action === "string") return value as JsonObject; if (isRecord(value.answers)) { const content = answersContent(value); if (!validMcpContent(pending.params, content)) throw new Error("Native elicitation response does not match its schema"); return { action: "accept", content }; } } return value; } function turnIdFromPage(turn: unknown): string | undefined { if (!isRecord(turn) || typeof turn.id !== "string") return undefined; return turn.status === "inProgress" ? turn.id : undefined; } const RESUME_BEFORE = new Set(["turn/start", "turn/steer", "turn/interrupt"]); const CHANGED_METHODS = new Set([ "thread/resume", "thread/start", "thread/name/set", "thread/archive", "thread/unarchive", "turn/start", "turn/steer", "turn/interrupt", ]); /** Native Codex side of the Host client channel. It is attached to the shared * service's existing official session; it never creates an app-server. */ export class NativeClientChannel { readonly #host: NativeClientChannelDependencies; readonly #knownThreads = new Set(); readonly #resumedThreads = new Set(); readonly #configuration = new Map(); readonly #pending = new Map(); readonly #pendingNativeIds = new Map(); #generationToken = 0; constructor(host: NativeClientChannelDependencies) { this.#host = host; } noteThread(threadId: string): void { this.#knownThreads.add(threadId); } observeMessage(value: JsonValue): void { if (!isRecord(value) || typeof value.method !== "string" || !isRecord(value.params)) return; const params = value.params as JsonObject; const threadId = threadIdFromParams(params); if (threadId) { this.noteThread(threadId); this.#host.events.changed(threadId, value.method); } if (value.method === "serverRequest/resolved") { const requestId = params.requestId; if (typeof requestId === "string" || typeof requestId === "number") { const publicId = this.#pendingNativeIds.get(requestKey(requestId)) ?? (typeof requestId === "string" && this.#pending.has(requestId) ? requestId : undefined); if (publicId) this.#deletePending(publicId); } return; } if (typeof value.id !== "string" && typeof value.id !== "number") return; const interaction = nativeInteraction(value.method, params); if (!interaction || !threadId) return; if (this.#pendingNativeIds.has(requestKey(value.id))) return; const publicId = `${NATIVE_REQUEST_PREFIX}${this.#generationToken}:${randomUUID()}`; this.#pending.set(publicId, { requestId: publicId, nativeRequestId: value.id, threadId, method: value.method, params, ...interaction, }); this.#pendingNativeIds.set(requestKey(value.id), publicId); } #deletePending(publicId: string): void { const pending = this.#pending.get(publicId); if (!pending) return; this.#pending.delete(publicId); this.#pendingNativeIds.delete(requestKey(pending.nativeRequestId)); } private observeResponse(method: string, params: JsonObject, reply: NativeClientReply): void { if ("error" in reply) return; const thread = resultThread(reply); if (thread && typeof thread.id === "string") { this.noteThread(thread.id); if (method === "thread/resume" || method === "thread/start") this.#resumedThreads.add(thread.id); } const threadId = threadIdFromParams(params); if (threadId) { this.noteThread(threadId); if (method === "thread/resume") this.#resumedThreads.add(threadId); } if (method === "thread/resume" && threadId) { const result = resultObject(reply); const model = nativeConfigId(result?.model); const reasoningEffort = nativeConfigId(result?.reasoningEffort); if (model || reasoningEffort) this.#configuration.set(threadId, { ...(model ? { model } : {}), ...(reasoningEffort ? { reasoningEffort } : {}), }); } if (method === "thread/list") { const data = resultObject(reply)?.data; if (Array.isArray(data)) for (const item of data) if (isRecord(item) && typeof item.id === "string") this.noteThread(item.id); } } async request(method: string, params: JsonObject): Promise { const generation = this.#generationToken; const stale = (): NativeClientReply => ({ error: { code: -32094, message: "Host generation changed; operation outcome may be unknown. Do not retry automatically.", }, }); const threadId = threadIdFromParams(params); if (threadId && RESUME_BEFORE.has(method) && !this.#resumedThreads.has(threadId)) { const resumed = await this.#host.request("thread/resume", { threadId }); if (generation !== this.#generationToken) return stale(); this.observeResponse("thread/resume", { threadId }, resumed); if ("error" in resumed) return resumed; } const reply = await this.#host.request(method, params); if (generation !== this.#generationToken) return stale(); this.observeResponse(method, params, reply); if (threadId && CHANGED_METHODS.has(method) && !("error" in reply)) this.#host.events.changed(threadId, method); return reply; } interactions(threadId: string): ClientInteraction[] { return [...this.#pending.values()] .filter((pending) => pending.threadId === threadId) .map((pending) => ({ requestId: pending.requestId, threadId, kind: pending.kind, interaction: pending.interaction, request: { method: pending.method, params: pending.params }, })); } async snapshot(threadId: string, options: ClientSnapshotOptions): Promise { const cursor = this.#host.events.cursor; const generation = this.#generationToken; const assertGeneration = () => { if (generation !== this.#generationToken) throw new ClientChannelRequestError(-32094, "Host generation changed; refresh the Thread"); }; try { if (!this.#resumedThreads.has(threadId)) { const resumed = await this.request("thread/resume", { threadId }); assertGeneration(); if ("error" in resumed) throw new ClientChannelRequestError(resumed.error.code, resumed.error.message); } const read = await this.request("thread/read", { threadId, includeTurns: false }); assertGeneration(); if ("error" in read) throw new ClientChannelRequestError(read.error.code, read.error.message); const thread = resultThread(read); if (!thread || thread.id !== threadId) throw new Error("Native Codex did not return the requested Thread"); if (typeof thread.modelProvider !== "string" || thread.modelProvider.length === 0) throw new Error("Native Codex Thread has no modelProvider"); const turnsReply = await this.request("thread/turns/list", { threadId, limit: 5, sortDirection: "desc", itemsView: "full", }); assertGeneration(); if ("error" in turnsReply) throw new ClientChannelRequestError(turnsReply.error.code, turnsReply.error.message); const turnsResult = resultObject(turnsReply) ?? {}; const data = Array.isArray(turnsResult.data) ? turnsResult.data : []; const turnsPage = { data, nextCursor: typeof turnsResult.nextCursor === "string" ? turnsResult.nextCursor : null, }; const activeTurnId = data.map(turnIdFromPage).find((id): id is string => id !== undefined); const configuration = this.#configuration.get(threadId); const snapshot = clientThreadSnapshotSchema.parse({ cursor, ...(activeTurnId ? { activeTurnId } : {}), thread: { ...thread, turns: [] }, turnsPage, // Native configuration is owned by official Codex. Only values returned // by native resume are presented; no Host defaults are synthesized. configuration: { ...(configuration ? { native: configuration } : {}), }, interactions: this.interactions(threadId), }); assertGeneration(); return compactClientSnapshot(snapshot, options); } catch (error) { if (generation !== this.#generationToken) throw new ClientChannelRequestError(-32094, "Host generation changed; refresh the Thread"); throw error; } } async respond(requestId: string | number, threadId: string, result: JsonValue): Promise { const pending = typeof requestId === "string" ? this.#pending.get(requestId) : undefined; if (!pending) return false; if (pending.threadId !== threadId) throw new Error("Interaction does not belong to this Thread"); const response = nativeResponse(pending, result); // Claim before delivery: a transport failure has an ambiguous outcome and // must not permit a second native writer to answer the request. this.#deletePending(pending.requestId); await this.#host.respond(pending.nativeRequestId, response); this.#host.events.changed(pending.threadId, "serverRequest/resolved"); return true; } resetGeneration(): void { const threads = [...this.#knownThreads]; this.#generationToken += 1; this.#knownThreads.clear(); this.#resumedThreads.clear(); this.#pending.clear(); this.#pendingNativeIds.clear(); this.#configuration.clear(); for (const threadId of threads) this.#host.events.changed(threadId, "thread/status/changed"); } isNativeRequestId(value: string | number): value is string { return typeof value === "string" && value.startsWith(NATIVE_REQUEST_PREFIX); } isStaleRequestId(value: string | number): boolean { if (!this.isNativeRequestId(value)) return false; const prefix = `${NATIVE_REQUEST_PREFIX}${this.#generationToken}:`; return !value.startsWith(prefix); } }