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
29 changes: 29 additions & 0 deletions src/core/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { query, type SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";

import { config } from "../config";
import type { BotContext, ExecutionResult, McpServerConfig } from "../types";
import { redactSecrets } from "../utils/sanitize";

function isResultMessage(msg: unknown): msg is SDKResultMessage {
return (
Expand Down Expand Up @@ -237,6 +238,34 @@ export async function executeAgent({
systemPrompt: { type: "preset", preset: "claude_code" },
env: buildProviderEnv(installationToken, artifactsDir),
abortController: controller,
// Without this, a non-zero CLI exit surfaces only as
// `Error("Claude Code process exited with code N")` with no detail. The
// SDK forwards CLI stderr here in stream chunks (not necessarily one
// line per call); log so the real failure reason (auth, rate-limit,
// model rejection, etc.) lands in pino. Pipe through redactSecrets
// first because CLI errors can echo bearer tokens / connection URLs
// that buildProviderEnv works hard to keep out of the subprocess —
// we don't want to undo that by leaking them into pod logs. trimEnd
// preserves leading indentation in multi-line stack traces; the
// 500-char cap matches the convention in src/daemon/updater.ts and
// scoped-rebase-executor.ts so an unexpectedly large chunk can't blow
// up log ingestion.
Comment thread
chrisleekr marked this conversation as resolved.
stderr: (chunk: string) => {
const redacted = redactSecrets(chunk);
const tail = redacted.body.trimEnd();
if (tail === "") return;
const truncated = tail.length > 500;
log.warn(
{
stderr: tail.slice(0, 500),
...(truncated ? { truncated: true } : {}),
...(redacted.matchCount > 0
? { redactedSecretCount: redacted.matchCount, redactedSecretKinds: redacted.kinds }
: {}),
},
"Claude CLI stderr",
);
},
Comment thread
chrisleekr marked this conversation as resolved.
};
const resolvedMaxTurns = maxTurns ?? config.agentMaxTurns;
if (resolvedMaxTurns !== undefined) {
Expand Down
106 changes: 101 additions & 5 deletions test/core/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ import type { McpServerConfig } from "../../src/types";
import { makeBotContext } from "../factories";

interface QueryCall {
options: { abortController?: AbortController };
options: {
abortController?: AbortController;
stderr?: (chunk: string) => void;
};
}

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

void mock.module("@anthropic-ai/claude-agent-sdk", () => ({
query: mock((opts: { prompt: string; options: { abortController?: AbortController } }) => {
lastQueryCall = { options: opts.options };
return nextIterator();
}),
query: mock(
(opts: {
prompt: string;
options: { abortController?: AbortController; stderr?: (chunk: string) => void };
}) => {
lastQueryCall = { options: opts.options };
return nextIterator();
},
),
}));

const { executeAgent } = await import("../../src/core/executor");
Expand Down Expand Up @@ -196,3 +204,91 @@ describe("executeAgent — cancellation", () => {
expect(result.errorMessage).toBe("daemon cancel");
});
});

describe("executeAgent — stderr callback", () => {
beforeEach(() => {
lastQueryCall = undefined;
nextIterator = emptyIterator;
});

it("forwards a stderr callback into the SDK query options", async () => {
await executeAgent(baseParams());

expect(lastQueryCall?.options.stderr).toBeTypeOf("function");
});

it("logs non-empty stderr chunks at warn level on the request logger", async () => {
const params = baseParams();
await executeAgent(params);

lastQueryCall?.options.stderr?.("oauth token expired\n");

const logWarn = params.ctx.log.warn as ReturnType<typeof mock>;
expect(logWarn).toHaveBeenCalledTimes(1);
expect(logWarn.mock.calls[0]).toEqual([{ stderr: "oauth token expired" }, "Claude CLI stderr"]);
});

it("preserves leading indentation so multi-line stack traces stay readable", async () => {
const params = baseParams();
await executeAgent(params);

lastQueryCall?.options.stderr?.("Error: boom\n at foo (file.ts:1:1)\n");

const logWarn = params.ctx.log.warn as ReturnType<typeof mock>;
expect(logWarn.mock.calls[0]?.[0]).toEqual({
stderr: "Error: boom\n at foo (file.ts:1:1)",
});
});

it("skips whitespace-only chunks to avoid log spam", async () => {
const params = baseParams();
await executeAgent(params);

lastQueryCall?.options.stderr?.("\n");
lastQueryCall?.options.stderr?.(" \t\n");

const logWarn = params.ctx.log.warn as ReturnType<typeof mock>;
expect(logWarn).not.toHaveBeenCalled();
});

it("caps stderr at 500 chars and flags truncation", async () => {
const params = baseParams();
await executeAgent(params);

const oversized = "x".repeat(600);
lastQueryCall?.options.stderr?.(oversized);

const logWarn = params.ctx.log.warn as ReturnType<typeof mock>;
expect(logWarn).toHaveBeenCalledTimes(1);
const [fields] = logWarn.mock.calls[0] ?? [];
expect(fields).toEqual({ stderr: "x".repeat(500), truncated: true });
});

it("redacts secrets from stderr before logging and surfaces the kind", async () => {
const params = baseParams();
await executeAgent(params);

const oauth = `sk-ant-oat01-${"A".repeat(80)}`;
lastQueryCall?.options.stderr?.(`auth failed: token=${oauth} expired`);

const logWarn = params.ctx.log.warn as ReturnType<typeof mock>;
expect(logWarn).toHaveBeenCalledTimes(1);
const [fields] = logWarn.mock.calls[0] ?? [];
expect(fields).toEqual({
stderr: "auth failed: token= expired",
redactedSecretCount: 1,
redactedSecretKinds: ["ANTHROPIC_OAUTH"],
});
});

it("skips chunks that become empty after secret redaction", async () => {
const params = baseParams();
await executeAgent(params);

const oauthOnly = `sk-ant-oat01-${"A".repeat(80)}\n`;
lastQueryCall?.options.stderr?.(oauthOnly);

const logWarn = params.ctx.log.warn as ReturnType<typeof mock>;
expect(logWarn).not.toHaveBeenCalled();
});
});
Loading