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
7 changes: 6 additions & 1 deletion src/selfhost/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,14 @@ export function createClaudeCodeAi(parentEnv: Record<string, string | undefined>
["--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 };
Expand Down
7 changes: 7 additions & 0 deletions test/unit/selfhost-ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string | undefined> = {};
const stub: StubSpawn = async (_c, _a, o) => {
Expand Down
Loading