1mod claude;
2mod codex;
3mod gemini;
4
5pub use claude::ClaudeAdapter;
6pub use codex::CodexAdapter;
7pub use gemini::GeminiAdapter;
8
9use crate::error::{Error, Result};
10use crate::events::StreamEvent;
11use crate::types::{CliName, RunOptions, RunResult};
12#[cfg(windows)]
13use process_wrap::tokio::JobObject;
14#[cfg(unix)]
15use process_wrap::tokio::ProcessGroup;
16use process_wrap::tokio::TokioChildWrapper;
17use process_wrap::tokio::TokioCommandWrap;
18use std::collections::HashMap;
19use tokio::io::{AsyncBufReadExt, BufReader};
20use tracing::{debug, warn};
21
22pub trait CliAdapter: Send + Sync {
24 fn name(&self) -> CliName;
25
26 fn run(
27 &self,
28 opts: &RunOptions,
29 emit: &(dyn Fn(StreamEvent) + Send + Sync),
30 cancel: tokio_util::sync::CancellationToken,
31 ) -> impl std::future::Future<Output = crate::error::Result<RunResult>> + Send;
32}
33
34pub(crate) fn get_adapter(cli: CliName) -> Box<dyn CliAdapterBoxed> {
36 match cli {
37 CliName::Claude => Box::new(ClaudeAdapter),
38 CliName::Codex => Box::new(CodexAdapter),
39 CliName::Gemini => Box::new(GeminiAdapter),
40 }
41}
42
43#[allow(dead_code)]
49pub(crate) trait CliAdapterBoxed: Send + Sync {
50 fn name(&self) -> CliName;
51
52 fn run_boxed<'a>(
53 &'a self,
54 opts: &'a RunOptions,
55 emit: &'a (dyn Fn(StreamEvent) + Send + Sync),
56 cancel: tokio_util::sync::CancellationToken,
57 ) -> std::pin::Pin<
58 Box<dyn std::future::Future<Output = crate::error::Result<RunResult>> + Send + 'a>,
59 >;
60}
61
62impl<T: CliAdapter> CliAdapterBoxed for T {
63 fn name(&self) -> CliName {
64 CliAdapter::name(self)
65 }
66
67 fn run_boxed<'a>(
68 &'a self,
69 opts: &'a RunOptions,
70 emit: &'a (dyn Fn(StreamEvent) + Send + Sync),
71 cancel: tokio_util::sync::CancellationToken,
72 ) -> std::pin::Pin<
73 Box<dyn std::future::Future<Output = crate::error::Result<RunResult>> + Send + 'a>,
74 > {
75 Box::pin(self.run(opts, emit, cancel))
76 }
77}
78
79pub(crate) enum SpawnOutcome {
83 Done {
85 exit_code: Option<i32>,
92 signal: Option<i32>,
95 stderr: Option<String>,
96 dropped_lines: u64,
101 },
102 Cancelled,
104}
105
106pub(crate) struct SpawnParams<'a> {
108 pub cli_label: &'a str,
109 pub binary: &'a str,
110 pub args: &'a [String],
111 pub extra_env: &'a HashMap<String, String>,
112 pub strip_env: &'a [&'static str],
115 pub cwd: &'a str,
116 pub max_bytes: usize,
117 pub cancel: &'a tokio_util::sync::CancellationToken,
118}
119
120pub(crate) async fn spawn_and_stream(
127 params: SpawnParams<'_>,
128 mut on_line: impl FnMut(&str) + Send,
129) -> Result<SpawnOutcome> {
130 let SpawnParams {
131 cli_label,
132 binary,
133 args,
134 extra_env,
135 strip_env,
136 cwd,
137 max_bytes,
138 cancel,
139 } = params;
140 debug!(cli = cli_label, binary = %binary, args = ?args, "spawning CLI");
141
142 let mut wrap = TokioCommandWrap::with_new(binary, |cmd| {
156 cmd.args(args);
157 for key in strip_env {
158 cmd.env_remove(key);
159 }
160 cmd.envs(extra_env)
161 .current_dir(cwd)
162 .stdin(std::process::Stdio::null())
163 .stdout(std::process::Stdio::piped())
164 .stderr(std::process::Stdio::piped())
165 .kill_on_drop(true);
166 });
167 #[cfg(unix)]
168 wrap.wrap(ProcessGroup::leader());
169 #[cfg(windows)]
170 wrap.wrap(JobObject);
171
172 let mut child = wrap
173 .spawn()
174 .map_err(|e| Error::Process(format!("failed to spawn {cli_label}: {e}")))?;
175
176 let stdout = child.stdout().take().expect("stdout piped");
177 let stderr = child.stderr().take().expect("stderr piped");
178
179 let stderr_handle = tokio::spawn(async move {
185 use tokio::io::AsyncReadExt;
186 let mut stderr = stderr;
187 let mut buf: Vec<u8> = Vec::new();
188 let mut chunk = [0u8; 8192];
189 loop {
190 match stderr.read(&mut chunk).await {
191 Ok(0) | Err(_) => break,
192 Ok(n) => {
193 buf.extend_from_slice(&chunk[..n]);
194 if buf.len() > STDERR_TAIL_BYTES * 2 {
195 buf.drain(..buf.len() - STDERR_TAIL_BYTES);
196 }
197 }
198 }
199 }
200 String::from_utf8_lossy(&buf).into_owned()
201 });
202
203 let mut reader = BufReader::new(stdout);
210 let mut line_buf: Vec<u8> = Vec::new();
211 let mut dropped_lines: u64 = 0;
212
213 loop {
214 line_buf.clear();
215 tokio::select! {
216 result = read_line_capped(&mut reader, &mut line_buf, max_bytes) => {
217 match result {
218 Ok(CappedLine::Eof) => break,
219 Ok(CappedLine::Line { dropped: true }) => {
220 dropped_lines += 1;
221 warn!(cli = cli_label, max_bytes, "dropped a stdout line larger than the retention cap");
222 }
223 Ok(CappedLine::Line { dropped: false }) => {
224 on_line(String::from_utf8_lossy(&line_buf).trim());
225 }
226 Err(e) => {
227 warn!(cli = cli_label, error = %e, "error reading stdout");
228 break;
229 }
230 }
231 }
232 _ = cancel.cancelled() => {
233 kill_process_group(&mut child).await;
234 return Ok(SpawnOutcome::Cancelled);
235 }
236 }
237 }
238
239 let status = Box::into_pin(child.wait()).await.map_err(Error::Io)?;
240 let exit_code = status.code();
246 #[cfg(unix)]
247 let signal = std::os::unix::process::ExitStatusExt::signal(&status);
248 #[cfg(not(unix))]
249 let signal: Option<i32> = None;
250 let stderr_text = stderr_handle.await.unwrap_or_default();
251
252 Ok(SpawnOutcome::Done {
253 exit_code,
254 signal,
255 stderr: if stderr_text.is_empty() {
256 None
257 } else {
258 Some(stderr_text)
259 },
260 dropped_lines,
261 })
262}
263
264const STDERR_TAIL_BYTES: usize = 64 * 1024;
266
267enum CappedLine {
269 Line { dropped: bool },
272 Eof,
274}
275
276async fn read_line_capped<R: tokio::io::AsyncBufRead + Unpin>(
283 reader: &mut R,
284 buf: &mut Vec<u8>,
285 cap: usize,
286) -> std::io::Result<CappedLine> {
287 let mut dropped = false;
288 loop {
289 let (consumed, line_complete) = {
290 let available = reader.fill_buf().await?;
291 if available.is_empty() {
292 return Ok(if buf.is_empty() && !dropped {
294 CappedLine::Eof
295 } else {
296 CappedLine::Line { dropped }
297 });
298 }
299 match available.iter().position(|&b| b == b'\n') {
300 Some(newline) => {
301 if !dropped {
302 if buf.len() + newline <= cap {
303 buf.extend_from_slice(&available[..newline]);
304 } else {
305 dropped = true;
306 buf.clear();
307 }
308 }
309 (newline + 1, true)
310 }
311 None => {
312 let n = available.len();
313 if !dropped {
314 if buf.len() + n <= cap {
315 buf.extend_from_slice(available);
316 } else {
317 dropped = true;
318 buf.clear();
319 }
320 }
321 (n, false)
322 }
323 }
324 };
325 reader.consume(consumed);
326 if line_complete {
327 return Ok(CappedLine::Line { dropped });
328 }
329 }
330}
331
332pub(crate) fn warn_dropped_lines(dropped_lines: u64, max_bytes: usize, emit: &dyn Fn(StreamEvent)) {
335 if dropped_lines > 0 {
336 emit(StreamEvent::Error {
337 message: format!(
338 "{dropped_lines} output line(s) exceeded the {max_bytes}-byte retention cap and were dropped"
339 ),
340 severity: Some(crate::events::Severity::Warning),
341 });
342 }
343}
344
345pub(crate) fn describe_signal(signal: Option<i32>) -> Option<String> {
352 let sig = signal?;
353 Some(match sig {
354 2 => "The agent was interrupted (SIGINT).".to_string(),
355 6 => "The agent aborted (SIGABRT).".to_string(),
356 9 => "The agent was killed (SIGKILL), most often by the system reclaiming memory."
357 .to_string(),
358 11 => "The agent crashed (SIGSEGV).".to_string(),
359 15 => "The agent was terminated (SIGTERM).".to_string(),
360 other => format!("The agent was terminated by signal {other}."),
361 })
362}
363
364pub(crate) fn extract_error_message(stderr: Option<&str>) -> Option<String> {
368 let stderr = stderr?;
369 let msg = stderr
371 .lines()
372 .filter(|l| !l.is_empty())
373 .find(|l| {
374 let lower = l.to_lowercase();
375 lower.contains("error")
376 || lower.contains("limit")
377 || lower.contains("failed")
378 || lower.contains("denied")
379 || lower.contains("unauthorized")
380 })
381 .or_else(|| stderr.lines().rfind(|l| !l.is_empty()));
382 msg.map(|s| s.trim().to_string())
383}
384
385async fn kill_process_group(child: &mut Box<dyn TokioChildWrapper>) {
392 let _ = Box::into_pin(child.kill()).await;
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398
399 #[cfg(unix)]
421 #[tokio::test]
422 async fn cancelling_kills_the_grandchild_not_just_the_child() {
423 let dir = tempfile::tempdir().unwrap();
424 let marker = dir.path().join("survivor");
425 std::fs::write(&marker, "alive").unwrap();
426
427 let script = format!("(sleep 3; rm -f '{}') & sleep 10", marker.display());
429 let args = vec!["-c".to_string(), script];
430 let cancel = tokio_util::sync::CancellationToken::new();
431
432 let token = cancel.clone();
433 tokio::spawn(async move {
434 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
435 token.cancel();
436 });
437
438 let outcome = spawn_and_stream(
439 SpawnParams {
440 cli_label: "test",
441 binary: "sh",
442 args: &args,
443 extra_env: &HashMap::new(),
444 strip_env: &[],
445 cwd: dir.path().to_str().unwrap(),
446 max_bytes: 1024,
447 cancel: &cancel,
448 },
449 |_: &str| {},
450 )
451 .await
452 .expect("spawn");
453
454 assert!(
455 matches!(outcome, SpawnOutcome::Cancelled),
456 "run was cancelled"
457 );
458
459 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
461 assert!(
462 marker.exists(),
463 "the grandchild outlived cancellation and deleted the marker — the kill did not reach the process group"
464 );
465 }
466
467 #[cfg(unix)]
477 #[tokio::test]
478 async fn a_signalled_process_reports_the_signal_not_a_fabricated_exit_code() {
479 let args = vec!["-c".to_string(), "kill -9 $$".to_string()];
480 let cancel = tokio_util::sync::CancellationToken::new();
481 let outcome = spawn_and_stream(
482 SpawnParams {
483 cli_label: "test",
484 binary: "sh",
485 args: &args,
486 extra_env: &HashMap::new(),
487 strip_env: &[],
488 cwd: ".",
489 max_bytes: 1024,
490 cancel: &cancel,
491 },
492 |_| {},
493 )
494 .await
495 .expect("spawn should succeed");
496
497 match outcome {
498 SpawnOutcome::Done {
499 exit_code, signal, ..
500 } => {
501 assert_eq!(exit_code, None, "a signalled process has no exit code");
502 assert_eq!(signal, Some(9), "SIGKILL should be reported as itself");
503 }
504 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
505 }
506 }
507
508 #[tokio::test]
511 async fn a_normal_exit_still_reports_its_code() {
512 let args = vec!["-c".to_string(), "exit 3".to_string()];
513 let cancel = tokio_util::sync::CancellationToken::new();
514 let outcome = spawn_and_stream(
515 SpawnParams {
516 cli_label: "test",
517 binary: "sh",
518 args: &args,
519 extra_env: &HashMap::new(),
520 strip_env: &[],
521 cwd: ".",
522 max_bytes: 1024,
523 cancel: &cancel,
524 },
525 |_| {},
526 )
527 .await
528 .expect("spawn should succeed");
529
530 match outcome {
531 SpawnOutcome::Done {
532 exit_code, signal, ..
533 } => {
534 assert_eq!(exit_code, Some(3));
535 assert_eq!(signal, None, "an ordinary exit was not signalled");
536 }
537 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
538 }
539 }
540
541 #[test]
543 fn describe_signal_names_the_common_kills() {
544 assert!(describe_signal(Some(9)).unwrap().contains("SIGKILL"));
545 assert!(describe_signal(Some(9)).unwrap().contains("memory"));
546 assert!(describe_signal(Some(15)).unwrap().contains("SIGTERM"));
547 assert!(describe_signal(Some(42)).unwrap().contains("42"));
548 assert_eq!(describe_signal(None), None);
549 }
550
551 #[cfg(unix)]
559 #[tokio::test]
560 async fn total_output_beyond_max_bytes_streams_through_and_completes() {
561 let script = "i=0; while [ $i -lt 200 ]; do printf '%0100d\\n' $i; i=$((i+1)); done";
563 let args = vec!["-c".to_string(), script.to_string()];
564 let cancel = tokio_util::sync::CancellationToken::new();
565 let mut lines = 0u32;
566 let outcome = spawn_and_stream(
567 SpawnParams {
568 cli_label: "test",
569 binary: "sh",
570 args: &args,
571 extra_env: &HashMap::new(),
572 strip_env: &[],
573 cwd: ".",
574 max_bytes: 1024,
575 cancel: &cancel,
576 },
577 |_| lines += 1,
578 )
579 .await
580 .expect("a large-but-line-bounded run must not be an error");
581
582 match outcome {
583 SpawnOutcome::Done { exit_code, .. } => assert_eq!(exit_code, Some(0)),
584 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
585 }
586 assert_eq!(lines, 200, "every line was streamed through");
587 }
588
589 #[cfg(unix)]
594 #[tokio::test]
595 async fn an_oversized_line_is_dropped_and_the_run_continues() {
596 let script = "echo before; printf '%05000d\\n' 7; echo after";
597 let args = vec!["-c".to_string(), script.to_string()];
598 let cancel = tokio_util::sync::CancellationToken::new();
599 let mut seen: Vec<String> = Vec::new();
600 let outcome = spawn_and_stream(
601 SpawnParams {
602 cli_label: "test",
603 binary: "sh",
604 args: &args,
605 extra_env: &HashMap::new(),
606 strip_env: &[],
607 cwd: ".",
608 max_bytes: 1024,
609 cancel: &cancel,
610 },
611 |l| seen.push(l.to_string()),
612 )
613 .await
614 .expect("an oversized line must not abort the run");
615
616 assert_eq!(seen, vec!["before".to_string(), "after".to_string()]);
617 match outcome {
618 SpawnOutcome::Done {
619 exit_code,
620 dropped_lines,
621 ..
622 } => {
623 assert_eq!(exit_code, Some(0));
624 assert_eq!(dropped_lines, 1, "the loss is counted, never silent");
625 }
626 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
627 }
628 }
629
630 #[cfg(unix)]
634 #[tokio::test]
635 async fn stderr_retains_a_bounded_tail() {
636 let script = "i=0; while [ $i -lt 10000 ]; do printf '%0100d\\n' $i 1>&2; i=$((i+1)); done; \
638 echo 'Error: the last words' 1>&2; exit 1";
639 let args = vec!["-c".to_string(), script.to_string()];
640 let cancel = tokio_util::sync::CancellationToken::new();
641 let outcome = spawn_and_stream(
642 SpawnParams {
643 cli_label: "test",
644 binary: "sh",
645 args: &args,
646 extra_env: &HashMap::new(),
647 strip_env: &[],
648 cwd: ".",
649 max_bytes: 1024,
650 cancel: &cancel,
651 },
652 |_| {},
653 )
654 .await
655 .expect("spawn should succeed");
656
657 match outcome {
658 SpawnOutcome::Done { stderr, .. } => {
659 let stderr = stderr.expect("stderr was written");
660 assert!(
661 stderr.len() <= 256 * 1024,
662 "stderr retention must be bounded, got {} bytes",
663 stderr.len()
664 );
665 assert!(
666 stderr.contains("the last words"),
667 "the tail is the part that explains the failure"
668 );
669 }
670 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
671 }
672 }
673}