From 00afd687c5ee0ee2ee68a3cd171f3e5e7eac226c Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 27 Jun 2026 11:15:47 -0700 Subject: [PATCH] fix(selfhost): surface the CLI's structured stdout error before the exit code (#1612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude --output-format json reports API/auth/model errors in its stdout envelope ({is_error,api_error_status}) even on a non-zero exit, with empty stderr. createClaudeCodeAi threw on the exit code BEFORE checking that envelope, so the failure surfaced as an opaque "claude_code_exit_1: " (blank) and the precise status was discarded — exactly why #1610's 404 was undiagnosable from logs/Sentry. Check claudeErrorStatus(stdout) before the exit-code throw so a structured error surfaces as claude_code_error_ (e.g. _404). A genuine crash with no structured stdout envelope still falls through to the exit code + redacted stderr. Regression test pins the non-zero-exit + stdout-envelope case. --- src/selfhost/ai.ts | 7 ++++++- test/unit/selfhost-ai.test.ts | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/selfhost/ai.ts b/src/selfhost/ai.ts index 284e7a2d5a..b57a7262be 100644 --- a/src/selfhost/ai.ts +++ b/src/selfhost/ai.ts @@ -295,9 +295,14 @@ export function createClaudeCodeAi(parentEnv: Record ["--print", "--output-format", "json", "--model", claudeModel, "--permission-mode", "plan", "--effort", effort, "--disallowedTools", "Bash,Edit,Write,WebFetch,WebSearch"], { env, input: prompt, timeoutMs: resolveCliTimeoutMs(parentEnv), cwd: await isolatedCliCwd() }, ); - if (code !== 0) throw new Error(`claude_code_exit_${code ?? "null"}: ${redactSecrets(stderr ?? "", [token]).slice(0, 500)}`); + // Surface the STRUCTURED error envelope FIRST. `claude --output-format json` reports API/auth/model errors in its + // stdout JSON ({is_error,api_error_status}) on a NON-ZERO exit too — e.g. an unknown model exits 1 with the 404 + // envelope in stdout and EMPTY stderr. Checking it before the exit code turns an opaque `claude_code_exit_1: ` + // (the #1610 symptom) into a precise `claude_code_error_404` — the signal that makes a reviewer outage + // diagnosable in logs + Sentry instead of a dead end. const errStatus = claudeErrorStatus(stdout); if (errStatus) throw new Error(`claude_code_error_${errStatus}`); + if (code !== 0) throw new Error(`claude_code_exit_${code ?? "null"}: ${redactSecrets(stderr ?? "", [token]).slice(0, 500)}`); const text = extractCliText(stdout); if (!text) throw new Error("claude_code_empty_output"); return { response: text }; diff --git a/test/unit/selfhost-ai.test.ts b/test/unit/selfhost-ai.test.ts index 52e1e2b391..2f10329e82 100644 --- a/test/unit/selfhost-ai.test.ts +++ b/test/unit/selfhost-ai.test.ts @@ -359,6 +359,13 @@ describe("subscription CLI helpers + fail-safe", () => { const stub: StubSpawn = async () => ({ stdout: JSON.stringify({ is_error: true, api_error_status: 401, result: "Failed to authenticate" }), code: 0 }); await expect(createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t" }, stub).run("m", { prompt: "x" })).rejects.toThrow(/claude_code_error_401/); }); + it("surfaces the structured stdout error on a NON-ZERO exit (precise status, not opaque exit code) (#1610)", async () => { + // Regression: an unknown model exits 1 with the error envelope in STDOUT ({is_error,api_error_status:404}) and + // EMPTY stderr. The exit-code throw used to win → `claude_code_exit_1: ` (blank, undiagnosable). Now the + // structured status is checked first → `claude_code_error_404`, the signal that surfaces in logs + Sentry. + const stub: StubSpawn = async () => ({ stdout: JSON.stringify({ is_error: true, api_error_status: 404, result: "There's an issue with the selected model (claude-code)." }), code: 1, stderr: "" }); + await expect(createClaudeCodeAi({ CLAUDE_CODE_OAUTH_TOKEN: "t" }, stub).run("m", { prompt: "x" })).rejects.toThrow(/claude_code_error_404/); + }); it("Claude Code returns the model text on success and scrubs billable keys", async () => { let capturedEnv: Record = {}; const stub: StubSpawn = async (_c, _a, o) => {