@@ -18,7 +18,10 @@ import type { McpServerConfig } from "../../src/types";
1818import { makeBotContext } from "../factories" ;
1919
2020interface QueryCall {
21- options : { abortController ?: AbortController } ;
21+ options : {
22+ abortController ?: AbortController ;
23+ stderr ?: ( chunk : string ) => void ;
24+ } ;
2225}
2326
2427let lastQueryCall : QueryCall | undefined ;
@@ -42,10 +45,15 @@ function emptyIterator(): AsyncIterableIterator<unknown> {
4245let nextIterator : IteratorFactory = emptyIterator ;
4346
4447void 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
5159const { 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