Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions packages/gittensory-engine/src/miner/cli-subprocess-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
const errorObj = parsed.error as Record<string, unknown> | 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[] {
Expand Down Expand Up @@ -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 {
Expand Down
92 changes: 92 additions & 0 deletions test/unit/cli-subprocess-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]");
});
});
});