Skip to content

Commit 3482443

Browse files
authored
fix(executor): capture Claude CLI stderr via SDK callback (#105)
1 parent d3d66f6 commit 3482443

2 files changed

Lines changed: 130 additions & 5 deletions

File tree

src/core/executor.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { query, type SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";
22

33
import { config } from "../config";
44
import type { BotContext, ExecutionResult, McpServerConfig } from "../types";
5+
import { redactSecrets } from "../utils/sanitize";
56

67
function isResultMessage(msg: unknown): msg is SDKResultMessage {
78
return (
@@ -237,6 +238,34 @@ export async function executeAgent({
237238
systemPrompt: { type: "preset", preset: "claude_code" },
238239
env: buildProviderEnv(installationToken, artifactsDir),
239240
abortController: controller,
241+
// Without this, a non-zero CLI exit surfaces only as
242+
// `Error("Claude Code process exited with code N")` with no detail. The
243+
// SDK forwards CLI stderr here in stream chunks (not necessarily one
244+
// line per call); log so the real failure reason (auth, rate-limit,
245+
// model rejection, etc.) lands in pino. Pipe through redactSecrets
246+
// first because CLI errors can echo bearer tokens / connection URLs
247+
// that buildProviderEnv works hard to keep out of the subprocess —
248+
// we don't want to undo that by leaking them into pod logs. trimEnd
249+
// preserves leading indentation in multi-line stack traces; the
250+
// 500-char cap matches the convention in src/daemon/updater.ts and
251+
// scoped-rebase-executor.ts so an unexpectedly large chunk can't blow
252+
// up log ingestion.
253+
stderr: (chunk: string) => {
254+
const redacted = redactSecrets(chunk);
255+
const tail = redacted.body.trimEnd();
256+
if (tail === "") return;
257+
const truncated = tail.length > 500;
258+
log.warn(
259+
{
260+
stderr: tail.slice(0, 500),
261+
...(truncated ? { truncated: true } : {}),
262+
...(redacted.matchCount > 0
263+
? { redactedSecretCount: redacted.matchCount, redactedSecretKinds: redacted.kinds }
264+
: {}),
265+
},
266+
"Claude CLI stderr",
267+
);
268+
},
240269
};
241270
const resolvedMaxTurns = maxTurns ?? config.agentMaxTurns;
242271
if (resolvedMaxTurns !== undefined) {

test/core/executor.test.ts

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ import type { McpServerConfig } from "../../src/types";
1818
import { makeBotContext } from "../factories";
1919

2020
interface QueryCall {
21-
options: { abortController?: AbortController };
21+
options: {
22+
abortController?: AbortController;
23+
stderr?: (chunk: string) => void;
24+
};
2225
}
2326

2427
let lastQueryCall: QueryCall | undefined;
@@ -42,10 +45,15 @@ function emptyIterator(): AsyncIterableIterator<unknown> {
4245
let nextIterator: IteratorFactory = emptyIterator;
4346

4447
void mock.module("@anthropic-ai/claude-agent-sdk", () => ({
45-
query: mock((opts: { prompt: string; options: { abortController?: AbortController } }) => {
46-
lastQueryCall = { options: opts.options };
47-
return nextIterator();
48-
}),
48+
query: mock(
49+
(opts: {
50+
prompt: string;
51+
options: { abortController?: AbortController; stderr?: (chunk: string) => void };
52+
}) => {
53+
lastQueryCall = { options: opts.options };
54+
return nextIterator();
55+
},
56+
),
4957
}));
5058

5159
const { executeAgent } = await import("../../src/core/executor");
@@ -196,3 +204,91 @@ describe("executeAgent — cancellation", () => {
196204
expect(result.errorMessage).toBe("daemon cancel");
197205
});
198206
});
207+
208+
describe("executeAgent — stderr callback", () => {
209+
beforeEach(() => {
210+
lastQueryCall = undefined;
211+
nextIterator = emptyIterator;
212+
});
213+
214+
it("forwards a stderr callback into the SDK query options", async () => {
215+
await executeAgent(baseParams());
216+
217+
expect(lastQueryCall?.options.stderr).toBeTypeOf("function");
218+
});
219+
220+
it("logs non-empty stderr chunks at warn level on the request logger", async () => {
221+
const params = baseParams();
222+
await executeAgent(params);
223+
224+
lastQueryCall?.options.stderr?.("oauth token expired\n");
225+
226+
const logWarn = params.ctx.log.warn as ReturnType<typeof mock>;
227+
expect(logWarn).toHaveBeenCalledTimes(1);
228+
expect(logWarn.mock.calls[0]).toEqual([{ stderr: "oauth token expired" }, "Claude CLI stderr"]);
229+
});
230+
231+
it("preserves leading indentation so multi-line stack traces stay readable", async () => {
232+
const params = baseParams();
233+
await executeAgent(params);
234+
235+
lastQueryCall?.options.stderr?.("Error: boom\n at foo (file.ts:1:1)\n");
236+
237+
const logWarn = params.ctx.log.warn as ReturnType<typeof mock>;
238+
expect(logWarn.mock.calls[0]?.[0]).toEqual({
239+
stderr: "Error: boom\n at foo (file.ts:1:1)",
240+
});
241+
});
242+
243+
it("skips whitespace-only chunks to avoid log spam", async () => {
244+
const params = baseParams();
245+
await executeAgent(params);
246+
247+
lastQueryCall?.options.stderr?.("\n");
248+
lastQueryCall?.options.stderr?.(" \t\n");
249+
250+
const logWarn = params.ctx.log.warn as ReturnType<typeof mock>;
251+
expect(logWarn).not.toHaveBeenCalled();
252+
});
253+
254+
it("caps stderr at 500 chars and flags truncation", async () => {
255+
const params = baseParams();
256+
await executeAgent(params);
257+
258+
const oversized = "x".repeat(600);
259+
lastQueryCall?.options.stderr?.(oversized);
260+
261+
const logWarn = params.ctx.log.warn as ReturnType<typeof mock>;
262+
expect(logWarn).toHaveBeenCalledTimes(1);
263+
const [fields] = logWarn.mock.calls[0] ?? [];
264+
expect(fields).toEqual({ stderr: "x".repeat(500), truncated: true });
265+
});
266+
267+
it("redacts secrets from stderr before logging and surfaces the kind", async () => {
268+
const params = baseParams();
269+
await executeAgent(params);
270+
271+
const oauth = `sk-ant-oat01-${"A".repeat(80)}`;
272+
lastQueryCall?.options.stderr?.(`auth failed: token=${oauth} expired`);
273+
274+
const logWarn = params.ctx.log.warn as ReturnType<typeof mock>;
275+
expect(logWarn).toHaveBeenCalledTimes(1);
276+
const [fields] = logWarn.mock.calls[0] ?? [];
277+
expect(fields).toEqual({
278+
stderr: "auth failed: token= expired",
279+
redactedSecretCount: 1,
280+
redactedSecretKinds: ["ANTHROPIC_OAUTH"],
281+
});
282+
});
283+
284+
it("skips chunks that become empty after secret redaction", async () => {
285+
const params = baseParams();
286+
await executeAgent(params);
287+
288+
const oauthOnly = `sk-ant-oat01-${"A".repeat(80)}\n`;
289+
lastQueryCall?.options.stderr?.(oauthOnly);
290+
291+
const logWarn = params.ctx.log.warn as ReturnType<typeof mock>;
292+
expect(logWarn).not.toHaveBeenCalled();
293+
});
294+
});

0 commit comments

Comments
 (0)