From 01fb97e8da0533be1aa8ade627a0c51e95f89b03 Mon Sep 17 00:00:00 2001 From: Mikhail Batukhtin <6481198+remdev@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:19:26 +0300 Subject: [PATCH 1/3] fix(codex-local): handle spawn error event in CodexRpcClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the `codex` binary is absent from PATH, Node.js emits an `error` event on the ChildProcess. Because `CodexRpcClient` only subscribed to `exit` and `data` events, the `error` event was unhandled — causing Node to throw it as an uncaught exception and crash the server. Add an `error` handler in the constructor that rejects all pending RPC requests and clears the queue. This makes a missing `codex` binary a recoverable condition: `fetchCodexRpcQuota()` rejects, `getQuotaWindows()` catches the error and returns `{ ok: false }`, and the server stays up. The fix mirrors the existing pattern in `runChildProcess` (packages/adapter-utils/src/server-utils.ts) which already handles `ENOENT` the same way for the main task execution path. --- packages/adapters/codex-local/src/server/quota.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/adapters/codex-local/src/server/quota.ts b/packages/adapters/codex-local/src/server/quota.ts index 7bc771e4..3c0ac3bf 100644 --- a/packages/adapters/codex-local/src/server/quota.ts +++ b/packages/adapters/codex-local/src/server/quota.ts @@ -432,6 +432,13 @@ class CodexRpcClient { } this.pending.clear(); }); + this.proc.on("error", (err: Error) => { + for (const request of this.pending.values()) { + clearTimeout(request.timer); + request.reject(err); + } + this.pending.clear(); + }); } private onStdout(chunk: string) { From c98af52590565835d8b5eb8dafd3ea1f907851b1 Mon Sep 17 00:00:00 2001 From: Mikhail Batukhtin <6481198+remdev@users.noreply.github.com> Date: Sun, 29 Mar 2026 14:41:25 +0300 Subject: [PATCH 2/3] test(codex-local): regression for CodexRpcClient spawn ENOENT Add a Vitest case that mocks `node:child_process.spawn` so the child emits `error` (ENOENT) after the constructor attaches listeners. `getQuotaWindows()` must resolve with `ok: false` instead of leaving an unhandled `error` event on the process. Register `packages/adapters/codex-local` in the root Vitest workspace. Document in DEVELOPING.md that a missing `codex` binary should not take down the API server during quota polling. --- doc/DEVELOPING.md | 2 + .../src/server/quota-spawn-error.test.ts | 57 +++++++++++++++++++ .../adapters/codex-local/vitest.config.ts | 7 +++ vitest.config.ts | 9 ++- 4 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 packages/adapters/codex-local/src/server/quota-spawn-error.test.ts create mode 100644 packages/adapters/codex-local/vitest.config.ts diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 7864b90e..639c967e 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -134,6 +134,8 @@ For `codex_local`, Paperclip also manages a per-company Codex home under the ins - `~/.paperclip/instances/default/companies//codex-home` +If the `codex` CLI is not installed or not on `PATH`, `codex_local` agent runs fail at execution time with a clear adapter error. Quota polling uses a short-lived `codex app-server` subprocess: when `codex` cannot be spawned, that provider reports `ok: false` in aggregated quota results and the API server keeps running (it must not exit on a missing binary). + ## Worktree-local Instances When developing from multiple git worktrees, do not point two Paperclip servers at the same embedded PostgreSQL data directory. diff --git a/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts new file mode 100644 index 00000000..a2349d84 --- /dev/null +++ b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts @@ -0,0 +1,57 @@ +import { EventEmitter } from "node:events"; +import type { ChildProcess } from "node:child_process"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const { mockSpawn } = vi.hoisted(() => ({ + mockSpawn: vi.fn(), +})); + +vi.mock("node:child_process", async (importOriginal) => { + const cp = await importOriginal(); + return { + ...cp, + spawn: (...args: Parameters) => mockSpawn(...args) as ReturnType, + }; +}); + +import { getQuotaWindows } from "./quota.js"; + +function createChildThatErrorsOnMicrotask(err: Error): ChildProcess { + const child = new EventEmitter() as ChildProcess; + const stream = Object.assign(new EventEmitter(), { + setEncoding: () => {}, + }); + Object.assign(child, { + stdout: stream, + stderr: Object.assign(new EventEmitter(), { setEncoding: () => {} }), + stdin: { write: vi.fn(), end: vi.fn() }, + kill: vi.fn(), + }); + queueMicrotask(() => { + child.emit("error", err); + }); + return child; +} + +describe("CodexRpcClient spawn failures", () => { + beforeEach(() => { + mockSpawn.mockReset(); + }); + + it("does not crash the process when codex is missing; getQuotaWindows returns ok: false", async () => { + const enoent = Object.assign(new Error("spawn codex ENOENT"), { + code: "ENOENT", + errno: -2, + syscall: "spawn codex", + path: "codex", + }); + mockSpawn.mockImplementation(() => createChildThatErrorsOnMicrotask(enoent)); + + const result = await getQuotaWindows(); + + expect(result.ok).toBe(false); + expect(result.windows).toEqual([]); + expect(result.error).toContain("Codex app-server"); + expect(result.error).toContain("spawn codex ENOENT"); + }); +}); diff --git a/packages/adapters/codex-local/vitest.config.ts b/packages/adapters/codex-local/vitest.config.ts new file mode 100644 index 00000000..f624398e --- /dev/null +++ b/packages/adapters/codex-local/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + }, +}); diff --git a/vitest.config.ts b/vitest.config.ts index 9bf83928..3ec05081 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,6 +2,13 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - projects: ["packages/db", "packages/adapters/opencode-local", "server", "ui", "cli"], + projects: [ + "packages/db", + "packages/adapters/codex-local", + "packages/adapters/opencode-local", + "server", + "ui", + "cli", + ], }, }); From dc3aa8f31f7512ff9dc1911cb841a2d3ce223e6c Mon Sep 17 00:00:00 2001 From: Mikhail Batukhtin <6481198+remdev@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:13:39 +0300 Subject: [PATCH 3/3] test(codex-local): isolate quota spawn test from host CODEX_HOME After the mocked RPC spawn fails, getQuotaWindows() still calls readCodexToken(). Use an empty mkdtemp directory for CODEX_HOME for the duration of the test so we never read ~/.codex/auth.json or call WHAM. --- .../src/server/quota-spawn-error.test.ts | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts index a2349d84..85d1e44c 100644 --- a/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts +++ b/packages/adapters/codex-local/src/server/quota-spawn-error.test.ts @@ -1,6 +1,9 @@ import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import type { ChildProcess } from "node:child_process"; -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn(), @@ -34,8 +37,33 @@ function createChildThatErrorsOnMicrotask(err: Error): ChildProcess { } describe("CodexRpcClient spawn failures", () => { + let previousCodexHome: string | undefined; + let isolatedCodexHome: string | undefined; + beforeEach(() => { mockSpawn.mockReset(); + // After the RPC path fails, getQuotaWindows() calls readCodexToken() which + // reads $CODEX_HOME/auth.json (default ~/.codex). Point CODEX_HOME at an + // empty temp directory so we never hit real host auth or the WHAM network. + previousCodexHome = process.env.CODEX_HOME; + isolatedCodexHome = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-codex-spawn-test-")); + process.env.CODEX_HOME = isolatedCodexHome; + }); + + afterEach(() => { + if (isolatedCodexHome) { + try { + fs.rmSync(isolatedCodexHome, { recursive: true, force: true }); + } catch { + /* ignore */ + } + isolatedCodexHome = undefined; + } + if (previousCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = previousCodexHome; + } }); it("does not crash the process when codex is missing; getQuotaWindows returns ok: false", async () => {