From ab083c33f3dba49c34f9637790b197f52f67fba8 Mon Sep 17 00:00:00 2001 From: joaovictor91123 Date: Sun, 12 Jul 2026 16:19:59 +0400 Subject: [PATCH] feat(miner-hands): parse Codex's JSONL stdout for its real error object in the CLI-subprocess driver (#5169) Ports src/selfhost/ai.ts's codexErrorFromStdout scanner (and its adjacent auth-failure special case) into cli-subprocess-driver.ts (redeclared, not imported, per this file's no-src-import convention). Codex's stderr is typically just an uninformative "Reading prompt from stdin..." startup banner; the real error lives in its JSONL stdout. On a non-zero exit from codex, the driver now scans stdout in reverse for a structured error object and prefers it over the generic exit-code/stderr error when found. When no structured detail is found AND stderr is exactly the stdin-reading banner, that specific combination means auth.json was present at boot but is now missing or expired -- the driver resolves this to a distinct, actionable "run `codex auth`" remediation instead of a useless exit-code string. Any other case (no detail, different stderr) falls back to today's raw stderr-based shape unchanged. The folded error passes through the same knownSecrets-aware redactSecrets call every other error path here uses. --- .../src/miner/cli-subprocess-driver.ts | 59 ++++++++++++ test/unit/cli-subprocess-driver.test.ts | 92 +++++++++++++++++++ 2 files changed, 151 insertions(+) diff --git a/packages/gittensory-engine/src/miner/cli-subprocess-driver.ts b/packages/gittensory-engine/src/miner/cli-subprocess-driver.ts index 832fe9f178..171a9e256d 100644 --- a/packages/gittensory-engine/src/miner/cli-subprocess-driver.ts +++ b/packages/gittensory-engine/src/miner/cli-subprocess-driver.ts @@ -103,6 +103,34 @@ function claudeErrorStatus(stdout: string): string | null { return null; } +/** Codex's stderr is typically just an uninformative "Reading prompt from stdin..." startup banner; the real + * error (auth failure, unknown model, API error) lands in its JSONL stdout instead. Scans lines in reverse + * (the error object is usually last) and returns the first human-readable detail found, or null. Ported from + * src/selfhost/ai.ts's `codexErrorFromStdout` -- redeclared here (not imported) per this file's own + * no-src-import convention, and returns the RAW detail unredacted; the caller applies this driver's own + * knownSecrets-aware `redactSecrets` at the call site (#5169). */ +function codexErrorFromStdout(stdout: string): string | null { + const lines = stdout.trim().split(/\r?\n/); + for (let i = lines.length - 1; i >= 0; i -= 1) { + const line = lines[i]; + if (!line?.trim()) continue; + try { + const parsed = JSON.parse(line) as Record; + const errorObj = parsed.error as Record | undefined; + const detail = + (typeof parsed.error === "string" && parsed.error) || + (typeof parsed.message === "string" && parsed.message) || + (typeof parsed.msg === "string" && parsed.msg) || + (errorObj && typeof errorObj.message === "string" ? errorObj.message : null) || + null; + if (detail) return detail; + } catch { + /* not JSON -- skip */ + } + } + return null; +} + /** The default argv contract, exported so the factory (#4289) can PREFIX provider config (e.g. a configured * model flag) without re-inventing — and silently drifting from — this baseline argv shape. */ export function defaultCliSubprocessArgs(task: CodingAgentDriverTask): string[] { @@ -172,6 +200,37 @@ export function createCliSubprocessCodingAgentDriver(options: CliSubprocessDrive }; } } + if (options.command === "codex") { + const stderrTrimmed = (spawned.stderr ?? "").trim(); + const jsonlDetail = codexErrorFromStdout(spawned.stdout); + if (!jsonlDetail && stderrTrimmed === "Reading prompt from stdin...") { + // codex's JSONL stream carried no structured detail and stderr is ONLY the stdin-reading banner (no + // API/auth error appended) -- auth.json was present at boot-time but is now expired or was deleted. + // A distinct, actionable remediation instead of the generic exit-code string (#5169). + return { + ok: false, + changedFiles: [], + summary: `${options.command} exited non-zero`, + transcript, + error: redactSecrets( + "codex_no_auth: auth.json missing or expired -- run `codex auth` to authenticate", + knownSecrets, + ), + }; + } + if (jsonlDetail) { + // Prefer the structured error from codex's JSONL stdout over the uninformative stderr startup + // message -- codex reports auth/model/API failures in its JSON stream, not stderr. + const detail = redactSecrets(jsonlDetail, knownSecrets).slice(0, MAX_ERROR_DETAIL_CHARS); + return { + ok: false, + changedFiles: [], + summary: `${options.command} exited non-zero`, + transcript, + error: `${options.command}_exit_${spawned.code}: ${detail}`, + }; + } + } const stderr = (spawned.stderr ?? "").trim(); const detail = redactSecrets(stderr || `exit ${spawned.code}`, knownSecrets).slice(0, MAX_ERROR_DETAIL_CHARS); return { diff --git a/test/unit/cli-subprocess-driver.test.ts b/test/unit/cli-subprocess-driver.test.ts index 6b5afa822e..2d46e9f861 100644 --- a/test/unit/cli-subprocess-driver.test.ts +++ b/test/unit/cli-subprocess-driver.test.ts @@ -240,4 +240,96 @@ describe("createCliSubprocessCodingAgentDriver (#4266)", () => { expect(result.error).toContain("[redacted]"); }); }); + + describe("Codex JSONL stdout error diagnostics (#5169)", () => { + it("prefers a real error object found in JSONL stdout over the generic exit-code error", async () => { + const { spawn } = fakeSpawn({ + stdout: '{"type":"start"}\n{"error":"unknown model: gpt-9"}', + code: 1, + stderr: "Reading prompt from stdin...", + }); + const driver = createCliSubprocessCodingAgentDriver({ command: "codex", spawn }); + const result = await driver.run(TASK); + expect(result.ok).toBe(false); + expect(result.error).toBe("codex_exit_1: unknown model: gpt-9"); + }); + + it("scans lines in reverse and returns the LAST detail-bearing line, skipping malformed/non-JSON lines in between", async () => { + const { spawn } = fakeSpawn({ + stdout: '{"message":"stale first error"}\nnot json at all\n\n{"msg":"the real final error"}', + code: 1, + }); + const driver = createCliSubprocessCodingAgentDriver({ command: "codex", spawn }); + const result = await driver.run(TASK); + expect(result.error).toBe("codex_exit_1: the real final error"); + }); + + it("falls through past a falsy (empty-string) error field to the next detail field", async () => { + const { spawn } = fakeSpawn({ + stdout: JSON.stringify({ error: "", message: "the real message" }), + code: 1, + }); + const driver = createCliSubprocessCodingAgentDriver({ command: "codex", spawn }); + const result = await driver.run(TASK); + expect(result.error).toBe("codex_exit_1: the real message"); + }); + + it("extracts the detail from a nested error.message object shape", async () => { + const { spawn } = fakeSpawn({ + stdout: JSON.stringify({ error: { message: "rate limited" } }), + code: 1, + }); + const driver = createCliSubprocessCodingAgentDriver({ command: "codex", spawn }); + const result = await driver.run(TASK); + expect(result.error).toBe("codex_exit_1: rate limited"); + }); + + it("resolves to a 'run codex auth' remediation when stdout has no detail and stderr is only the stdin-reading banner", async () => { + const { spawn } = fakeSpawn({ + stdout: "", + code: 1, + stderr: "Reading prompt from stdin...", + }); + const driver = createCliSubprocessCodingAgentDriver({ command: "codex", spawn }); + const result = await driver.run(TASK); + expect(result.error).toBe("codex_no_auth: auth.json missing or expired -- run `codex auth` to authenticate"); + }); + + it("regression: falls back to the generic stderr-based error unchanged when stdout has nothing parseable and stderr is NOT the exact auth banner", async () => { + const { spawn } = fakeSpawn({ + stdout: "not json at all", + code: 1, + stderr: "some other stderr detail", + }); + const driver = createCliSubprocessCodingAgentDriver({ command: "codex", spawn }); + const result = await driver.run(TASK); + expect(result.error).toBe("codex_exit_1: some other stderr detail"); + }); + + it("never scans stdout for JSONL errors on a non-codex command (falls through untouched even with codex-shaped stdout)", async () => { + const { spawn } = fakeSpawn({ + stdout: '{"error":"unknown model: gpt-9"}', + code: 1, + stderr: "claude own stderr", + }); + const driver = createCliSubprocessCodingAgentDriver({ command: "claude", spawn }); + const result = await driver.run(TASK); + expect(result.error).toBe("claude_exit_1: claude own stderr"); + }); + + it("invariant: a folded JSONL-detail error is never left unredacted when it contains a known secret value", async () => { + const { spawn } = fakeSpawn({ + stdout: JSON.stringify({ error: "auth failed for token my-injected-longkey-leaked" }), + code: 1, + }); + const driver = createCliSubprocessCodingAgentDriver({ + command: "codex", + spawn, + knownSecrets: ["my-injected-longkey-leaked"], + }); + const result = await driver.run(TASK); + expect(result.error).not.toContain("my-injected-longkey-leaked"); + expect(result.error).toContain("[redacted]"); + }); + }); });