@@ -430,51 +430,120 @@ impl ToolExecutor {
430430 . stderr ( Stdio :: piped ( ) )
431431 . kill_on_drop ( true ) ;
432432
433- let child = command. spawn ( ) . map_err ( AppError :: from) ?;
433+ // On Unix, place the child in its own process group so we can kill any
434+ // descendants the shell backgrounds. Without this, a command like
435+ // `sh -c "sleep 60 &"` orphans the sleep when sh exits or is killed —
436+ // it survives the timeout and continues to run with the agent's privileges.
437+ #[ cfg( unix) ]
438+ command. process_group ( 0 ) ;
434439
435440 const MAX_OUTPUT_BYTES : usize = 256 * 1024 ; // 256KB limit
436441
437- match tokio:: time:: timeout ( self . command_timeout , child. wait_with_output ( ) ) . await {
438- Ok ( Ok ( output) ) => {
439- let stdout_raw = & output. stdout ;
440- let stderr_raw = & output. stderr ;
441- let exit_code = output. status . code ( ) . unwrap_or ( -1 ) ;
442-
443- let stdout = if stdout_raw. len ( ) > MAX_OUTPUT_BYTES {
444- let truncated = String :: from_utf8_lossy ( & stdout_raw[ ..MAX_OUTPUT_BYTES ] ) ;
445- format ! (
446- "{}\n \n ... [truncated: {} total bytes, showing first {}]" ,
447- truncated,
448- stdout_raw. len( ) ,
449- MAX_OUTPUT_BYTES
450- )
451- } else {
452- String :: from_utf8_lossy ( stdout_raw) . to_string ( )
453- } ;
454-
455- let stderr = if stderr_raw. len ( ) > MAX_OUTPUT_BYTES {
456- let truncated = String :: from_utf8_lossy ( & stderr_raw[ ..MAX_OUTPUT_BYTES ] ) ;
457- format ! (
458- "{}\n \n ... [truncated: {} total bytes, showing first {}]" ,
459- truncated,
460- stderr_raw. len( ) ,
461- MAX_OUTPUT_BYTES
462- )
463- } else {
464- String :: from_utf8_lossy ( stderr_raw) . to_string ( )
465- } ;
466-
467- Ok ( format ! (
468- "exit_code: {}\n stdout:\n {}\n stderr:\n {}" ,
469- exit_code, stdout, stderr
470- ) )
442+ let mut child = command. spawn ( ) . map_err ( AppError :: from) ?;
443+
444+ // Capture the leader pid before waiting. This is the process group ID
445+ // since we requested process_group(0).
446+ #[ cfg( unix) ]
447+ let pgid = child. id ( ) . map ( |id| id as i32 ) ;
448+
449+ let stdout_pipe = child. stdout . take ( ) ;
450+ let stderr_pipe = child. stderr . take ( ) ;
451+
452+ let stdout_task = tokio:: spawn ( async move {
453+ let mut buf = Vec :: new ( ) ;
454+ let mut total = 0usize ;
455+ if let Some ( mut out) = stdout_pipe {
456+ let mut chunk = [ 0u8 ; 8192 ] ;
457+ loop {
458+ let n = tokio:: io:: AsyncReadExt :: read ( & mut out, & mut chunk) . await ?;
459+ if n == 0 {
460+ break ;
461+ }
462+ total += n;
463+ if buf. len ( ) < MAX_OUTPUT_BYTES {
464+ let remaining = MAX_OUTPUT_BYTES - buf. len ( ) ;
465+ buf. extend_from_slice ( & chunk[ ..n. min ( remaining) ] ) ;
466+ }
467+ }
471468 }
472- Ok ( Err ( error) ) => Err ( AppError :: from ( error) ) ,
473- Err ( _) => Err ( AppError :: Internal ( format ! (
474- "Command timed out after {}s" ,
475- self . command_timeout. as_secs( )
476- ) ) ) ,
477- }
469+ Ok :: < _ , std:: io:: Error > ( ( buf, total) )
470+ } ) ;
471+
472+ let stderr_task = tokio:: spawn ( async move {
473+ let mut buf = Vec :: new ( ) ;
474+ let mut total = 0usize ;
475+ if let Some ( mut err) = stderr_pipe {
476+ let mut chunk = [ 0u8 ; 8192 ] ;
477+ loop {
478+ let n = tokio:: io:: AsyncReadExt :: read ( & mut err, & mut chunk) . await ?;
479+ if n == 0 {
480+ break ;
481+ }
482+ total += n;
483+ if buf. len ( ) < MAX_OUTPUT_BYTES {
484+ let remaining = MAX_OUTPUT_BYTES - buf. len ( ) ;
485+ buf. extend_from_slice ( & chunk[ ..n. min ( remaining) ] ) ;
486+ }
487+ }
488+ }
489+ Ok :: < _ , std:: io:: Error > ( ( buf, total) )
490+ } ) ;
491+
492+ let status = match tokio:: time:: timeout ( self . command_timeout , child. wait ( ) ) . await {
493+ Ok ( Ok ( status) ) => status,
494+ Ok ( Err ( error) ) => return Err ( AppError :: from ( error) ) ,
495+ Err ( _) => {
496+ #[ cfg( unix) ]
497+ if let Some ( pgid) = pgid {
498+ // Kill the entire process group so backgrounded descendants don't survive.
499+ // SAFETY: killpg with a valid pgid we just spawned is a safe syscall.
500+ unsafe {
501+ libc:: killpg ( pgid, libc:: SIGKILL ) ;
502+ }
503+ }
504+ #[ cfg( not( unix) ) ]
505+ {
506+ let _ = child. kill ( ) . await ;
507+ }
508+ let _ = child. wait ( ) . await ;
509+ let _ = stdout_task. await ;
510+ let _ = stderr_task. await ;
511+ return Err ( AppError :: Internal ( format ! (
512+ "Command timed out after {}s" ,
513+ self . command_timeout. as_secs( )
514+ ) ) ) ;
515+ }
516+ } ;
517+
518+ let ( stdout_raw, stdout_total) = stdout_task
519+ . await
520+ . map_err ( |e| AppError :: Internal ( format ! ( "stdout task failed: {e}" ) ) ) ?
521+ . map_err ( AppError :: from) ?;
522+ let ( stderr_raw, stderr_total) = stderr_task
523+ . await
524+ . map_err ( |e| AppError :: Internal ( format ! ( "stderr task failed: {e}" ) ) ) ?
525+ . map_err ( AppError :: from) ?;
526+
527+ let format_stream = |buf : & [ u8 ] , total : usize | {
528+ if total > MAX_OUTPUT_BYTES {
529+ let truncated = String :: from_utf8_lossy ( buf) ;
530+ format ! (
531+ "{}\n \n ... [truncated: {} total bytes, showing first {}]" ,
532+ truncated, total, MAX_OUTPUT_BYTES
533+ )
534+ } else {
535+ String :: from_utf8_lossy ( buf) . to_string ( )
536+ }
537+ } ;
538+
539+ let stdout = format_stream ( & stdout_raw, stdout_total) ;
540+ let stderr = format_stream ( & stderr_raw, stderr_total) ;
541+ let exit_code = status. code ( ) . unwrap_or ( -1 ) ;
542+
543+ Ok ( format ! (
544+ "exit_code: {}\n stdout:\n {}\n stderr:\n {}" ,
545+ exit_code, stdout, stderr
546+ ) )
478547 }
479548
480549 async fn web_search ( & self , input : & serde_json:: Value ) -> AppResult < String > {
@@ -1086,6 +1155,41 @@ mod tests {
10861155 cleanup ( "run_cmd_timeout" ) ;
10871156 }
10881157
1158+ #[ tokio:: test]
1159+ #[ cfg( unix) ]
1160+ async fn test_run_command_timeout_kills_backgrounded_children ( ) {
1161+ // Regression: with only kill_on_drop on the parent shell, a command like
1162+ // `sh -c "sleep 60 &"` orphans the sleep when sh exits or is killed —
1163+ // the descendant survives the timeout and continues running with the
1164+ // agent's privileges. The fix puts the child in its own process group
1165+ // and killpg's the whole group on timeout.
1166+ let sandbox_path = with_sandbox ( "run_cmd_orphan" ) ;
1167+ let proof = sandbox_path. join ( "orphan_proof.txt" ) ;
1168+ let proof_str = proof. to_string_lossy ( ) . to_string ( ) ;
1169+
1170+ let mut executor = ToolExecutor :: new ( sandbox_path) ;
1171+ executor. command_timeout = Duration :: from_millis ( 300 ) ;
1172+
1173+ let call = ToolCall {
1174+ tool : ToolName :: RunCommand ,
1175+ input : serde_json:: json!( {
1176+ "command" : format!( "(sleep 3 && echo orphan > '{}') & sleep 60" , proof_str) ,
1177+ } ) ,
1178+ } ;
1179+ let result = executor. execute ( call) . await ;
1180+ assert ! ( result. is_error, "timeout should trigger error" ) ;
1181+
1182+ // Wait long enough that the orphan WOULD have written its file if it survived.
1183+ tokio:: time:: sleep ( Duration :: from_secs ( 5 ) ) . await ;
1184+ assert ! (
1185+ !proof. exists( ) ,
1186+ "backgrounded descendant must be killed with the process group, but it wrote: {}" ,
1187+ proof. display( )
1188+ ) ;
1189+
1190+ cleanup ( "run_cmd_orphan" ) ;
1191+ }
1192+
10891193 // ── validate_path edge cases ─────────────────────────────────────────────
10901194
10911195 #[ tokio:: test]
0 commit comments