diff --git a/docs/provider-codex-design.md b/docs/provider-codex-design.md new file mode 100644 index 0000000..0739707 --- /dev/null +++ b/docs/provider-codex-design.md @@ -0,0 +1,114 @@ +# Codex as a codeoid backend — implementation design + +> Untracked design doc (convention: like `provider-followups-handoff.md`). +> Grounded 2026-07-10 against the REAL protocol schema of `@openai/codex@0.144.1` +> (`codex app-server generate-json-schema` / `generate-ts` — regenerate, don't trust +> this doc over the generator). Follow the pi provider template throughout +> (#131–135 + `feat/pi-bundled`). + +## Why + +Codex brings agentic GPT with **ChatGPT-subscription auth** (codex owns its login +store in `~/.codex`, incl. `ChatgptAuthTokensRefresh` — codeoid never touches +tokens). Fills the same slot pi does for its providers: a native harness driven at +full fidelity, NOT a raw-API reimplementation. The stateless `openai` provider +stays as the API-key chat tier. + +## Integration surface (verified against 0.144.1) + +- **Transport:** `codex app-server` — JSON-RPC over stdio, experimental but + self-describing: `generate-ts` emits TypeScript bindings, `generate-json-schema` + emits the schema (v2 = 516 defs). Vendor the generated types under + `src/daemon/providers/codex/protocol/` (build step or checked-in, pinned to the + bundled codex version). +- **Backing session = thread:** `ThreadStart` / `ThreadResume` / `ThreadFork` / + `ThreadArchive`. Thread id is the `backingSessionId` (mirrors pi's session file). + `resetToNewSession` = start a fresh thread. +- **Event stream → ProviderEvent (near 1:1):** + | codex v2 notification | ProviderEvent | + |---|---| + | `AgentMessageDelta` | `text_delta` (+ accumulate for `text_done`) | + | `ReasoningTextDelta` / `ReasoningSummaryTextDelta` | `thinking_delta` | + | `ItemStarted` / `ItemCompleted` (command, file change, tool call items) | `tool_start` / `tool_complete` | + | `TurnStarted` / `TurnCompleted` | turn boundary → `turn_done` (`NormalizedTurnResult`) | + | `ThreadTokenUsageUpdated` | `llm_call` usage | + | `Error` | `error` | + | `TurnPlanUpdated` / `PlanDelta` | `custom_message` (parts) — optional v2 | +- **Approvals are NATIVE server→client requests** — the big win vs pi (no injected + bridge extension): `CommandExecutionRequestApproval`, + `FileChangeRequestApproval`, `ApplyPatchApproval`, `ExecCommandApproval`, + `PermissionsRequestApproval`. Handler: translate each into codeoid's + `canUseTool(toolId, approvalId, toolName, input)` and answer the JSON-RPC + request with approve/deny. Set codex's own policy to always-ask + (`-c` overrides / `TurnEnvironmentParams`) so EVERY privileged action routes + through codeoid — fail-closed parity with the pi bridge: if approval policy + can't be pinned to ask, fail the turn, don't run ungated. +- **`ToolRequestUserInput`** (questions with options) → `requestUserInput` + (`UiRequestFn` → `session.ui_request` from #131). Perfect fit. +- **Dynamic commands:** custom prompts live in `~/.codex/prompts`; if the server + exposes a list (check `ClientRequest` defs), map to `listCommands()`. + +## Provider shape + +`src/daemon/providers/codex/{index,translate,resolve}.ts`: + +- `CodexProvider implements SessionProvider` — warm (keep the app-server child + alive across turns), `pushMidTurn` likely unsupported at first (interrupt + + re-send; check `TurnAbort`/interrupt params in schema). +- `seedFromHistory` = `renderHistorySeed` string prepend (same ceiling as + claude/pi; `ThreadResume` only resumes codex's OWN threads). +- **Spawn env**: `buildSubprocessEnv` policy — `exact: []`, prefixes + `["OPENAI_", "CODEX_"]` is NOT enough on its own: codex reads `~/.codex` + via HOME (already in shared basics). DENY list already protects `CODEOID_*`. + Mirror `buildPiEnv` with a `buildCodexEnv`. +- **Resolution/bundling**: reuse `feat/pi-bundled` infra — + `resolveCodexCommand` (config `providers.codex.command` → PATH → bundled + `@openai/codex` optionalDependency). NOTE: `@openai/codex` ships a Rust binary + per-platform (native pkg) — bundled fallback spawns the platform binary from + the package, NOT `process.execPath + js`. Verify install size/platform matrix + before pinning; if too heavy, ship resolution + `markUnavailable` hint only + (the registry infra from #141 handles the UX either way). +- **Config**: `providers.codex: { enabled: true, command: "codex" }` — same + schema shape as pi. +- **Registry**: factory gated on resolution, `markUnavailable` hint otherwise. + +## Tests (pi template) + +- `fake-codex` fixture: a bun script speaking newline JSON-RPC — initialize, + thread start, scripted turn with AgentMessageDelta/Item*/Turn* notifications, + and an approval server-request the test answers through the provider. +- Provider unit tests mirroring `provider-pi.test.ts` T1–T11: turn translation, + approval allow/deny round-trip, fail-closed when approval policy can't be + pinned, usage deltas, seedFromHistory prepend, missing binary. +- Wire is already provider-agnostic — no protocol changes expected at all. + +## Open questions (decide while building) + +1. v2 vs v1 protocol surface — generate both, target v2 (`thread/turn` model); + confirm which one `app-server` speaks by default and whether `initialize` + negotiates. +2. Interrupt semantics — find the turn-abort request; map `TurnRun.interrupt()`. +3. Sandbox interplay — codex has its own sandbox (`codex sandbox`, + `sandbox_permissions` config). Decide default: disable codex sandboxing and + rely on codeoid approvals (pi parity), or keep both layers. Start with both + (defense in depth), document. +4. Model catalog — `listModels()` from config/`Account*` requests, else static. + +## Suggested first PR slice + +Resolution + registry entry + spawn + initialize/thread-start + text-only turn +translation with approvals mapped (fail-closed) + fake-codex tests. Items/plan +richness and commands can follow wire-additively. + +## Probe results (2026-07-10, live against @openai/codex@0.144.1 app-server) + +- **Framing:** newline-delimited JSON-RPC 2.0 over stdio. No Content-Length. +- **Handshake:** `initialize {clientInfo:{name,title,version}}` → result `{userAgent, codexHome,...}`; then notification `initialized`. +- **Thread:** `thread/start {cwd, approvalPolicy?, sandbox?, developerInstructions?, baseInstructions?, model?, ephemeral?}` → `{thread:{id, ...}}` + `thread/started` notif. `thread/resume {threadId,...}` to reattach. Thread id = backingSessionId. +- **Turn:** `turn/start {threadId, input:[{type:"text", text, text_elements:[]}], cwd?, approvalPolicy?, sandboxPolicy?, model?, effort?}`. Mid-turn: `turn/steer`. Interrupt: `turn/interrupt`. +- **systemPromptAppend** → `developerInstructions` on thread/start (verify precedence vs baseInstructions). +- **Streaming notifications:** `item/agentMessage/delta` (text), `item/reasoning/textDelta` + `item/reasoning/summaryTextDelta` (thinking), `item/started`/`item/completed` (items: commandExecution, fileChange, mcpToolCall, webSearch, agentMessage, reasoning, plan), `item/commandExecution/outputDelta`, `turn/started`/`turn/completed` (usage), `thread/tokenUsage/updated`, `error`. +- **Server→client approval requests (answer the JSON-RPC id):** `item/commandExecution/requestApproval` / `item/fileChange/requestApproval` / `item/permissions/requestApproval` `{threadId, turnId, itemId, approvalId?, ...}`; `item/tool/requestUserInput` (questions+options → session.ui_request). +- **Models:** `model/list {}` → `{data:[{id, model, displayName, description, supportedReasoningEfforts[...]}]}` — direct `listModels()`. +- **Auth:** `getAuthStatus`, `account/login/start` (ChatGPT subscription login lives in codex; `~/.codex` via HOME). +- Generated TS bindings: `codex app-server generate-ts` (vendor under providers/codex/protocol, pinned). diff --git a/src/config.ts b/src/config.ts index c111293..a106aff 100644 --- a/src/config.ts +++ b/src/config.ts @@ -425,8 +425,20 @@ const ProvidersSchema = z command: z.string().default("pi"), }) .default({ enabled: true, command: "pi" }), + /** OpenAI Codex CLI driven over `codex app-server` (JSON-RPC/stdio). */ + codex: z + .object({ + /** Register the codex backend in the provider catalog. */ + enabled: z.boolean().default(true), + /** Binary to spawn — override for a wrapper script or absolute path. */ + command: z.string().default("codex"), + }) + .default({ enabled: true, command: "codex" }), }) - .default({ pi: { enabled: true, command: "pi" } }); + .default({ + pi: { enabled: true, command: "pi" }, + codex: { enabled: true, command: "codex" }, + }); const RootSchema = z.object({ daemonUrl: z.string().default("ws://127.0.0.1:7400"), @@ -564,6 +576,10 @@ export interface CodeoidConfig { enabled: boolean; command: string; }; + codex: { + enabled: boolean; + command: string; + }; }; /** * Daemon-native hooks — dispatched at Session's seams for every backend. diff --git a/src/daemon/providers/codex/index.ts b/src/daemon/providers/codex/index.ts new file mode 100644 index 0000000..876148f --- /dev/null +++ b/src/daemon/providers/codex/index.ts @@ -0,0 +1,460 @@ +/** + * CodexProvider — OpenAI Codex CLI as a codeoid backend. + * + * Drives `codex app-server` (JSON-RPC over stdio, see rpc.ts) so codex's + * own harness does the work — tools, sandbox, ChatGPT-subscription auth in + * ~/.codex — while codeoid owns approvals, scrollback, and history. + * First slice (see docs/provider-codex-design.md): + * + * - warm process; one codex THREAD per backing session (thread id is the + * backingSessionId; resetToNewSession starts a fresh thread) + * - `approvalPolicy: "untrusted"` is pinned on every turn so codex asks + * before privileged actions; each server-side approval request routes + * through codeoid's canUseTool — approve/deny round-trips natively (no + * injected bridge, unlike pi) + * - `item/tool/requestUserInput` → requestUserInput (session.ui_request) + * - text/reasoning deltas stream; command/fileChange/mcp items surface + * as tool records; usage from turn/completed + * - seedFromHistory prepends renderHistorySeed to the first prompt (the + * warm-backend ceiling; codex threads only resume codex's own state) + * + * Non-gated items (codex ran something its policy considers trusted, e.g. + * read-only commands) are recorded retrospectively at item/completed as a + * paired tool_start/tool_complete — visible in scrollback + canonical + * history without pretending codeoid gated them. + */ + +import { randomUUID } from "node:crypto"; +import { AsyncQueue } from "../../async-queue.js"; +import type { Store } from "../../store.js"; +import type { + ModelInfo, + NormalizedTurnResult, + ProviderEvent, + SessionProvider, + ToolApprovalFn, + TurnOpts, + TurnRun, + UiRequestFn, +} from "../interface.js"; +import { renderHistorySeed, type CanonicalTurn } from "../canonical.js"; +import { buildCodexEnv } from "../env.js"; +import { CodexRpcProcess } from "./rpc.js"; + +export interface CodexProviderInit { + sessionId: string; + /** codex thread id from a previous run, or the codeoid session id on first run. */ + initialBackingId: string; + /** Resolved binary (see resolve.ts). */ + command: string; + argsPrefix?: string[]; + store: Store; + onModels?: ( + models: ReadonlyArray<{ value: string; displayName: string; description?: string }>, + ) => void; +} + +/** Item types that surface as tool records (vs text/reasoning streams). */ +const TOOL_ITEM_TYPES = new Set([ + "commandExecution", + "fileChange", + "mcpToolCall", + "webSearch", + "tool", +]); + +export class CodexProvider implements SessionProvider { + readonly id = "codex"; + readonly displayName = "Codex (OpenAI)"; + + onRecoveryNeeded: ((content: string) => void) | undefined; + + #sessionId: string; + #backingSessionId: string; + #command: string; + #argsPrefix: string[]; + #onModels?: CodexProviderInit["onModels"]; + + #proc: CodexRpcProcess | null = null; + #threadId: string | null = null; + #hasQueried = false; + #modelsReported = false; + #pendingHistorySeed: string | null = null; + + // Per-turn wiring. + #turnQueue: AsyncQueue | null = null; + #canUseTool: ToolApprovalFn | null = null; + #requestUserInput: UiRequestFn | undefined; + #turnStartedAt = 0; + #turnModel = "codex"; + #currentTurnId: string | null = null; + /** item id → {name, input} for items already announced via tool_start. */ + #announcedItems = new Map }>(); + + constructor(init: CodexProviderInit) { + this.#sessionId = init.sessionId; + this.#backingSessionId = init.initialBackingId; + this.#command = init.command; + this.#argsPrefix = init.argsPrefix ?? []; + this.#onModels = init.onModels; + } + + get backingSessionId(): string { + return this.#backingSessionId; + } + get hasQueried(): boolean { + return this.#hasQueried; + } + get queuedMessages(): number { + return 0; + } + + resetToNewSession(newBackingId: string): void { + this.#backingSessionId = newBackingId; + this.#threadId = null; + this.#hasQueried = false; + } + + setHasQueried(value: boolean): void { + this.#hasQueried = value; + } + + seedFromHistory(history: readonly CanonicalTurn[]): void { + const seed = renderHistorySeed(history); + this.#pendingHistorySeed = seed.length > 0 ? seed : null; + } + + runTurn(opts: TurnOpts): TurnRun { + const queue = new AsyncQueue(); + this.#turnQueue = queue; + this.#canUseTool = opts.canUseTool; + this.#requestUserInput = opts.requestUserInput; + this.#turnStartedAt = Date.now(); + this.#turnModel = opts.model ?? "codex"; + this.#announcedItems.clear(); + this.#hasQueried = true; + + void this.#startTurn(opts).catch((err: unknown) => { + this.#push({ type: "error", message: `codex: ${err instanceof Error ? err.message : String(err)}` }); + queue.close(); + }); + + return { + events: queue, + interrupt: async () => { + if (this.#proc?.alive && this.#threadId) { + try { + await this.#proc.request("turn/interrupt", { + threadId: this.#threadId, + ...(this.#currentTurnId ? { turnId: this.#currentTurnId } : {}), + }); + return; + } catch { + /* fall through to hard close */ + } + } + queue.close(); + }, + }; + } + + async listModels(): Promise { + if (!this.#proc?.alive) return []; + try { + const result = (await this.#proc.request("model/list", {})) as { + data?: Array<{ id?: string; model?: string; displayName?: string; description?: string }>; + }; + return (result.data ?? []) + .filter((m) => typeof (m.model ?? m.id) === "string") + .map((m) => ({ + id: (m.model ?? m.id) as string, + displayName: m.displayName ?? ((m.model ?? m.id) as string), + description: m.description, + })); + } catch { + return []; + } + } + + async dispose(): Promise { + await this.teardown(); + } + + async teardown(): Promise { + this.#turnQueue?.close(); + this.#turnQueue = null; + this.#proc?.kill(); + this.#proc = null; + this.#threadId = null; + } + + // ── Internal ────────────────────────────────────────────────────────── + + async #startTurn(opts: TurnOpts): Promise { + await this.#ensureThread(opts); + const seed = this.#pendingHistorySeed; + this.#pendingHistorySeed = null; + const text = seed ? `${seed}\n\n${opts.userMessage}` : opts.userMessage; + + const result = (await this.#proc!.request("turn/start", { + threadId: this.#threadId, + input: [{ type: "text", text, text_elements: [] }], + cwd: opts.workdir, + // Fail-closed parity with the pi bridge: codex must ASK for every + // privileged action so codeoid's approval gate is authoritative. + approvalPolicy: "untrusted", + ...(opts.model ? { model: opts.model } : {}), + })) as { turn?: { id?: string } }; + this.#currentTurnId = result.turn?.id ?? null; + } + + async #ensureThread(opts: TurnOpts): Promise { + if (this.#proc?.alive && this.#threadId) return; + + if (!this.#proc?.alive) { + this.#proc = new CodexRpcProcess({ + command: this.#command, + argsPrefix: this.#argsPrefix, + cwd: opts.workdir, + env: buildCodexEnv(), + onNotification: (method, params) => this.#onNotification(method, params), + onServerRequest: (method, params) => this.#onServerRequest(method, params), + onExit: ({ code, signal, stderrTail }) => { + const queue = this.#turnQueue; + if (queue && !queue.closed) { + this.#push({ + type: "error", + message: `codex exited unexpectedly (code=${code} signal=${signal})${stderrTail ? `: ${stderrTail.slice(-500)}` : ""}`, + }); + queue.close(); + } + }, + }); + await this.#proc.request("initialize", { + clientInfo: { name: "codeoid", title: "codeoid", version: "1.0" }, + }); + this.#proc.notify("initialized"); + } + + // Resume the prior thread when the backing id looks like one codex + // minted; otherwise (first run: backing id is the codeoid session id) + // start fresh. Resume failure degrades to a fresh thread — never wedge. + if (this.#backingSessionId !== this.#sessionId) { + try { + const resumed = (await this.#proc.request("thread/resume", { + threadId: this.#backingSessionId, + })) as { thread?: { id?: string } }; + if (resumed.thread?.id) { + this.#threadId = resumed.thread.id; + return; + } + } catch { + /* fall through to thread/start */ + } + } + const started = (await this.#proc.request("thread/start", { + cwd: opts.workdir, + ...(opts.systemPromptAppend ? { developerInstructions: opts.systemPromptAppend } : {}), + })) as { thread?: { id?: string } }; + if (!started.thread?.id) throw new Error("codex thread/start returned no thread id"); + this.#threadId = started.thread.id; + this.#backingSessionId = started.thread.id; + + if (!this.#modelsReported && this.#onModels) { + const models = await this.listModels(); + if (models.length > 0) { + this.#modelsReported = true; + this.#onModels(models.map((m) => ({ value: m.id, displayName: m.displayName, description: m.description }))); + } + } + } + + #push(event: ProviderEvent): void { + const queue = this.#turnQueue; + if (!queue || queue.closed) return; + try { + queue.push(event); + } catch { + /* closed between check and push */ + } + } + + #onNotification(method: string, params: Record): void { + switch (method) { + case "item/agentMessage/delta": { + const delta = (params.delta ?? params.text) as string | undefined; + if (delta) this.#push({ type: "text_delta", content: delta }); + break; + } + case "item/reasoning/textDelta": + case "item/reasoning/summaryTextDelta": { + const delta = (params.delta ?? params.text) as string | undefined; + if (delta) this.#push({ type: "thinking_delta", content: delta }); + break; + } + case "item/completed": { + const item = params.item as Record | undefined; + if (!item) break; + const itemType = item.type as string; + const itemId = String(item.id ?? ""); + if (itemType === "agentMessage") { + const text = (item.text ?? item.content ?? "") as string; + this.#push({ type: "text_done", content: typeof text === "string" ? text : "" }); + this.#push({ type: "thinking_done" }); + } else if (TOOL_ITEM_TYPES.has(itemType)) { + // Items codeoid gated were announced at approval time; items + // codex ran under its own trusted policy are recorded here + // retrospectively so scrollback + canonical history stay honest. + if (!this.#announcedItems.has(itemId)) { + const { name, input } = codexItemToTool(itemType, item); + this.#push({ + type: "tool_start", + toolId: itemId, + sdkToolUseId: itemId, + name, + input, + approvalId: `codex-auto-${itemId}`, + }); + } + this.#push({ + type: "tool_complete", + sdkToolUseId: itemId, + output: extractItemOutput(item), + success: (item.status ?? "completed") !== "failed", + }); + this.#announcedItems.delete(itemId); + } + break; + } + case "turn/completed": { + const turn = params.turn as Record | undefined; + const usage = (turn?.usage ?? params.usage ?? {}) as Record; + const result: NormalizedTurnResult = { + providerId: this.id, + model: this.#turnModel, + inputTokens: usage.inputTokens ?? usage.input_tokens ?? 0, + outputTokens: usage.outputTokens ?? usage.output_tokens ?? 0, + cacheReadTokens: usage.cachedInputTokens ?? usage.cached_input_tokens ?? 0, + cacheCreationTokens: 0, + totalCostUsd: 0, + durationMs: Date.now() - this.#turnStartedAt, + stopReason: (turn?.status as string | undefined) ?? undefined, + }; + this.#push({ type: "turn_done", result }); + this.#turnQueue?.close(); + break; + } + case "error": { + const message = (params.message ?? params.error ?? "codex error") as string; + this.#push({ type: "error", message: String(message) }); + break; + } + default: + break; // plan/diff/status notifications — future slices + } + } + + async #onServerRequest(method: string, params: Record): Promise { + switch (method) { + case "item/commandExecution/requestApproval": + case "item/fileChange/requestApproval": + case "item/permissions/requestApproval": { + const canUseTool = this.#canUseTool; + // Fail closed: an approval with no gate wired is denied, never run. + if (!canUseTool) return { decision: "denied" }; + const itemId = String(params.itemId ?? params.approvalId ?? randomUUID()); + const { name, input } = approvalToTool(method, params); + this.#announcedItems.set(itemId, { name, input }); + this.#push({ + type: "tool_start", + toolId: itemId, + sdkToolUseId: itemId, + name, + input, + approvalId: `codex-${itemId}`, + }); + const verdict = await canUseTool(itemId, `codex-${itemId}`, name, input); + return { decision: verdict.behavior === "allow" ? "approved" : "denied" }; + } + case "item/tool/requestUserInput": { + const ask = this.#requestUserInput; + if (!ask) return { answers: [] }; + const questions = (params.questions ?? []) as Array>; + const answers: Array> = []; + for (const q of questions) { + const options = (q.options as Array<{ label?: string }> | undefined) + ?.map((o) => o.label ?? "") + .filter((l) => l.length > 0); + const response = await ask({ + method: options && options.length > 0 ? "select" : "input", + title: String(q.header ?? q.question ?? "Codex asks"), + message: typeof q.question === "string" ? q.question : undefined, + options, + }); + answers.push({ + id: q.id, + // Cancellation is "no answer", never consent (interface contract). + answer: response.cancelled ? null : (response.value ?? null), + }); + } + return { answers }; + } + default: + // Unknown server request — refuse rather than guess (fail closed). + throw new Error(`codeoid does not handle codex request: ${method}`); + } + } +} + +/** Map an approval request to a codeoid tool name + input. */ +function approvalToTool( + method: string, + params: Record, +): { name: string; input: Record } { + if (method === "item/commandExecution/requestApproval") { + return { + name: "Bash", + input: { + command: String(params.command ?? params.parsedCmd ?? ""), + ...(params.cwd ? { cwd: params.cwd } : {}), + ...(params.reason ? { reason: params.reason } : {}), + }, + }; + } + if (method === "item/fileChange/requestApproval") { + return { + name: "codex_file_change", + input: { + ...(params.changes !== undefined ? { changes: params.changes } : {}), + ...(params.grantRoot !== undefined ? { grantRoot: params.grantRoot } : {}), + ...(params.reason ? { reason: params.reason } : {}), + }, + }; + } + return { name: "codex_permissions", input: { ...params } }; +} + +/** Tool record for items codex ran without asking (trusted policy). */ +function codexItemToTool( + itemType: string, + item: Record, +): { name: string; input: Record } { + if (itemType === "commandExecution") { + return { name: "Bash", input: { command: String(item.command ?? "") } }; + } + if (itemType === "fileChange") { + return { name: "codex_file_change", input: { changes: item.changes ?? item.patch ?? null } }; + } + if (itemType === "mcpToolCall") { + return { + name: `mcp__${String(item.server ?? "codex")}__${String(item.tool ?? "tool")}`, + input: (item.arguments as Record) ?? {}, + }; + } + return { name: `codex_${itemType}`, input: {} }; +} + +function extractItemOutput(item: Record): string { + const out = item.aggregatedOutput ?? item.output ?? item.result ?? item.text ?? ""; + return typeof out === "string" ? out : JSON.stringify(out); +} diff --git a/src/daemon/providers/codex/resolve.ts b/src/daemon/providers/codex/resolve.ts new file mode 100644 index 0000000..69e0c72 --- /dev/null +++ b/src/daemon/providers/codex/resolve.ts @@ -0,0 +1,40 @@ +/** + * codex binary resolution — config override → system PATH. + * + * No bundled fallback (yet): @openai/codex ships a per-platform native + * Rust binary, so bundling needs a size/platform audit first — see + * docs/provider-codex-design.md. Until then, the registry's + * supported-but-unavailable path (#141) surfaces the install hint in the + * catalog, the startup log, and session.set_provider errors. + */ + +import { existsSync } from "node:fs"; + +export const CODEX_INSTALL_HINT = + "no codex binary found — install the Codex CLI (npm i -g @openai/codex) " + + "or point providers.codex.command at a binary"; + +export interface CodexCommandResolution { + command: string; + argsPrefix: string[]; + source: "config" | "path"; +} + +export function resolveCodexCommand( + configured: string | undefined, + env: Record = process.env, +): CodexCommandResolution | null { + // Explicit config override — verified so a typo is loud at startup, not + // a first-turn spawn failure. + if (configured !== undefined && configured !== "codex") { + if (configured.includes("/")) { + return existsSync(configured) + ? { command: configured, argsPrefix: [], source: "config" } + : null; + } + const found = Bun.which(configured, { PATH: env.PATH ?? "" }); + return found ? { command: found, argsPrefix: [], source: "config" } : null; + } + const onPath = Bun.which("codex", { PATH: env.PATH ?? "" }); + return onPath ? { command: onPath, argsPrefix: [], source: "path" } : null; +} diff --git a/src/daemon/providers/codex/rpc.ts b/src/daemon/providers/codex/rpc.ts new file mode 100644 index 0000000..8b25e20 --- /dev/null +++ b/src/daemon/providers/codex/rpc.ts @@ -0,0 +1,186 @@ +/** + * Codex app-server client — newline-delimited JSON-RPC 2.0 over stdio. + * + * Verified live against @openai/codex@0.144.1 (`codex app-server`): + * - requests: {jsonrpc, id, method, params} → {id, result|error} + * - notifications: {method, params} in both directions + * - SERVER→CLIENT requests (approvals, user-input questions) carry an id + * and expect a response frame — the seam codeoid's approval gate plugs + * into. Unlike pi, no bridge extension is injected: approvals are + * native to the protocol. + */ + +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; + +export type CodexFrame = Record & { + id?: number | string; + method?: string; + result?: unknown; + error?: { code?: number; message?: string }; + params?: Record; +}; + +export interface CodexSpawnOptions { + /** Resolved binary (see resolve.ts). */ + command: string; + /** argv before `app-server` (bundled runtime entry, if any). */ + argsPrefix?: string[]; + /** Extra args after `app-server`. */ + args?: string[]; + cwd: string; + /** Allowlisted env (buildCodexEnv) — never raw process.env (GHSA-38vh). */ + env: Record; + /** Server→client NOTIFICATION (no id). */ + onNotification: (method: string, params: Record) => void; + /** + * Server→client REQUEST (id present) — approvals + user-input questions. + * The returned value is sent back as the JSON-RPC result; a throw sends + * a JSON-RPC error (codex treats it as a denial-equivalent failure). + */ + onServerRequest: (method: string, params: Record) => Promise; + onExit: (info: { code: number | null; signal: string | null; stderrTail: string }) => void; +} + +const REQUEST_TIMEOUT_MS = 30_000; +const STDERR_TAIL_BYTES = 4_096; + +export class CodexRpcProcess { + #proc: ChildProcessWithoutNullStreams; + #stdoutBuf = ""; + #stderrTail = ""; + #nextId = 1; + #pending = new Map< + number, + { resolve: (result: unknown) => void; reject: (err: Error) => void; timer: NodeJS.Timeout } + >(); + #opts: CodexSpawnOptions; + #exited = false; + + constructor(opts: CodexSpawnOptions) { + this.#opts = opts; + this.#proc = spawn( + opts.command, + [...(opts.argsPrefix ?? []), "app-server", ...(opts.args ?? [])], + { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"], env: opts.env }, + ); + // A codex that dies mid-write must not crash the daemon: without an + // error listener, stdin's EPIPE becomes an uncaught stream error. The + // exit handler owns diagnostics; writes after death are just dropped. + this.#proc.stdin.on("error", () => {}); + this.#proc.stdout.setEncoding("utf8"); + this.#proc.stdout.on("data", (chunk: string) => this.#onStdout(chunk)); + this.#proc.stderr.setEncoding("utf8"); + this.#proc.stderr.on("data", (chunk: string) => { + this.#stderrTail = (this.#stderrTail + chunk).slice(-STDERR_TAIL_BYTES); + }); + this.#proc.on("error", (err) => this.#failAll(new Error(`codex spawn failed: ${err.message}`))); + this.#proc.on("exit", (code, signal) => { + this.#exited = true; + this.#failAll(new Error(`codex exited (code=${code} signal=${signal})`)); + opts.onExit({ code, signal, stderrTail: this.#stderrTail }); + }); + } + + get alive(): boolean { + return !this.#exited; + } + + /** Client→server request; resolves with the JSON-RPC result. */ + request( + method: string, + params: Record, + timeoutMs = REQUEST_TIMEOUT_MS, + ): Promise { + if (this.#exited) return Promise.reject(new Error("codex process has exited")); + const id = this.#nextId++; + const frame = JSON.stringify({ jsonrpc: "2.0", id, method, params }); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.#pending.delete(id); + reject(new Error(`codex request timed out: ${method}`)); + }, timeoutMs); + this.#pending.set(id, { resolve, reject, timer }); + this.#proc.stdin.write(`${frame}\n`, (err) => { + if (err) { + clearTimeout(timer); + this.#pending.delete(id); + reject(err); + } + }); + }); + } + + /** Client→server notification (no response expected). */ + notify(method: string, params?: Record): void { + if (this.#exited) return; + this.#proc.stdin.write( + `${JSON.stringify({ jsonrpc: "2.0", method, ...(params ? { params } : {}) })}\n`, + ); + } + + kill(): void { + this.#proc.kill("SIGKILL"); + } + + #onStdout(chunk: string): void { + this.#stdoutBuf += chunk; + for (;;) { + const idx = this.#stdoutBuf.indexOf("\n"); + if (idx < 0) break; + const line = this.#stdoutBuf.slice(0, idx).trim(); + this.#stdoutBuf = this.#stdoutBuf.slice(idx + 1); + if (!line) continue; + let frame: CodexFrame; + try { + frame = JSON.parse(line) as CodexFrame; + } catch { + continue; // non-JSON noise on stdout — ignore + } + this.#dispatch(frame); + } + } + + #dispatch(frame: CodexFrame): void { + // Response to one of OUR requests. + if (frame.id !== undefined && frame.method === undefined) { + const pending = this.#pending.get(frame.id as number); + if (!pending) return; + this.#pending.delete(frame.id as number); + clearTimeout(pending.timer); + if (frame.error) pending.reject(new Error(frame.error.message ?? "codex error")); + else pending.resolve(frame.result); + return; + } + // Server→client REQUEST — must be answered (approvals, questions). + if (frame.id !== undefined && frame.method !== undefined) { + const id = frame.id; + this.#opts + .onServerRequest(frame.method, frame.params ?? {}) + .then((result) => { + this.#proc.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`); + }) + .catch((err: unknown) => { + this.#proc.stdin.write( + `${JSON.stringify({ + jsonrpc: "2.0", + id, + error: { code: -32000, message: err instanceof Error ? err.message : String(err) }, + })}\n`, + ); + }); + return; + } + // Notification. + if (frame.method !== undefined) { + this.#opts.onNotification(frame.method, frame.params ?? {}); + } + } + + #failAll(err: Error): void { + for (const [, p] of this.#pending) { + clearTimeout(p.timer); + p.reject(err); + } + this.#pending.clear(); + } +} diff --git a/src/daemon/providers/env.ts b/src/daemon/providers/env.ts index 5341120..808074d 100644 --- a/src/daemon/providers/env.ts +++ b/src/daemon/providers/env.ts @@ -102,3 +102,24 @@ export function buildPiEnv( base, ); } + +/** + * Environment for the `codex app-server` subprocess. + * + * codex's primary credential store is `~/.codex/auth.json` (HOME is in the + * shared basics — ChatGPT-subscription tokens never transit codeoid), with + * env-key fallbacks for API-key users. Same posture as pi: conventional + * credential shapes plus codex's own namespace; anything exotic goes + * through `CODEOID_AGENT_ENV_ALLOW`. + */ +export function buildCodexEnv( + base: Record = process.env, +): Record { + return buildSubprocessEnv( + { + prefixes: ["CODEX_", "OPENAI_", "LC_"], + suffixes: ["_API_KEY"], + }, + base, + ); +} diff --git a/src/daemon/providers/registry.ts b/src/daemon/providers/registry.ts index 327535e..23d9746 100644 --- a/src/daemon/providers/registry.ts +++ b/src/daemon/providers/registry.ts @@ -24,6 +24,8 @@ import { GeminiProvider } from "./gemini/index.js"; import { OpenAIProvider } from "./openai/index.js"; import { PiProvider } from "./pi/index.js"; import { PI_INSTALL_HINT, resolvePiCommand } from "./pi/resolve.js"; +import { CodexProvider } from "./codex/index.js"; +import { CODEX_INSTALL_HINT, resolveCodexCommand } from "./codex/resolve.js"; import { StatelessSessionProvider } from "./stateless.js"; /** @@ -211,5 +213,31 @@ export function createDefaultProviderRegistry(config?: CodeoidConfig): ProviderR ); } } + if (config?.providers?.codex?.enabled !== false) { + const configured = config?.providers?.codex?.command; + const resolution = resolveCodexCommand(configured === "codex" ? undefined : configured); + if (resolution) { + registry.register({ + id: "codex", + displayName: "Codex (OpenAI)", + create: (init) => + new CodexProvider({ + sessionId: init.sessionId, + initialBackingId: init.initialBackingId, + command: resolution.command, + argsPrefix: resolution.argsPrefix, + store: init.store, + onModels: init.onModels, + }), + }); + } else { + registry.markUnavailable( + "codex", + configured !== undefined && configured !== "codex" + ? `providers.codex.command (${JSON.stringify(configured)}) does not exist or is not on PATH` + : CODEX_INSTALL_HINT, + ); + } + } return registry; } diff --git a/src/tests/fixtures/fake-codex.ts b/src/tests/fixtures/fake-codex.ts new file mode 100644 index 0000000..837140e --- /dev/null +++ b/src/tests/fixtures/fake-codex.ts @@ -0,0 +1,182 @@ +/** + * fake-codex — offline stand-in for `codex app-server` (newline JSON-RPC). + * + * Mirrors the wire behavior probed against @openai/codex@0.144.1 + * (docs/provider-codex-design.md → Probe results). Turn behavior is keyed + * off the prompt text, like fake-pi: + * + * "use-tool" → server→client item/commandExecution/requestApproval, + * then runs or skips the item based on the decision + * "auto-tool" → item/completed for a command codex ran WITHOUT asking + * "ask-user" → server→client item/tool/requestUserInput (select) + * "echo-prompt"→ agentMessage containing the full received prompt + * default → two agentMessage deltas + completed message + * + * Every turn ends with turn/completed carrying usage. + */ + +const enc = new TextEncoder(); +function send(obj: unknown): void { + process.stdout.write(enc.encode(`${JSON.stringify(obj)}\n`)); +} + +let nextServerReqId = 1000; +const pendingServerReqs = new Map) => void>(); + +function serverRequest(method: string, params: Record): Promise> { + const id = nextServerReqId++; + send({ jsonrpc: "2.0", id, method, params }); + return new Promise((resolve) => pendingServerReqs.set(id, resolve)); +} + +function usage() { + return { inputTokens: 120, cachedInputTokens: 20, outputTokens: 45 }; +} + +async function runTurn(threadId: string, prompt: string): Promise { + send({ method: "turn/started", params: { turn: { id: "turn-1" } } }); + + if (prompt.includes("use-tool")) { + const decision = await serverRequest("item/commandExecution/requestApproval", { + threadId, + turnId: "turn-1", + itemId: "item-cmd-1", + command: "rm -rf /tmp/scratch", + cwd: "/tmp", + reason: "cleanup", + }); + if (decision.decision === "approved") { + send({ + method: "item/completed", + params: { + item: { id: "item-cmd-1", type: "commandExecution", command: "rm -rf /tmp/scratch", aggregatedOutput: "removed", status: "completed" }, + }, + }); + send({ method: "item/agentMessage/delta", params: { delta: "Cleaned up." } }); + send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text: "Cleaned up." } } }); + } else { + send({ method: "item/agentMessage/delta", params: { delta: "Approval denied; skipping." } }); + send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text: "Approval denied; skipping." } } }); + } + } else if (prompt.includes("auto-tool")) { + // codex ran a trusted read without asking — only item/completed arrives. + send({ + method: "item/completed", + params: { item: { id: "item-auto-1", type: "commandExecution", command: "ls -la", aggregatedOutput: "file-a\nfile-b", status: "completed" } }, + }); + send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text: "Listed files." } } }); + } else if (prompt.includes("ask-user")) { + const answer = await serverRequest("item/tool/requestUserInput", { + threadId, + turnId: "turn-1", + itemId: "item-q-1", + questions: [ + { id: "q1", header: "Pick one", question: "Which env?", options: [{ label: "dev" }, { label: "prod" }] }, + ], + }); + const answers = answer.answers as Array<{ answer?: string | null }> | undefined; + const picked = answers?.[0]?.answer ?? "no-answer"; + send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text: `You picked: ${picked}` } } }); + } else if (prompt.includes("hang-forever")) { + // Emit nothing further — the test interrupts the turn. + return; + } else if (prompt.includes("unknown-request")) { + const resp = await serverRequest("custom/unknownThing", { anything: true }); + const text = resp.__error ? "server-request-errored" : "server-request-oddly-ok"; + send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text } } }); + } else if (prompt.includes("echo-prompt")) { + send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text: `PROMPT:${prompt}` } } }); + } else { + send({ method: "item/reasoning/textDelta", params: { delta: "thinking..." } }); + send({ method: "item/agentMessage/delta", params: { delta: "Hello " } }); + send({ method: "item/agentMessage/delta", params: { delta: "world" } }); + send({ method: "item/completed", params: { item: { id: "m1", type: "agentMessage", text: "Hello world" } } }); + } + + send({ method: "turn/completed", params: { turn: { id: "turn-1", status: "completed", usage: usage() } } }); +} + +let buf = ""; +process.stdin.on("data", (chunk: Buffer) => { + buf += chunk.toString("utf8"); + for (;;) { + const idx = buf.indexOf("\n"); + if (idx < 0) break; + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (!line) continue; + let frame: Record; + try { + frame = JSON.parse(line); + } catch { + continue; + } + void handle(frame); + } +}); + +async function handle(frame: Record): Promise { + const id = frame.id as number | undefined; + const method = frame.method as string | undefined; + const params = (frame.params ?? {}) as Record; + + // Response to one of OUR server→client requests. + if (id !== undefined && method === undefined) { + const resolve = pendingServerReqs.get(id); + if (resolve) { + pendingServerReqs.delete(id); + const error = frame.error as { message?: string } | undefined; + resolve( + error + ? { __error: error.message ?? "error" } + : ((frame.result ?? { decision: "denied" }) as Record), + ); + } + return; + } + if (method === undefined) return; + + switch (method) { + case "initialize": + send({ id, result: { userAgent: "fake-codex/0.144.1" } }); + break; + case "initialized": + break; + case "thread/start": + send({ id, result: { thread: { id: "codex-thread-1" } } }); + send({ method: "thread/started", params: { thread: { id: "codex-thread-1" } } }); + break; + case "thread/resume": + send({ id, result: { thread: { id: String(params.threadId ?? "codex-thread-1") } } }); + break; + case "model/list": + send({ + id, + result: { + data: [ + { id: "gpt-5.6-terra", model: "gpt-5.6-terra", displayName: "GPT-5.6-Terra", description: "Balanced agentic coding model." }, + ], + }, + }); + break; + case "turn/start": { + send({ id, result: { turn: { id: "turn-1" } } }); + const input = params.input as Array<{ type: string; text?: string }> | undefined; + const prompt = input?.find((i) => i.type === "text")?.text ?? ""; + void runTurn(String(params.threadId ?? ""), prompt); + break; + } + case "turn/interrupt": + send({ id, result: {} }); + send({ method: "turn/completed", params: { turn: { id: "turn-1", status: "interrupted", usage: usage() } } }); + break; + case "test/noReply": + break; // deliberately never answered — rpc timeout test + default: + send({ id, error: { code: -32601, message: `fake-codex: unknown method ${method}` } }); + break; + } +} + +// Module scope (avoids global-script collisions with other fixtures under tsc). +export {}; diff --git a/src/tests/pi-resolve.test.ts b/src/tests/pi-resolve.test.ts index 266a5ba..7df12b2 100644 --- a/src/tests/pi-resolve.test.ts +++ b/src/tests/pi-resolve.test.ts @@ -104,9 +104,10 @@ describe("registry activation", () => { const registry = createDefaultProviderRegistry(config); expect(registry.has("pi")).toBe(false); expect(registry.unavailableHint("pi")).toContain("definitely-missing"); - expect(registry.unavailableEntries()).toEqual([ - { id: "pi", hint: expect.stringContaining("providers.pi.command") }, - ]); + expect(registry.unavailableEntries()).toContainEqual({ + id: "pi", + hint: expect.stringContaining("providers.pi.command"), + }); }); it("keeps pi out of the catalog entirely when disabled", () => { diff --git a/src/tests/provider-codex.test.ts b/src/tests/provider-codex.test.ts new file mode 100644 index 0000000..4eae548 --- /dev/null +++ b/src/tests/provider-codex.test.ts @@ -0,0 +1,361 @@ +/** + * CodexProvider tests — offline over the fake-codex fixture (newline + * JSON-RPC subprocess, same pattern as fake-pi). + * + * C1 text turn: thinking + text deltas, text_done, turn_done with usage + * C2 approval APPROVED: server request → tool_start → canUseTool(Bash, + * command input) → fixture sees "approved" → tool_complete + * C3 approval DENIED: decision "denied" reaches codex; no tool_complete + * C4 non-gated item: retrospective tool_start/tool_complete pair + * C5 seedFromHistory: first prompt carries the structured transcript + * C6 requestUserInput: select question round-trips the picked option + * C7 missing binary surfaces a clear error + * C8 model/list maps to ModelInfo + * C9 resolveCodexCommand: config → PATH → null (+ registry hint) + */ + +import { describe, it, expect } from "bun:test"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CodexRpcProcess } from "../daemon/providers/codex/rpc.js"; +import { CodexProvider } from "../daemon/providers/codex/index.js"; +import { resolveCodexCommand } from "../daemon/providers/codex/resolve.js"; +import { createDefaultProviderRegistry } from "../daemon/providers/registry.js"; +import type { ProviderEvent, TurnOpts, TurnRun } from "../daemon/providers/interface.js"; +import type { CodeoidConfig } from "../config.js"; +import type { Store } from "../daemon/store.js"; + +const FIXTURE = join(import.meta.dir, "fixtures", "fake-codex.ts"); + +function makeProvider(command = process.execPath, argsPrefix = [FIXTURE]): CodexProvider { + return new CodexProvider({ + sessionId: "sess-1", + initialBackingId: "sess-1", // first run — provider starts a fresh thread + command, + argsPrefix, + store: {} as Store, // not consulted in this slice + }); +} + +function turnOpts( + userMessage: string, + overrides: Partial = {}, +): TurnOpts { + return { + history: [], + userMessage, + workdir: "/tmp", + canUseTool: async () => ({ behavior: "allow" as const }), + ...overrides, + }; +} + +async function collect(run: TurnRun): Promise { + const events: ProviderEvent[] = []; + for await (const event of run.events) { + events.push(event); + if (event.type === "turn_done" || event.type === "error") break; + } + return events; +} + +describe("CodexProvider over fake-codex", () => { + it("C1: text turn streams thinking + text and completes with usage", async () => { + const p = makeProvider(); + const events = await collect(p.runTurn(turnOpts("hello"))); + await p.teardown(); + + expect(events.some((e) => e.type === "thinking_delta" && e.content === "thinking...")).toBe(true); + const deltas = events.filter((e) => e.type === "text_delta").map((e) => (e as { content: string }).content); + expect(deltas.join("")).toBe("Hello world"); + const done = events.find((e) => e.type === "text_done"); + expect(done && (done as { content: string }).content).toBe("Hello world"); + const turnDone = events.find((e) => e.type === "turn_done"); + expect(turnDone).toBeDefined(); + if (turnDone?.type === "turn_done") { + expect(turnDone.result.providerId).toBe("codex"); + expect(turnDone.result.inputTokens).toBe(120); + expect(turnDone.result.outputTokens).toBe(45); + expect(turnDone.result.cacheReadTokens).toBe(20); + } + }); + + it("C2: approval request routes through canUseTool and approval runs the item", async () => { + const p = makeProvider(); + const gated: Array<{ name: string; input: Record }> = []; + const events = await collect( + p.runTurn( + turnOpts("please use-tool", { + canUseTool: async (_toolId, _approvalId, toolName, input) => { + gated.push({ name: toolName, input }); + return { behavior: "allow" as const }; + }, + }), + ), + ); + await p.teardown(); + + expect(gated).toEqual([ + { name: "Bash", input: { command: "rm -rf /tmp/scratch", cwd: "/tmp", reason: "cleanup" } }, + ]); + // tool_start announced at approval time, tool_complete after codex ran it. + const start = events.find((e) => e.type === "tool_start"); + expect(start && (start as { name: string }).name).toBe("Bash"); + const complete = events.find((e) => e.type === "tool_complete"); + expect(complete && (complete as { output: string }).output).toBe("removed"); + expect(events.some((e) => e.type === "text_done" && e.content === "Cleaned up.")).toBe(true); + }); + + it("C3: denial reaches codex and the item never runs", async () => { + const p = makeProvider(); + const events = await collect( + p.runTurn( + turnOpts("please use-tool", { + canUseTool: async () => ({ behavior: "deny" as const, message: "no" }), + }), + ), + ); + await p.teardown(); + + expect(events.some((e) => e.type === "tool_complete")).toBe(false); + expect(events.some((e) => e.type === "text_done" && e.content === "Approval denied; skipping.")).toBe(true); + }); + + it("C4: non-gated item is recorded retrospectively as start+complete", async () => { + const p = makeProvider(); + const events = await collect(p.runTurn(turnOpts("auto-tool please"))); + await p.teardown(); + + const start = events.find((e) => e.type === "tool_start"); + const complete = events.find((e) => e.type === "tool_complete"); + expect(start).toBeDefined(); + expect(complete).toBeDefined(); + if (start?.type === "tool_start") { + expect(start.name).toBe("Bash"); + expect(start.input).toEqual({ command: "ls -la" }); + } + if (complete?.type === "tool_complete") { + expect(complete.output).toBe("file-a\nfile-b"); + expect(complete.success).toBe(true); + } + }); + + it("C5: seedFromHistory prepends the structured transcript to the first prompt", async () => { + const p = makeProvider(); + p.seedFromHistory([ + { role: "user", content: "earlier question" }, + { + role: "assistant", + content: "earlier answer", + providerId: "claude", + model: "opus", + toolCalls: [ + { id: "t1", name: "run_shell", input: { command: "bun test" }, output: "1 pass", success: true }, + ], + }, + ]); + const events = await collect(p.runTurn(turnOpts("echo-prompt"))); + await p.teardown(); + + const done = events.find((e) => e.type === "text_done"); + expect(done).toBeDefined(); + if (done?.type === "text_done") { + expect(done.content).toContain(""); + expect(done.content).toContain("### Tool call: run_shell → ok"); + expect(done.content).toContain("echo-prompt"); + } + + // One-shot: the second turn goes through clean. + const second = await collect(p.runTurn(turnOpts("echo-prompt again"))); + const done2 = second.find((e) => e.type === "text_done"); + if (done2?.type === "text_done") { + expect(done2.content).not.toContain(""); + } + await p.teardown(); + }); + + it("C6: requestUserInput select question round-trips the picked option", async () => { + const p = makeProvider(); + const asked: string[] = []; + const events = await collect( + p.runTurn( + turnOpts("ask-user", { + requestUserInput: async (req) => { + asked.push(`${req.method}:${req.title}:${(req.options ?? []).join("|")}`); + return { value: "prod", cancelled: false }; + }, + }), + ), + ); + await p.teardown(); + + expect(asked).toEqual(["select:Pick one:dev|prod"]); + expect(events.some((e) => e.type === "text_done" && e.content === "You picked: prod")).toBe(true); + }); + + it("C7: a missing codex binary surfaces a clear error", async () => { + const p = makeProvider("/nonexistent/codex-binary", []); + const events = await collect(p.runTurn(turnOpts("hello"))); + await p.teardown(); + expect(events.some((e) => e.type === "error")).toBe(true); + }); + + it("C8: model/list maps to ModelInfo", async () => { + const p = makeProvider(); + // Warm the process + thread with a cheap turn first. + await collect(p.runTurn(turnOpts("hello"))); + const models = await p.listModels(); + await p.teardown(); + expect(models).toEqual([ + { id: "gpt-5.6-terra", displayName: "GPT-5.6-Terra", description: "Balanced agentic coding model." }, + ]); + }); + + it("C9: lifecycle — thread id becomes the backing id, reset/setHasQueried/dispose, onModels fires", async () => { + const reported: Array<{ value: string; displayName: string }> = []; + const p = new CodexProvider({ + sessionId: "sess-1", + initialBackingId: "sess-1", + command: process.execPath, + argsPrefix: [FIXTURE], + store: {} as Store, + onModels: (models) => reported.push(...models.map((m) => ({ value: m.value, displayName: m.displayName }))), + }); + expect(p.hasQueried).toBe(false); + expect(p.queuedMessages).toBe(0); + + await collect(p.runTurn(turnOpts("hello"))); + expect(p.hasQueried).toBe(true); + // codex minted the thread — it round-trips as the backing session id. + expect(p.backingSessionId).toBe("codex-thread-1"); + expect(reported).toEqual([{ value: "gpt-5.6-terra", displayName: "GPT-5.6-Terra" }]); + + p.setHasQueried(false); + expect(p.hasQueried).toBe(false); + p.resetToNewSession("fresh-backing"); + expect(p.backingSessionId).toBe("fresh-backing"); + + await p.dispose(); // teardown via dispose + expect(await p.listModels()).toEqual([]); // no live process → empty catalog + }); + + it("C10: interrupt() sends turn/interrupt and the turn ends as interrupted", async () => { + const p = makeProvider(); + const run = p.runTurn(turnOpts("hang-forever")); + // Give the turn time to start (thread + turn/start round trips). + await new Promise((r) => setTimeout(r, 300)); + await run.interrupt(); + const events = await collect(run); + await p.teardown(); + + const done = events.find((e) => e.type === "turn_done"); + expect(done).toBeDefined(); + if (done?.type === "turn_done") expect(done.result.stopReason).toBe("interrupted"); + }); + + it("C11: unknown server→client requests are refused with a JSON-RPC error (fail closed)", async () => { + const p = makeProvider(); + const events = await collect(p.runTurn(turnOpts("unknown-request"))); + await p.teardown(); + // The fixture saw an error response — codeoid refused rather than guessed. + expect(events.some((e) => e.type === "text_done" && e.content === "server-request-errored")).toBe(true); + }); + + it("C12: rpc edges — spawn failure, request timeout, request/notify after exit", async () => { + // Spawn failure rejects in-flight requests. + const dead = new CodexRpcProcess({ + command: "/nonexistent/codex-binary", + cwd: "/tmp", + env: {}, + onNotification: () => {}, + onServerRequest: async () => ({}), + onExit: () => {}, + }); + expect(dead.request("initialize", {})).rejects.toThrow(/spawn failed|exited/); + + // A request the server never answers times out. + const exited = new Promise((resolve) => { + const rpc = new CodexRpcProcess({ + command: process.execPath, + argsPrefix: [FIXTURE], + cwd: "/tmp", + env: { PATH: process.env.PATH ?? "" }, + onNotification: () => {}, + onServerRequest: async () => ({}), + onExit: () => resolve(), + }); + void (async () => { + expect(rpc.request("test/noReply", {}, 200)).rejects.toThrow(/timed out/); + await new Promise((r) => setTimeout(r, 250)); + rpc.kill(); + await exitedSoon(rpc); + // Post-exit: requests reject, notify is a silent no-op. + expect(rpc.request("initialize", {})).rejects.toThrow(/exited/); + rpc.notify("initialized"); + })(); + }); + await exited; + }); +}); + +/** Wait until the rpc process reports dead. */ +async function exitedSoon(rpc: CodexRpcProcess): Promise { + const deadline = Date.now() + 2000; + while (rpc.alive) { + if (Date.now() > deadline) throw new Error("codex rpc never exited"); + await new Promise((r) => setTimeout(r, 20)); + } +} + +describe("codex resolution + registry", () => { + it("config override → PATH → null, with registry hints", () => { + // Bogus configured command → supported-but-unavailable with the hint. + const config = { + providers: { + pi: { enabled: false, command: "pi" }, + codex: { enabled: true, command: "/definitely/missing/codex" }, + }, + } as unknown as CodeoidConfig; + const registry = createDefaultProviderRegistry(config); + expect(registry.has("codex")).toBe(false); + expect(registry.unavailableHint("codex")).toContain("providers.codex.command"); + + // No codex anywhere → null + generic install hint. + expect(resolveCodexCommand(undefined, { PATH: "" })).toBeNull(); + + // Bare-name override resolves via PATH; system codex resolves as "path". + const tmp = mkdtempSync(join(tmpdir(), "codeoid-codex-resolve-")); + try { + const bin = join(tmp, "my-codex"); + writeFileSync(bin, "#!/bin/sh\necho fake-codex\n"); + chmodSync(bin, 0o755); + expect(resolveCodexCommand("my-codex", { PATH: tmp })).toEqual({ + command: bin, + argsPrefix: [], + source: "config", + }); + expect(resolveCodexCommand("missing-name", { PATH: tmp })).toBeNull(); + const sys = join(tmp, "codex"); + writeFileSync(sys, "#!/bin/sh\necho fake-codex\n"); + chmodSync(sys, 0o755); + expect(resolveCodexCommand(undefined, { PATH: tmp })).toEqual({ + command: sys, + argsPrefix: [], + source: "path", + }); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + // Disabled → absent entirely, no hint. + const disabled = createDefaultProviderRegistry({ + providers: { + pi: { enabled: false, command: "pi" }, + codex: { enabled: false, command: "codex" }, + }, + } as unknown as CodeoidConfig); + expect(disabled.has("codex")).toBe(false); + expect(disabled.unavailableHint("codex")).toBeUndefined(); + }); +});