From 6c1cf1aceaba88222c0f17dc9d9531cb55419ab2 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 00:06:38 +0530 Subject: [PATCH 1/6] refactor(codex): add app-server transport --- src/local-agent-availability.ts | 56 +---- src/local-agent-codex/app-server-transport.ts | 214 ++++++++++++++++++ src/local-agent-codex/command.ts | 57 +++++ src/local-agent-path.ts | 38 +++- 4 files changed, 320 insertions(+), 45 deletions(-) create mode 100644 src/local-agent-codex/app-server-transport.ts create mode 100644 src/local-agent-codex/command.ts diff --git a/src/local-agent-availability.ts b/src/local-agent-availability.ts index 747f304f..0ab34648 100644 --- a/src/local-agent-availability.ts +++ b/src/local-agent-availability.ts @@ -1,6 +1,8 @@ -import { spawnSync } from "node:child_process"; -import { delimiter, resolve } from "node:path"; -import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; +import { + removeDevspaceNodeModulesBinFromPath, + resolveLocalAgentExecutable, +} from "./local-agent-path.js"; +import { checkCodexAppServerAvailability } from "./local-agent-codex/command.js"; import { LOCAL_AGENT_PROVIDERS, type LocalAgentProvider, @@ -24,7 +26,7 @@ export function checkLocalAgentProviderAvailability( ): LocalAgentProviderAvailability { switch (provider) { case "codex": - return packageAvailability(provider, "@openai/codex-sdk"); + return codexAvailability(env); case "claude": return packageAvailability(provider, "@anthropic-ai/claude-agent-sdk"); case "opencode": @@ -87,7 +89,7 @@ function commandAvailability( command: string, options: { env?: NodeJS.ProcessEnv } = {}, ): LocalAgentProviderAvailability { - const executable = resolveCommand(command, options.env); + const executable = resolveLocalAgentExecutable(command, options.env); if (!executable) { return { name: provider, @@ -99,45 +101,11 @@ function commandAvailability( return { name: provider, available: true }; } -function resolveCommand(command: string, env: NodeJS.ProcessEnv = process.env): string | undefined { - const commandHasPath = command.includes("/") || command.includes("\\"); - if (commandHasPath) return executableExists(command, env) ? command : undefined; - - for (const candidate of candidateCommandPaths(command, env)) { - if (executableExists(candidate, env)) return candidate; - } - return undefined; -} - -function candidateCommandPaths(command: string, env: NodeJS.ProcessEnv): string[] { - const path = env.PATH; - if (!path) return []; - const extensions = process.platform === "win32" - ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") - .split(";") - .filter(Boolean) - : [""]; - const candidates: string[] = []; - for (const directory of path.split(delimiter)) { - if (!directory) continue; - for (const extension of extensions) { - candidates.push(resolve(directory, `${command}${extension}`)); - } - } - return candidates; -} - -function executableExists(command: string, env: NodeJS.ProcessEnv): boolean { - const result = spawnSync(command, ["--version"], { - encoding: "utf8", - env, - windowsHide: true, - timeout: 5_000, - }); - const code = typeof result.error === "object" && result.error && "code" in result.error - ? result.error.code - : undefined; - return code !== "ENOENT"; +function codexAvailability(env: NodeJS.ProcessEnv): LocalAgentProviderAvailability { + const result = checkCodexAppServerAvailability(env); + return result.available + ? { name: "codex", available: true } + : { name: "codex", available: false, reason: result.reason }; } function piAvailabilityEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { diff --git a/src/local-agent-codex/app-server-transport.ts b/src/local-agent-codex/app-server-transport.ts new file mode 100644 index 00000000..2f19ab24 --- /dev/null +++ b/src/local-agent-codex/app-server-transport.ts @@ -0,0 +1,214 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createInterface } from "node:readline"; +import { terminateProcessTree } from "../process-platform.js"; +import type { ResolvedCodexCommand } from "./command.js"; + +const STDERR_LIMIT = 32_000; +const SHUTDOWN_GRACE_MS = 2_000; + +export interface CodexAppServerConnection { + request(method: string, params?: unknown): Promise; + notify(method: string, params?: unknown): void; + onNotification(handler: (method: string, params: unknown) => void): () => void; + isUsable(): boolean; + close(): Promise; +} + +interface PendingRequest { + resolve(value: unknown): void; + reject(error: Error): void; +} + +export async function startCodexAppServer( + command: ResolvedCodexCommand, +): Promise { + const client = new StdioCodexAppServerConnection(command); + try { + await client.request("initialize", { + clientInfo: { + name: "devspace", + title: "DevSpace", + version: "1", + }, + capabilities: null, + }); + client.notify("initialized"); + return client; + } catch (error) { + await client.close(); + throw error; + } +} + +class StdioCodexAppServerConnection implements CodexAppServerConnection { + private readonly child: ChildProcessWithoutNullStreams; + private readonly pending = new Map(); + private readonly notificationHandlers = new Set<(method: string, params: unknown) => void>(); + private readonly closePromise: Promise; + private nextRequestId = 1; + private stderr = ""; + private usable = true; + private closing = false; + + constructor(command: ResolvedCodexCommand) { + const detached = process.platform !== "win32"; + this.child = spawn(command.executable, ["app-server"], { + env: command.env, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + detached, + shell: process.platform === "win32", + }); + + this.closePromise = new Promise((resolve) => { + this.child.once("close", (code, signal) => { + this.usable = false; + const suffix = this.stderr.trim() ? `\n${this.stderr.trim()}` : ""; + this.failPending(new Error( + `Codex app-server exited${code !== null ? ` with code ${code}` : signal ? ` from ${signal}` : ""}.${suffix}`, + )); + resolve(); + }); + }); + + this.child.once("error", (error) => { + this.usable = false; + this.failPending(new Error(`Codex app-server failed to start: ${error.message}`)); + }); + this.child.stderr.on("data", (chunk: Buffer) => { + this.stderr = takeTail(this.stderr + chunk.toString("utf8"), STDERR_LIMIT); + }); + + const lines = createInterface({ input: this.child.stdout }); + lines.on("line", (line) => this.handleLine(line)); + } + + request(method: string, params?: unknown): Promise { + if (!this.isUsable()) { + return Promise.reject(new Error("Codex app-server connection is not available.")); + } + const id = this.nextRequestId++; + const payload = params === undefined ? { id, method } : { id, method, params }; + return new Promise((resolve, reject) => { + this.pending.set(String(id), { resolve, reject }); + this.write(payload, reject); + }); + } + + notify(method: string, params?: unknown): void { + if (!this.isUsable()) throw new Error("Codex app-server connection is not available."); + this.write(params === undefined ? { method } : { method, params }); + } + + onNotification(handler: (method: string, params: unknown) => void): () => void { + this.notificationHandlers.add(handler); + return () => this.notificationHandlers.delete(handler); + } + + isUsable(): boolean { + return this.usable && !this.closing && this.child.exitCode === null && this.child.signalCode === null; + } + + async close(): Promise { + if (this.closing) { + await this.closePromise; + return; + } + this.closing = true; + this.usable = false; + this.failPending(new Error("Codex app-server connection closed.")); + this.child.stdin.end(); + if (this.child.exitCode !== null || this.child.signalCode !== null) { + await this.closePromise; + return; + } + + const detached = process.platform !== "win32"; + terminateProcessTree(this.child, "SIGTERM", detached); + const exited = await Promise.race([ + this.closePromise.then(() => true), + delay(SHUTDOWN_GRACE_MS).then(() => false), + ]); + if (!exited && this.child.exitCode === null && this.child.signalCode === null) { + terminateProcessTree(this.child, "SIGKILL", detached); + await this.closePromise; + } + } + + private handleLine(line: string): void { + let message: unknown; + try { + message = JSON.parse(line) as unknown; + } catch { + this.usable = false; + this.failPending(new Error(`Codex app-server emitted invalid JSON: ${line.slice(0, 500)}`)); + return; + } + if (!isRecord(message)) return; + + const id = requestId(message.id); + const method = typeof message.method === "string" ? message.method : undefined; + if (id !== undefined && method) { + this.write({ + id: message.id, + error: { + code: -32601, + message: `DevSpace does not handle Codex server request ${method}.`, + }, + }); + return; + } + if (id !== undefined) { + const pending = this.pending.get(id); + if (!pending) return; + this.pending.delete(id); + if (isRecord(message.error)) { + pending.reject(new Error(codexRpcError(message.error))); + } else { + pending.resolve(message.result); + } + return; + } + if (!method) return; + for (const handler of this.notificationHandlers) { + handler(method, message.params); + } + } + + private write(payload: unknown, reject?: (error: Error) => void): void { + this.child.stdin.write(`${JSON.stringify(payload)}\n`, (error) => { + if (!error) return; + this.usable = false; + const wrapped = new Error(`Failed to write to Codex app-server: ${error.message}`); + reject?.(wrapped); + this.failPending(wrapped); + }); + } + + private failPending(error: Error): void { + for (const pending of this.pending.values()) pending.reject(error); + this.pending.clear(); + } +} + +function requestId(value: unknown): string | undefined { + return typeof value === "string" || typeof value === "number" ? String(value) : undefined; +} + +function codexRpcError(error: Record): string { + const message = typeof error.message === "string" ? error.message : "Codex app-server request failed"; + const code = typeof error.code === "number" ? ` (${error.code})` : ""; + return `${message}${code}`; +} + +function takeTail(value: string, maxLength: number): string { + return value.length <= maxLength ? value : value.slice(value.length - maxLength); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/local-agent-codex/command.ts b/src/local-agent-codex/command.ts new file mode 100644 index 00000000..0c872468 --- /dev/null +++ b/src/local-agent-codex/command.ts @@ -0,0 +1,57 @@ +import { spawnSync } from "node:child_process"; +import { + removeDevspaceNodeModulesBinFromPath, + resolveLocalAgentExecutable, +} from "../local-agent-path.js"; + +export interface ResolvedCodexCommand { + executable: string; + env: NodeJS.ProcessEnv; + runtimeKey: string; +} + +export function resolveCodexCommand( + env: NodeJS.ProcessEnv = process.env, +): ResolvedCodexCommand | undefined { + const explicit = env.CODEX_COMMAND?.trim(); + const commandEnv = explicit ? { ...env } : codexDefaultEnvironment(env); + const executable = resolveLocalAgentExecutable(explicit || "codex", commandEnv); + if (!executable) return undefined; + return { + executable, + env: commandEnv, + runtimeKey: `${executable}\0${commandEnv.CODEX_HOME ?? ""}`, + }; +} + +export function checkCodexAppServerAvailability( + env: NodeJS.ProcessEnv = process.env, +): { available: true } | { available: false; reason: string } { + const resolved = resolveCodexCommand(env); + if (!resolved) { + return { + available: false, + reason: `${env.CODEX_COMMAND?.trim() || "codex"} executable not found`, + }; + } + const result = spawnSync(resolved.executable, ["app-server", "--help"], { + encoding: "utf8", + env: resolved.env, + windowsHide: true, + timeout: 5_000, + }); + if (!result.error && result.status === 0) return { available: true }; + const detail = result.stderr?.trim() || result.error?.message || `exit ${result.status ?? "unknown"}`; + return { + available: false, + reason: `Codex CLI does not support app-server (${detail})`, + }; +} + +function codexDefaultEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + if (!env.PATH) return { ...env }; + return { + ...env, + PATH: removeDevspaceNodeModulesBinFromPath(env.PATH), + }; +} diff --git a/src/local-agent-path.ts b/src/local-agent-path.ts index c8ff2935..8feb3c77 100644 --- a/src/local-agent-path.ts +++ b/src/local-agent-path.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { accessSync, constants, existsSync, readFileSync } from "node:fs"; import { delimiter, resolve, sep } from "node:path"; export function removeDevspaceNodeModulesBinFromPath(pathValue: string): string { @@ -8,6 +8,42 @@ export function removeDevspaceNodeModulesBinFromPath(pathValue: string): string .join(delimiter); } +export function resolveLocalAgentExecutable( + command: string, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + if (command.includes("/") || command.includes("\\")) { + const candidate = resolve(command); + return executableExists(candidate) ? candidate : undefined; + } + + const path = env.PATH; + if (!path) return undefined; + const extensions = process.platform === "win32" + ? (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD") + .split(";") + .filter(Boolean) + : [""]; + + for (const directory of path.split(delimiter)) { + if (!directory) continue; + for (const extension of extensions) { + const candidate = resolve(directory, `${command}${extension}`); + if (executableExists(candidate)) return candidate; + } + } + return undefined; +} + +function executableExists(path: string): boolean { + try { + accessSync(path, process.platform === "win32" ? constants.F_OK : constants.X_OK); + return true; + } catch { + return false; + } +} + function isDevspaceNodeModulesBin(pathEntry: string): boolean { const resolvedEntry = resolve(pathEntry); if (!resolvedEntry.endsWith(`${sep}node_modules${sep}.bin`)) { From e70d0e43929bec81d7e945554d93e4c3a3c42a45 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 00:07:00 +0530 Subject: [PATCH 2/6] refactor(codex): pool app-server threads --- package.json | 2 +- src/local-agent-adapters.ts | 15 +- src/local-agent-codex/runtime.test.ts | 144 ++++++++++++++ src/local-agent-codex/runtime.ts | 273 ++++++++++++++++++++++++++ src/local-agent-runtime-registry.ts | 7 + src/local-agent-runtime.test.ts | 100 ---------- src/local-agent-runtime.ts | 80 -------- 7 files changed, 433 insertions(+), 188 deletions(-) create mode 100644 src/local-agent-codex/runtime.test.ts create mode 100644 src/local-agent-codex/runtime.ts delete mode 100644 src/local-agent-runtime.test.ts diff --git a/package.json b/package.json index a9a17f3f..bc44a558 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/local-agent-control.test.ts && tsx src/local-agent-runtime-pool.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", + "test": "tsx src/config.test.ts && tsx src/request-meta.test.ts && tsx src/incoming-artifacts.test.ts && tsx src/artifact-download.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-codex/runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/local-agent-manager.test.ts && tsx src/local-agent-control.test.ts && tsx src/local-agent-runtime-pool.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/workspace-conversation.test.ts && tsx src/review-checkpoints.test.ts && tsx src/server.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/local-agent-adapters.ts b/src/local-agent-adapters.ts index 53964550..af31b408 100644 --- a/src/local-agent-adapters.ts +++ b/src/local-agent-adapters.ts @@ -4,12 +4,9 @@ import { Readable, Writable } from "node:stream"; import type { EffortLevel } from "@anthropic-ai/claude-agent-sdk"; import type { LocalAgentProvider } from "./local-agent-profiles.js"; import { removeDevspaceNodeModulesBinFromPath } from "./local-agent-path.js"; -import { - createCodexSdkLocalAgentRuntime, - type LocalAgentRunInput, - type LocalAgentRunResult, -} from "./local-agent-runtime.js"; +import type { LocalAgentRunInput, LocalAgentRunResult } from "./local-agent-runtime.js"; import type { HarnessDriver, HarnessRuntime } from "./local-agent-runtime-pool.js"; +import { createCodexHarnessDriver } from "./local-agent-codex/runtime.js"; export interface LocalAgentAdapter { readonly provider: LocalAgentProvider; @@ -49,8 +46,12 @@ class CodexLocalAgentAdapter implements LocalAgentAdapter { readonly provider = "codex" as const; async run(input: LocalAgentRunInput): Promise { - const runtime = await createCodexSdkLocalAgentRuntime(); - return runtime.run(input); + const runtime = await createCodexHarnessDriver().createRuntime(input); + try { + return await runtime.run(input); + } finally { + await runtime.close(); + } } } diff --git a/src/local-agent-codex/runtime.test.ts b/src/local-agent-codex/runtime.test.ts new file mode 100644 index 00000000..05bdd370 --- /dev/null +++ b/src/local-agent-codex/runtime.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { CodexAppServerRuntime } from "./runtime.js"; +import type { CodexAppServerConnection } from "./app-server-transport.js"; + +class FakeCodexConnection implements CodexAppServerConnection { + readonly requests: Array<{ method: string; params: unknown }> = []; + private readonly handlers = new Set<(method: string, params: unknown) => void>(); + private threadCount = 0; + private turnCount = 0; + private usable = true; + + async request(method: string, params?: unknown): Promise { + this.requests.push({ method, params }); + if (method === "thread/start") { + return { thread: { id: `thread_${++this.threadCount}` } }; + } + if (method === "thread/resume") { + const record = asRecord(params); + return { thread: { id: record?.threadId } }; + } + if (method === "turn/start") { + const record = asRecord(params); + const threadId = stringValue(record?.threadId) ?? "missing"; + const prompt = firstPrompt(record?.input); + const turnId = `turn_${++this.turnCount}`; + queueMicrotask(() => { + this.emit("item/completed", { + threadId, + turnId, + item: { + type: "agentMessage", + id: `item_${turnId}`, + text: `response:${prompt}`, + phase: "final_answer", + }, + }); + this.emit("turn/completed", { + threadId, + turn: { + id: turnId, + status: "completed", + items: [], + }, + }); + }); + return { turn: { id: turnId } }; + } + throw new Error(`Unexpected request: ${method}`); + } + + notify(): void {} + + onNotification(handler: (method: string, params: unknown) => void): () => void { + this.handlers.add(handler); + return () => this.handlers.delete(handler); + } + + isUsable(): boolean { + return this.usable; + } + + async close(): Promise { + this.usable = false; + } + + private emit(method: string, params: unknown): void { + for (const handler of this.handlers) handler(method, params); + } +} + +const connection = new FakeCodexConnection(); +const runtime = new CodexAppServerRuntime(connection); + +try { + const [first, second] = await Promise.all([ + runtime.run({ + workspace: "/tmp/a", + prompt: "one", + writeMode: "read_only", + model: "gpt-test", + thinking: "high", + }), + runtime.run({ + workspace: "/tmp/b", + prompt: "two", + writeMode: "allowed", + }), + ]); + + assert.equal(first.providerSessionId, "thread_1"); + assert.equal(first.finalResponse, "response:one"); + assert.equal(second.providerSessionId, "thread_2"); + assert.equal(second.finalResponse, "response:two"); + + const resumed = await runtime.run({ + workspace: "/tmp/a", + prompt: "continue", + providerSessionId: "thread_1", + writeMode: "full_access", + model: "gpt-next", + thinking: "xhigh", + }); + assert.equal(resumed.providerSessionId, "thread_1"); + assert.equal(resumed.finalResponse, "response:continue"); + + const startRequests = connection.requests.filter((request) => request.method === "thread/start"); + assert.equal(startRequests.length, 2, "one App Server runtime should host multiple Codex threads"); + assert.deepEqual(startRequests.map((request) => asRecord(request.params)?.sandbox), [ + "read-only", + "workspace-write", + ]); + + const resumeRequest = connection.requests.find((request) => request.method === "thread/resume"); + assert.deepEqual(resumeRequest, { + method: "thread/resume", + params: { + threadId: "thread_1", + cwd: "/tmp/a", + approvalPolicy: "never", + sandbox: "danger-full-access", + model: "gpt-next", + }, + }); + + const finalTurn = connection.requests.filter((request) => request.method === "turn/start").at(-1); + assert.equal(asRecord(finalTurn?.params)?.effort, "xhigh"); +} finally { + await runtime.close(); +} + +function firstPrompt(value: unknown): string { + if (!Array.isArray(value)) return ""; + return stringValue(asRecord(value[0])?.text) ?? ""; +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} diff --git a/src/local-agent-codex/runtime.ts b/src/local-agent-codex/runtime.ts new file mode 100644 index 00000000..62af37b5 --- /dev/null +++ b/src/local-agent-codex/runtime.ts @@ -0,0 +1,273 @@ +import type { LocalAgentRunInput, LocalAgentRunResult, LocalAgentWriteMode } from "../local-agent-runtime.js"; +import type { HarnessDriver, HarnessRuntime } from "../local-agent-runtime-pool.js"; +import { + startCodexAppServer, + type CodexAppServerConnection, +} from "./app-server-transport.js"; +import { resolveCodexCommand } from "./command.js"; + +type CodexSandboxMode = "read-only" | "workspace-write" | "danger-full-access"; + +interface PendingTurn { + turnId?: string; + items: unknown[]; + promise: Promise; + resolve(value: TurnCompletion): void; + reject(error: Error): void; +} + +interface TurnCompletion { + status: "completed" | "interrupted" | "failed"; + error?: string; + items: unknown[]; +} + +/** + * One runtime owns one App Server connection and can host many Codex threads. + * Thread ids remain durable outside this module, so closing this runtime never + * destroys the logical DevSpace agents that were using it. + */ +export class CodexAppServerRuntime implements HarnessRuntime { + private readonly pendingTurns = new Map(); + private readonly unsubscribe: () => void; + private closed = false; + + constructor(private readonly connection: CodexAppServerConnection) { + this.unsubscribe = connection.onNotification((method, params) => { + this.handleNotification(method, params); + }); + } + + async run(input: LocalAgentRunInput): Promise { + if (!this.isUsable()) throw new Error("Codex app-server runtime is closed."); + + const threadId = input.providerSessionId + ? await this.resumeThread(input.providerSessionId, input) + : await this.startThread(input); + if (this.pendingTurns.has(threadId)) { + throw new Error(`Codex thread ${threadId} already has a turn in progress.`); + } + + const pending = createPendingTurn(); + this.pendingTurns.set(threadId, pending); + try { + const response = await this.connection.request("turn/start", { + threadId, + input: [{ type: "text", text: input.prompt, text_elements: [] }], + cwd: input.workspace, + approvalPolicy: "never", + model: input.model, + effort: input.thinking, + }); + pending.turnId = parseTurnStartResponse(response); + const completion = await pending.promise; + if (completion.status !== "completed") { + throw new Error( + completion.error + ? `Codex turn ${completion.status}: ${completion.error}` + : `Codex turn ${completion.status}.`, + ); + } + const items = mergeItems(pending.items, completion.items); + const finalResponse = finalAgentMessage(items); + if (!finalResponse) throw new Error("Codex completed without a final response."); + return { + provider: "codex", + providerSessionId: threadId, + finalResponse, + items, + }; + } catch (error) { + if (this.pendingTurns.get(threadId) === pending) this.pendingTurns.delete(threadId); + throw error; + } + } + + isUsable(): boolean { + return !this.closed && this.connection.isUsable(); + } + + async close(): Promise { + if (this.closed) return; + this.closed = true; + this.unsubscribe(); + const error = new Error("Codex app-server runtime closed."); + for (const pending of this.pendingTurns.values()) pending.reject(error); + this.pendingTurns.clear(); + await this.connection.close(); + } + + private async startThread(input: LocalAgentRunInput): Promise { + const response = await this.connection.request("thread/start", { + cwd: input.workspace, + approvalPolicy: "never", + sandbox: sandboxModeFor(input.writeMode), + model: input.model, + }); + return parseThreadResponse(response, "thread/start"); + } + + private async resumeThread(threadId: string, input: LocalAgentRunInput): Promise { + const response = await this.connection.request("thread/resume", { + threadId, + cwd: input.workspace, + approvalPolicy: "never", + sandbox: sandboxModeFor(input.writeMode), + model: input.model, + }); + return parseThreadResponse(response, "thread/resume"); + } + + private handleNotification(method: string, params: unknown): void { + if (method === "item/completed") { + const item = parseCompletedItem(params); + if (!item) return; + const pending = this.pendingTurns.get(item.threadId); + if (!pending) return; + if (pending.turnId && pending.turnId !== item.turnId) return; + pending.items.push(item.item); + return; + } + if (method !== "turn/completed") return; + const completion = parseTurnCompletion(params); + if (!completion) return; + const pending = this.pendingTurns.get(completion.threadId); + if (!pending) return; + if (pending.turnId && pending.turnId !== completion.turnId) return; + this.pendingTurns.delete(completion.threadId); + pending.resolve(completion.result); + } +} + +export function createCodexHarnessDriver( + env: NodeJS.ProcessEnv = process.env, +): HarnessDriver { + return { + provider: "codex", + runtimeKey: () => resolveCodexCommand(env)?.runtimeKey ?? "unavailable", + createRuntime: async () => { + const command = resolveCodexCommand(env); + if (!command) { + throw new Error( + `${env.CODEX_COMMAND?.trim() || "codex"} executable not found. Install Codex or set CODEX_COMMAND.`, + ); + } + return new CodexAppServerRuntime(await startCodexAppServer(command)); + }, + }; +} + +function sandboxModeFor(writeMode: LocalAgentWriteMode | undefined): CodexSandboxMode { + switch (writeMode) { + case "allowed": + return "workspace-write"; + case "full_access": + return "danger-full-access"; + case "read_only": + case undefined: + return "read-only"; + } +} + +function createPendingTurn(): PendingTurn { + let resolve!: (value: TurnCompletion) => void; + let reject!: (error: Error) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { items: [], promise, resolve, reject }; +} + +function parseThreadResponse(value: unknown, method: string): string { + const result = asRecord(value); + const thread = asRecord(result?.thread); + const id = stringValue(thread?.id); + if (!id) throw new Error(`Codex ${method} response is missing thread.id.`); + return id; +} + +function parseTurnStartResponse(value: unknown): string { + const result = asRecord(value); + const turn = asRecord(result?.turn); + const id = stringValue(turn?.id); + if (!id) throw new Error("Codex turn/start response is missing turn.id."); + return id; +} + +function parseCompletedItem(value: unknown): { threadId: string; turnId: string; item: unknown } | undefined { + const params = asRecord(value); + const threadId = stringValue(params?.threadId); + const turnId = stringValue(params?.turnId); + if (!threadId || !turnId || params?.item === undefined) return undefined; + return { threadId, turnId, item: params.item }; +} + +function parseTurnCompletion(value: unknown): { + threadId: string; + turnId: string; + result: TurnCompletion; +} | undefined { + const params = asRecord(value); + const threadId = stringValue(params?.threadId); + const turn = asRecord(params?.turn); + const turnId = stringValue(turn?.id); + const status = turn?.status; + if (!threadId || !turn || !turnId || (status !== "completed" && status !== "interrupted" && status !== "failed")) { + return undefined; + } + const error = asRecord(turn.error); + const errorMessage = stringValue(error?.message) ?? stringValue(error?.additionalDetails); + return { + threadId, + turnId, + result: { + status, + error: errorMessage, + items: Array.isArray(turn.items) ? turn.items : [], + }, + }; +} + +function mergeItems(completed: unknown[], turnItems: unknown[]): unknown[] { + if (completed.length === 0) return turnItems; + if (turnItems.length === 0) return completed; + const merged = [...completed]; + const ids = new Set(completed.map(itemId).filter((id): id is string => Boolean(id))); + for (const item of turnItems) { + const id = itemId(item); + if (id && ids.has(id)) continue; + if (id) ids.add(id); + merged.push(item); + } + return merged; +} + +function finalAgentMessage(items: unknown[]): string | undefined { + let fallback: string | undefined; + let final: string | undefined; + for (const item of items) { + const record = asRecord(item); + if (record?.type !== "agentMessage") continue; + const text = stringValue(record.text); + if (!text) continue; + fallback = text; + if (record.phase === "final_answer") final = text; + } + return final ?? fallback; +} + +function itemId(value: unknown): string | undefined { + return stringValue(asRecord(value)?.id); +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function asRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} + diff --git a/src/local-agent-runtime-registry.ts b/src/local-agent-runtime-registry.ts index 6354d54e..97f21149 100644 --- a/src/local-agent-runtime-registry.ts +++ b/src/local-agent-runtime-registry.ts @@ -1,6 +1,7 @@ import type { LocalAgentProvider } from "./local-agent-profiles.js"; import type { LocalAgentRunInput, LocalAgentRunResult } from "./local-agent-runtime.js"; import { createLocalAgentAdapter, createOpencodeHarnessDriver } from "./local-agent-adapters.js"; +import { createCodexHarnessDriver } from "./local-agent-codex/runtime.js"; import { HarnessRuntimePool, type HarnessDriver, @@ -8,6 +9,7 @@ import { interface LocalAgentRuntimeRegistryOptions { pool?: HarnessRuntimePool; + codexDriver?: HarnessDriver; opencodeDriver?: HarnessDriver; } @@ -17,10 +19,12 @@ interface LocalAgentRuntimeRegistryOptions { */ export class LocalAgentRuntimeRegistry { private readonly pool: HarnessRuntimePool; + private readonly codexDriver: HarnessDriver; private readonly opencodeDriver: HarnessDriver; constructor(options: LocalAgentRuntimeRegistryOptions = {}) { this.pool = options.pool ?? new HarnessRuntimePool(); + this.codexDriver = options.codexDriver ?? createCodexHarnessDriver(); this.opencodeDriver = options.opencodeDriver ?? createOpencodeHarnessDriver(); } @@ -28,6 +32,9 @@ export class LocalAgentRuntimeRegistry { provider: LocalAgentProvider, input: LocalAgentRunInput, ): Promise { + if (provider === "codex") { + return this.pool.run(this.codexDriver, input); + } if (provider === "opencode") { return this.pool.run(this.opencodeDriver, input); } diff --git a/src/local-agent-runtime.test.ts b/src/local-agent-runtime.test.ts deleted file mode 100644 index 1d45d166..00000000 --- a/src/local-agent-runtime.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import assert from "node:assert/strict"; -import type { RunResult, ThreadOptions } from "@openai/codex-sdk"; -import { - CodexSdkLocalAgentRuntime, - createCodexSdkLocalAgentRuntime, -} from "./local-agent-runtime.js"; - -const emptyTurn = (finalResponse: string): RunResult => ({ - finalResponse, - items: [], - usage: null, -}); - -class FakeThread { - prompts: string[] = []; - - constructor(readonly id: string | null) {} - - async run(prompt: string): Promise { - this.prompts.push(prompt); - return emptyTurn(`response:${prompt}`); - } -} - -class FakeCodex { - started: ThreadOptions[] = []; - resumed: Array<{ id: string; options?: ThreadOptions }> = []; - readonly startThreadInstance = new FakeThread("new-thread"); - readonly resumeThreadInstance = new FakeThread("resumed-thread"); - - startThread(options?: ThreadOptions): FakeThread { - this.started.push(options ?? {}); - return this.startThreadInstance; - } - - resumeThread(id: string, options?: ThreadOptions): FakeThread { - this.resumed.push({ id, options }); - return this.resumeThreadInstance; - } -} - -const codex = new FakeCodex(); -const runtime = new CodexSdkLocalAgentRuntime(codex); -const readOnly = await runtime.run({ - prompt: "inspect only", - workspace: "/tmp/project", -}); - -assert.equal(readOnly.provider, "codex"); -assert.equal(readOnly.providerSessionId, "new-thread"); -assert.equal(readOnly.finalResponse, "response:inspect only"); -assert.deepEqual(codex.startThreadInstance.prompts, ["inspect only"]); -assert.deepEqual(codex.started[0], { - workingDirectory: "/tmp/project", - sandboxMode: "read-only", - approvalPolicy: "never", - model: undefined, - modelReasoningEffort: undefined, -}); - -await runtime.run({ - prompt: "make change", - workspace: "/tmp/project", - writeMode: "allowed", - model: "gpt-5.4", - thinking: "high", -}); - -assert.deepEqual(codex.started[1], { - workingDirectory: "/tmp/project", - sandboxMode: "workspace-write", - approvalPolicy: "never", - model: "gpt-5.4", - modelReasoningEffort: "high", -}); - -const resumed = await runtime.run({ - prompt: "continue", - workspace: "/tmp/project", - providerSessionId: "existing-thread", - writeMode: "full_access", -}); - -assert.equal(resumed.providerSessionId, "resumed-thread"); -assert.deepEqual(codex.resumeThreadInstance.prompts, ["continue"]); -assert.deepEqual(codex.resumed, [ - { - id: "existing-thread", - options: { - workingDirectory: "/tmp/project", - sandboxMode: "danger-full-access", - approvalPolicy: "never", - model: undefined, - modelReasoningEffort: undefined, - }, - }, -]); - -const created = await createCodexSdkLocalAgentRuntime(undefined, () => new FakeCodex()); -assert.equal(created.provider, "codex"); diff --git a/src/local-agent-runtime.ts b/src/local-agent-runtime.ts index 54130c2e..9548326d 100644 --- a/src/local-agent-runtime.ts +++ b/src/local-agent-runtime.ts @@ -1,12 +1,3 @@ -import type { - Codex, - CodexOptions, - ModelReasoningEffort, - RunResult, - SandboxMode, - ThreadOptions, -} from "@openai/codex-sdk"; - export type LocalAgentWriteMode = "read_only" | "allowed" | "full_access"; export interface LocalAgentRunInput { @@ -29,74 +20,3 @@ export interface LocalAgentRuntime { readonly provider: string; run(input: LocalAgentRunInput): Promise; } - -interface CodexThreadLike { - readonly id: string | null; - run(prompt: string): Promise; -} - -interface CodexClientLike { - startThread(options?: ThreadOptions): CodexThreadLike; - resumeThread(id: string, options?: ThreadOptions): CodexThreadLike; -} - -type CodexFactory = (options?: CodexOptions) => CodexClientLike; - -function sandboxModeFor(writeMode: LocalAgentWriteMode | undefined): SandboxMode { - switch (writeMode) { - case "allowed": - return "workspace-write"; - case "full_access": - return "danger-full-access"; - case "read_only": - case undefined: - return "read-only"; - } -} - -function threadOptionsFor(input: LocalAgentRunInput): ThreadOptions { - return { - workingDirectory: input.workspace, - sandboxMode: sandboxModeFor(input.writeMode), - approvalPolicy: "never", - model: input.model, - modelReasoningEffort: input.thinking as ModelReasoningEffort | undefined, - }; -} - -export class CodexSdkLocalAgentRuntime implements LocalAgentRuntime { - readonly provider = "codex" as const; - private readonly codex: CodexClientLike; - - constructor(codex: CodexClientLike) { - this.codex = codex; - } - - async run(input: LocalAgentRunInput): Promise { - const options = threadOptionsFor(input); - const thread = input.providerSessionId - ? this.codex.resumeThread(input.providerSessionId, options) - : this.codex.startThread(options); - const turn = await thread.run(input.prompt); - - return { - provider: this.provider, - providerSessionId: thread.id, - finalResponse: turn.finalResponse, - items: turn.items, - }; - } -} - -export async function createCodexSdkLocalAgentRuntime( - options?: CodexOptions, - codexFactory?: CodexFactory, -): Promise { - const factory = codexFactory ?? (await defaultCodexFactory()); - return new CodexSdkLocalAgentRuntime(factory(options)); -} - -async function defaultCodexFactory(): Promise { - const module = await import("@openai/codex-sdk"); - return (options) => new module.Codex(options) as Codex; -} From 5e4190922583b6106f33758273463f848f0d9933 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 00:07:06 +0530 Subject: [PATCH 3/6] build(codex): remove bundled sdk runtime --- docs/agent-profile-schema.md | 4 +- docs/configuration.md | 4 ++ package-lock.json | 135 ----------------------------------- package.json | 1 - 4 files changed, 6 insertions(+), 138 deletions(-) diff --git a/docs/agent-profile-schema.md b/docs/agent-profile-schema.md index 0dc3db95..1737219c 100644 --- a/docs/agent-profile-schema.md +++ b/docs/agent-profile-schema.md @@ -71,7 +71,7 @@ provider: copilot Unsupported or custom providers are rejected. DevSpace maps providers to their native integration: -- `codex`: Codex SDK +- `codex`: the user's Codex CLI through `codex app-server` - `claude`: Claude Code SDK - `opencode`: OpenCode SDK - `pi`: Pi RPC mode @@ -102,7 +102,7 @@ thinking: xhigh DevSpace passes this through to providers that expose a matching control: - `claude`: SDK effort with adaptive thinking. -- `codex`: SDK model reasoning effort. +- `codex`: App Server turn reasoning effort. - `pi`: `--thinking`. - `opencode`: model variant. - `cursor` and `copilot`: ACP thought-level config when supported. diff --git a/docs/configuration.md b/docs/configuration.md index c44da6d3..4b8a767e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -161,6 +161,10 @@ terminal. Starter profile templates are available under `examples/agents/`. Copy or adapt them into one of the active profile directories before use. +Codex subagents use the user's installed `codex` CLI and its existing login, +configuration, and `CODEX_HOME`. Set `CODEX_COMMAND` to an explicit executable +when DevSpace should use a non-default Codex installation. + Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed. Example: diff --git a/package-lock.json b/package-lock.json index 79993030..642d8140 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,6 @@ "@earendil-works/pi-coding-agent": "^0.80.3", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", - "@openai/codex-sdk": "^0.142.5", "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", "better-sqlite3": "^12.10.0", @@ -2643,140 +2642,6 @@ "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@openai/codex": { - "version": "0.142.5", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5.tgz", - "integrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", - "license": "Apache-2.0", - "bin": { - "codex": "bin/codex.js" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.142.5-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.142.5-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.142.5-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.142.5-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.142.5-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.142.5-win32-x64" - } - }, - "node_modules/@openai/codex-darwin-arm64": { - "name": "@openai/codex", - "version": "0.142.5-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-darwin-arm64.tgz", - "integrity": "sha512-l43p8xv+Z/2/b6fCUc7/FmcQZsaPB7RFizLponGwHAnFOWe3i9Vky69p+up3BUam9AetoQQUv7Mo+2KdaFEqhA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@openai/codex-darwin-x64": { - "name": "@openai/codex", - "version": "0.142.5-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-darwin-x64.tgz", - "integrity": "sha512-yk6A06/VmW7NFsa48OVPaj//g/zeSpd79wjuqfXZwW8ZKRYQm3+wCd3hWjPl79F3QnXvDvM2j3JMIBL3m3GXXg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@openai/codex-linux-arm64": { - "name": "@openai/codex", - "version": "0.142.5-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-linux-arm64.tgz", - "integrity": "sha512-77ka5PSnm5HdxdBT99IwntCasmbqevlS0eiC0AtEb6ZXCLkim2gm0AWm+jNYy0EhbssvNK+KghayWo34HMgXeA==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@openai/codex-linux-x64": { - "name": "@openai/codex", - "version": "0.142.5-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-linux-x64.tgz", - "integrity": "sha512-pxY+d3NgNE57Y/MApD3/TZUAygxJN6I9h3ZeDUwe67mxWjUxsuapxMRFTKSznCalYbRAeZp752+AAXmUbmguEg==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@openai/codex-sdk": { - "version": "0.142.5", - "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.142.5.tgz", - "integrity": "sha512-MConZ+eoBoZmkc4reezuzOgLtoI1BQBzo/nVYsSjtAIBpwKcgeEm1rfmqfUnTfFaBNHFTxBntcS7ZeQYuDPbWA==", - "license": "Apache-2.0", - "dependencies": { - "@openai/codex": "0.142.5" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@openai/codex-win32-arm64": { - "name": "@openai/codex", - "version": "0.142.5-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-win32-arm64.tgz", - "integrity": "sha512-65BEqGbUZ7r0ayunIHdBjo5crwgbwKX/6puOcO+VCswUw/dXvDsN2IGcbXB52+bS9U5+FxP783cUHfTT6m40DQ==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@openai/codex-win32-x64": { - "name": "@openai/codex", - "version": "0.142.5-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-win32-x64.tgz", - "integrity": "sha512-a+wI4PEx9a2fg6V5ueTTDkOkr1XpEvA5RFXIbo/L2hOfzMmGtyRnbG24bCGu5Q2RSgVxSQV0aLkdb3vdYMNH9A==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, "node_modules/@opencode-ai/sdk": { "version": "1.17.13", "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.17.13.tgz", diff --git a/package.json b/package.json index bc44a558..45482ca0 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "@earendil-works/pi-coding-agent": "^0.80.3", "@modelcontextprotocol/ext-apps": "^1.7.2", "@modelcontextprotocol/sdk": "^1.29.0", - "@openai/codex-sdk": "^0.142.5", "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.2.5", "better-sqlite3": "^12.10.0", From ea5f12f65569abfc04acec0809b5e794cdcc9d3c Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 07:22:04 +0530 Subject: [PATCH 4/6] test(codex): make availability environment-neutral --- src/local-agent-availability.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 5d56697c..86caf77b 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -5,7 +5,14 @@ import { getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js"; -assert.equal(checkLocalAgentProviderAvailability("codex").available, true); +{ + const availability = checkLocalAgentProviderAvailability("codex", { + ...process.env, + CODEX_COMMAND: "/definitely/missing/devspace-codex", + }); + assert.equal(availability.available, false); + assert.match(availability.reason ?? "", /executable not found/); +} { const availability = checkLocalAgentProviderAvailability("pi", { From c90cb7fbeda365fd63a620c67910c3d95cad4bbd Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 08:05:40 +0530 Subject: [PATCH 5/6] fix(codex): bound app-server process lifecycle --- src/local-agent-availability.test.ts | 36 ++++++++++++ src/local-agent-codex/app-server-transport.ts | 37 +++++++++--- src/local-agent-codex/command.ts | 56 ++++++++++++++++++- src/local-agent-codex/runtime.test.ts | 40 ++++++++++++- src/local-agent-codex/runtime.ts | 13 ++++- 5 files changed, 171 insertions(+), 11 deletions(-) diff --git a/src/local-agent-availability.test.ts b/src/local-agent-availability.test.ts index 86caf77b..125b69c2 100644 --- a/src/local-agent-availability.test.ts +++ b/src/local-agent-availability.test.ts @@ -4,6 +4,7 @@ import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvailabilitySnapshot, } from "./local-agent-availability.js"; +import { buildCodexProcessLaunch } from "./local-agent-codex/command.js"; { const availability = checkLocalAgentProviderAvailability("codex", { @@ -14,6 +15,41 @@ import { assert.match(availability.reason ?? "", /executable not found/); } +{ + const native = buildCodexProcessLaunch({ + executable: "C:\\Program Files\\Codex\\codex.exe", + env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" }, + runtimeKey: "native", + }, ["app-server"], "win32"); + assert.deepEqual(native, { + executable: "C:\\Program Files\\Codex\\codex.exe", + args: ["app-server"], + }); + + const shim = buildCodexProcessLaunch({ + executable: "C:\\Users\\me\\App Data\\npm\\codex.cmd", + env: { ComSpec: "C:\\Windows\\System32\\cmd.exe" }, + runtimeKey: "shim", + }, ["app-server", "--help"], "win32"); + assert.deepEqual(shim, { + executable: "C:\\Windows\\System32\\cmd.exe", + args: [ + "/d", + "/s", + "/c", + '""C:\\Users\\me\\App Data\\npm\\codex.cmd" app-server --help"', + ], + }); + assert.throws( + () => buildCodexProcessLaunch({ + executable: "C:\\Users\\me&bad\\codex.cmd", + env: { ComSpec: "cmd.exe" }, + runtimeKey: "unsafe", + }, ["app-server"], "win32"), + /cannot be launched safely/, + ); +} + { const availability = checkLocalAgentProviderAvailability("pi", { ...process.env, diff --git a/src/local-agent-codex/app-server-transport.ts b/src/local-agent-codex/app-server-transport.ts index 2f19ab24..66ae2427 100644 --- a/src/local-agent-codex/app-server-transport.ts +++ b/src/local-agent-codex/app-server-transport.ts @@ -1,7 +1,10 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createInterface } from "node:readline"; import { terminateProcessTree } from "../process-platform.js"; -import type { ResolvedCodexCommand } from "./command.js"; +import { + buildCodexProcessLaunch, + type ResolvedCodexCommand, +} from "./command.js"; const STDERR_LIMIT = 32_000; const SHUTDOWN_GRACE_MS = 2_000; @@ -10,6 +13,7 @@ export interface CodexAppServerConnection { request(method: string, params?: unknown): Promise; notify(method: string, params?: unknown): void; onNotification(handler: (method: string, params: unknown) => void): () => void; + onClose(handler: (error: Error) => void): () => void; isUsable(): boolean; close(): Promise; } @@ -44,27 +48,28 @@ class StdioCodexAppServerConnection implements CodexAppServerConnection { private readonly child: ChildProcessWithoutNullStreams; private readonly pending = new Map(); private readonly notificationHandlers = new Set<(method: string, params: unknown) => void>(); + private readonly closeHandlers = new Set<(error: Error) => void>(); private readonly closePromise: Promise; private nextRequestId = 1; private stderr = ""; private usable = true; private closing = false; + private connectionFailure: Error | undefined; constructor(command: ResolvedCodexCommand) { const detached = process.platform !== "win32"; - this.child = spawn(command.executable, ["app-server"], { + const launch = buildCodexProcessLaunch(command, ["app-server"]); + this.child = spawn(launch.executable, launch.args, { env: command.env, stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached, - shell: process.platform === "win32", }); this.closePromise = new Promise((resolve) => { this.child.once("close", (code, signal) => { - this.usable = false; const suffix = this.stderr.trim() ? `\n${this.stderr.trim()}` : ""; - this.failPending(new Error( + this.failConnection(new Error( `Codex app-server exited${code !== null ? ` with code ${code}` : signal ? ` from ${signal}` : ""}.${suffix}`, )); resolve(); @@ -72,8 +77,7 @@ class StdioCodexAppServerConnection implements CodexAppServerConnection { }); this.child.once("error", (error) => { - this.usable = false; - this.failPending(new Error(`Codex app-server failed to start: ${error.message}`)); + this.failConnection(new Error(`Codex app-server failed to start: ${error.message}`)); }); this.child.stderr.on("data", (chunk: Buffer) => { this.stderr = takeTail(this.stderr + chunk.toString("utf8"), STDERR_LIMIT); @@ -105,6 +109,16 @@ class StdioCodexAppServerConnection implements CodexAppServerConnection { return () => this.notificationHandlers.delete(handler); } + onClose(handler: (error: Error) => void): () => void { + if (this.connectionFailure) { + const failure = this.connectionFailure; + queueMicrotask(() => handler(failure)); + return () => undefined; + } + this.closeHandlers.add(handler); + return () => this.closeHandlers.delete(handler); + } + isUsable(): boolean { return this.usable && !this.closing && this.child.exitCode === null && this.child.signalCode === null; } @@ -189,6 +203,15 @@ class StdioCodexAppServerConnection implements CodexAppServerConnection { for (const pending of this.pending.values()) pending.reject(error); this.pending.clear(); } + + private failConnection(error: Error): void { + if (this.connectionFailure) return; + this.connectionFailure = error; + this.usable = false; + this.failPending(error); + for (const handler of this.closeHandlers) handler(error); + this.closeHandlers.clear(); + } } function requestId(value: unknown): string | undefined { diff --git a/src/local-agent-codex/command.ts b/src/local-agent-codex/command.ts index 0c872468..dc89f3cf 100644 --- a/src/local-agent-codex/command.ts +++ b/src/local-agent-codex/command.ts @@ -1,4 +1,5 @@ import { spawnSync } from "node:child_process"; +import { extname } from "node:path"; import { removeDevspaceNodeModulesBinFromPath, resolveLocalAgentExecutable, @@ -10,6 +11,11 @@ export interface ResolvedCodexCommand { runtimeKey: string; } +export interface CodexProcessLaunch { + executable: string; + args: string[]; +} + export function resolveCodexCommand( env: NodeJS.ProcessEnv = process.env, ): ResolvedCodexCommand | undefined { @@ -34,7 +40,16 @@ export function checkCodexAppServerAvailability( reason: `${env.CODEX_COMMAND?.trim() || "codex"} executable not found`, }; } - const result = spawnSync(resolved.executable, ["app-server", "--help"], { + let launch: CodexProcessLaunch; + try { + launch = buildCodexProcessLaunch(resolved, ["app-server", "--help"]); + } catch (error) { + return { + available: false, + reason: error instanceof Error ? error.message : String(error), + }; + } + const result = spawnSync(launch.executable, launch.args, { encoding: "utf8", env: resolved.env, windowsHide: true, @@ -48,6 +63,24 @@ export function checkCodexAppServerAvailability( }; } +export function buildCodexProcessLaunch( + command: ResolvedCodexCommand, + args: readonly string[], + platform: NodeJS.Platform = process.platform, +): CodexProcessLaunch { + if (platform !== "win32" || !isWindowsBatchShim(command.executable)) { + return { executable: command.executable, args: [...args] }; + } + + validateWindowsBatchShimPath(command.executable); + for (const arg of args) validateWindowsBatchArgument(arg); + const commandLine = `""${command.executable}"${args.length > 0 ? ` ${args.join(" ")}` : ""}"`; + return { + executable: command.env.ComSpec?.trim() || process.env.ComSpec?.trim() || "cmd.exe", + args: ["/d", "/s", "/c", commandLine], + }; +} + function codexDefaultEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { if (!env.PATH) return { ...env }; return { @@ -55,3 +88,24 @@ function codexDefaultEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { PATH: removeDevspaceNodeModulesBinFromPath(env.PATH), }; } + +function isWindowsBatchShim(executable: string): boolean { + const extension = extname(executable).toLowerCase(); + return extension === ".cmd" || extension === ".bat"; +} + +function validateWindowsBatchShimPath(executable: string): void { + // cmd.exe expands/interprets these characters even inside quoted command + // strings. Codex shims never need them, so reject instead of attempting + // incomplete shell escaping. + if (/["&|<>^%!\r\n]/.test(executable)) { + throw new Error("Codex batch shim path contains characters that cannot be launched safely."); + } +} + +function validateWindowsBatchArgument(arg: string): void { + // DevSpace only invokes fixed Codex subcommands/options through this path. + if (!/^[A-Za-z0-9_-]+$/.test(arg)) { + throw new Error(`Unsafe Codex batch-shim argument: ${arg}`); + } +} diff --git a/src/local-agent-codex/runtime.test.ts b/src/local-agent-codex/runtime.test.ts index 05bdd370..d58c83c6 100644 --- a/src/local-agent-codex/runtime.test.ts +++ b/src/local-agent-codex/runtime.test.ts @@ -5,9 +5,11 @@ import type { CodexAppServerConnection } from "./app-server-transport.js"; class FakeCodexConnection implements CodexAppServerConnection { readonly requests: Array<{ method: string; params: unknown }> = []; private readonly handlers = new Set<(method: string, params: unknown) => void>(); + private readonly closeHandlers = new Set<(error: Error) => void>(); private threadCount = 0; private turnCount = 0; private usable = true; + autoCompleteTurns = true; async request(method: string, params?: unknown): Promise { this.requests.push({ method, params }); @@ -23,7 +25,7 @@ class FakeCodexConnection implements CodexAppServerConnection { const threadId = stringValue(record?.threadId) ?? "missing"; const prompt = firstPrompt(record?.input); const turnId = `turn_${++this.turnCount}`; - queueMicrotask(() => { + if (this.autoCompleteTurns) queueMicrotask(() => { this.emit("item/completed", { threadId, turnId, @@ -55,6 +57,11 @@ class FakeCodexConnection implements CodexAppServerConnection { return () => this.handlers.delete(handler); } + onClose(handler: (error: Error) => void): () => void { + this.closeHandlers.add(handler); + return () => this.closeHandlers.delete(handler); + } + isUsable(): boolean { return this.usable; } @@ -63,6 +70,11 @@ class FakeCodexConnection implements CodexAppServerConnection { this.usable = false; } + emitClose(error: Error): void { + this.usable = false; + for (const handler of this.closeHandlers) handler(error); + } + private emit(method: string, params: unknown): void { for (const handler of this.handlers) handler(method, params); } @@ -128,6 +140,24 @@ try { await runtime.close(); } +{ + const closingConnection = new FakeCodexConnection(); + closingConnection.autoCompleteTurns = false; + const closingRuntime = new CodexAppServerRuntime(closingConnection); + try { + const run = closingRuntime.run({ + workspace: "/tmp/a", + prompt: "wait for close", + writeMode: "allowed", + }); + await waitForRequest(closingConnection, "turn/start"); + closingConnection.emitClose(new Error("Codex app-server exited unexpectedly.")); + await assert.rejects(run, /exited unexpectedly/); + } finally { + await closingRuntime.close(); + } +} + function firstPrompt(value: unknown): string { if (!Array.isArray(value)) return ""; return stringValue(asRecord(value[0])?.text) ?? ""; @@ -142,3 +172,11 @@ function asRecord(value: unknown): Record | undefined { ? value as Record : undefined; } + +async function waitForRequest(connection: FakeCodexConnection, method: string): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (connection.requests.some((request) => request.method === method)) return; + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error(`Timed out waiting for ${method}.`); +} diff --git a/src/local-agent-codex/runtime.ts b/src/local-agent-codex/runtime.ts index 62af37b5..6f4beb68 100644 --- a/src/local-agent-codex/runtime.ts +++ b/src/local-agent-codex/runtime.ts @@ -30,12 +30,16 @@ interface TurnCompletion { export class CodexAppServerRuntime implements HarnessRuntime { private readonly pendingTurns = new Map(); private readonly unsubscribe: () => void; + private readonly unsubscribeClose: () => void; private closed = false; constructor(private readonly connection: CodexAppServerConnection) { this.unsubscribe = connection.onNotification((method, params) => { this.handleNotification(method, params); }); + this.unsubscribeClose = connection.onClose((error) => { + this.rejectPendingTurns(error); + }); } async run(input: LocalAgentRunInput): Promise { @@ -91,9 +95,9 @@ export class CodexAppServerRuntime implements HarnessRuntime { if (this.closed) return; this.closed = true; this.unsubscribe(); + this.unsubscribeClose(); const error = new Error("Codex app-server runtime closed."); - for (const pending of this.pendingTurns.values()) pending.reject(error); - this.pendingTurns.clear(); + this.rejectPendingTurns(error); await this.connection.close(); } @@ -137,6 +141,11 @@ export class CodexAppServerRuntime implements HarnessRuntime { this.pendingTurns.delete(completion.threadId); pending.resolve(completion.result); } + + private rejectPendingTurns(error: Error): void { + for (const pending of this.pendingTurns.values()) pending.reject(error); + this.pendingTurns.clear(); + } } export function createCodexHarnessDriver( From b07806169ac0858cdf5f1f02b3c9e83658d6d671 Mon Sep 17 00:00:00 2001 From: Waishnav Date: Wed, 12 Aug 2026 08:07:16 +0530 Subject: [PATCH 6/6] fix(codex): propagate terminal transport failures --- src/local-agent-codex/app-server-transport.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/local-agent-codex/app-server-transport.ts b/src/local-agent-codex/app-server-transport.ts index 66ae2427..feb599e7 100644 --- a/src/local-agent-codex/app-server-transport.ts +++ b/src/local-agent-codex/app-server-transport.ts @@ -154,8 +154,7 @@ class StdioCodexAppServerConnection implements CodexAppServerConnection { try { message = JSON.parse(line) as unknown; } catch { - this.usable = false; - this.failPending(new Error(`Codex app-server emitted invalid JSON: ${line.slice(0, 500)}`)); + this.failConnection(new Error(`Codex app-server emitted invalid JSON: ${line.slice(0, 500)}`)); return; } if (!isRecord(message)) return; @@ -192,10 +191,9 @@ class StdioCodexAppServerConnection implements CodexAppServerConnection { private write(payload: unknown, reject?: (error: Error) => void): void { this.child.stdin.write(`${JSON.stringify(payload)}\n`, (error) => { if (!error) return; - this.usable = false; const wrapped = new Error(`Failed to write to Codex app-server: ${error.message}`); reject?.(wrapped); - this.failPending(wrapped); + this.failConnection(wrapped); }); }