1use std::process::Stdio;
21use std::sync::Arc;
22
23use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
24use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
25use tokio::sync::{mpsc, oneshot};
26use tokio::task::JoinHandle;
27use tracing::{debug, warn};
28
29use crate::Error;
30use crate::argv::build_args;
31use crate::options::{Options, WireTee};
32
33pub const CLOSE_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
40
41fn spawn_error(err: std::io::Error, options: &Options) -> Error {
45 if err.kind() != std::io::ErrorKind::NotFound {
46 return Error::Io(err);
47 }
48 match options.cwd.as_deref() {
49 Some(cwd) if !cwd.is_dir() => {
50 Error::CwdNotFound { path: cwd.to_string_lossy().into_owned() }
51 }
52 _ => Error::CliNotFound { binary: options.binary.clone() },
53 }
54}
55
56pub fn query_cli_version(binary: &str) -> Result<String, Error> {
64 let output =
65 std::process::Command::new(binary).arg("--version").output().map_err(|e| {
66 match e.kind() {
67 std::io::ErrorKind::NotFound => Error::CliNotFound { binary: binary.to_string() },
68 _ => Error::Io(e),
69 }
70 })?;
71 if !output.status.success() {
72 return Err(Error::Process {
73 exit_code: output.status.code(),
74 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
75 });
76 }
77 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
78}
79
80pub fn check_cli_version(reported: &str, min_version: &str) -> Result<(), Error> {
88 let token = reported
90 .split_whitespace()
91 .find(|t| t.chars().next().is_some_and(|c| c.is_ascii_digit()))
92 .ok_or_else(|| Error::Connection {
93 reason: format!("could not parse claude version from: {reported}"),
94 })?;
95 let reported_triple = parse_semver_triple(token).ok_or_else(|| Error::Connection {
96 reason: format!("could not parse semver triple from: {token}"),
97 })?;
98 let min_triple = parse_semver_triple(min_version).ok_or_else(|| Error::Connection {
99 reason: format!("could not parse minimum semver triple from: {min_version}"),
100 })?;
101 if reported_triple < min_triple {
102 return Err(Error::Connection {
103 reason: format!("claude CLI version {reported} below minimum required {min_version}"),
104 });
105 }
106 Ok(())
107}
108
109fn parse_semver_triple(s: &str) -> Option<(u32, u32, u32)> {
112 let mut parts = s.split('.');
113 let major: u32 = parts.next()?.parse().ok()?;
114 let minor: u32 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
115 let patch_str = parts.next().unwrap_or("0");
116 let patch_digits: String = patch_str.chars().take_while(char::is_ascii_digit).collect();
118 let patch: u32 = if patch_digits.is_empty() { 0 } else { patch_digits.parse().ok()? };
119 Some((major, minor, patch))
120}
121
122const LOGGABLE_FLAGS: [&str; 16] = [
127 "output-format",
128 "verbose",
129 "system-prompt",
130 "system-prompt-file",
131 "append-system-prompt",
132 "allowedTools",
133 "max-turns",
134 "model",
135 "permission-prompt-tool",
136 "permission-mode",
137 "resume",
138 "session-id",
139 "mcp-config",
140 "plugin-dir",
141 "effort",
142 "input-format",
143];
144
145fn flag_names(
160 args: &[String],
161 extra_args: &std::collections::HashMap<String, Option<String>>,
162) -> (String, usize) {
163 let mut names: Vec<&str> = Vec::new();
164 let mut dropped = 0;
165 for token in args {
166 let Some(name) = token.strip_prefix("--") else { continue };
167 let name = name.split_once('=').map_or(name, |(before, _)| before);
168 if LOGGABLE_FLAGS.contains(&name) && !names.contains(&name) {
169 names.push(name);
170 } else if !extra_args.contains_key(name) {
171 dropped += 1;
172 }
173 }
174 (names.join(" "), dropped)
175}
176
177pub(crate) const STDIN_WRITE_BOUND: std::time::Duration = std::time::Duration::from_secs(30);
189
190fn write_stalled(bound: std::time::Duration, what: &str) -> Error {
192 Error::Io(std::io::Error::new(
193 std::io::ErrorKind::TimedOut,
194 format!("the CLI stopped reading stdin: {what} did not complete within {bound:?}"),
195 ))
196}
197
198#[derive(Clone, Copy, Debug)]
201enum Closed {
202 EndInput,
204 Stalled,
207 Failed,
209}
210
211impl Closed {
212 fn why(self) -> &'static str {
213 match self {
214 Self::EndInput => "stdin already closed (end_input)",
215 Self::Stalled => "stdin was closed because a write outlived its bound",
216 Self::Failed => "stdin was closed because a write failed",
217 }
218 }
219}
220
221fn closed_stdin(reason: Option<Closed>) -> Error {
223 Error::Io(std::io::Error::new(
224 std::io::ErrorKind::BrokenPipe,
225 reason.map_or("stdin already closed", Closed::why),
226 ))
227}
228
229type IoAck = Result<(), Error>;
232
233#[derive(Debug)]
235enum WriterCmd {
236 Write(String, oneshot::Sender<IoAck>),
238 EndInput(oneshot::Sender<IoAck>),
240}
241
242pub struct Subprocess {
249 writer_tx: mpsc::UnboundedSender<WriterCmd>,
253 write_bound: std::time::Duration,
265 reader_rx: mpsc::UnboundedReceiver<Result<Option<String>, Error>>,
267 writer_task: Option<JoinHandle<()>>,
273 stderr_task: Option<JoinHandle<String>>,
277 child: Option<Child>,
280 pgid: Option<u32>,
284 session: Arc<parking_lot::RwLock<String>>,
290 closed: bool,
292}
293
294impl std::fmt::Debug for Subprocess {
295 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296 f.debug_struct("Subprocess")
297 .field("child", &self.child.is_some())
298 .field("closed", &self.closed)
299 .finish_non_exhaustive()
300 }
301}
302
303impl Subprocess {
304 pub async fn spawn(options: &Options) -> Result<Self, Error> {
313 if let Some(min) = &options.minimum_cli_version {
318 let binary = options.binary.clone();
321 let reported = tokio::task::spawn_blocking(move || query_cli_version(&binary))
322 .await
323 .map_err(|e| Error::Connection {
324 reason: format!("version probe join failed: {e}"),
325 })?
326 .map_err(|e| Error::Connection {
327 reason: format!("minimum_cli_version set but --version probe failed: {e}"),
328 })?;
329 check_cli_version(&reported, min)?;
330 }
331 let args = build_args(options)?;
332 let session = Arc::new(parking_lot::RwLock::new(
335 options.session_id.clone().or_else(|| options.resume.clone()).unwrap_or_default(),
336 ));
337 let mut cmd = Command::new(&options.binary);
338 cmd.args(&args);
339 if let Some(cwd) = &options.cwd {
340 cmd.current_dir(cwd);
341 }
342
343 cmd.env_remove("CLAUDECODE");
359 cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
360 for (k, v) in &options.env {
361 cmd.env(k, v);
362 }
363 cmd.env("CLAUDE_AGENT_SDK_VERSION", env!("CARGO_PKG_VERSION"));
364 if let Some(cwd) = &options.cwd {
365 cmd.env("PWD", cwd);
366 }
367
368 cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
369 cmd.kill_on_drop(true);
370 #[cfg(unix)]
374 cmd.process_group(0);
375
376 let (flags, flags_dropped) = flag_names(&args, &options.extra_args);
377 debug!(
378 binary = %options.binary,
379 cwd = ?options.cwd,
380 %flags,
381 flags_dropped,
382 extra_args_len = options.extra_args.len(),
383 "spawning claude subprocess"
384 );
385 let mut child = cmd.spawn().map_err(|e| spawn_error(e, options))?;
386 #[cfg(unix)]
390 let pgid = child.id();
391 #[cfg(not(unix))]
392 let pgid: Option<u32> = None;
393
394 let stdin = child
395 .stdin
396 .take()
397 .ok_or_else(|| Error::Connection { reason: "stdin pipe missing".into() })?;
398 let stdout = child
399 .stdout
400 .take()
401 .ok_or_else(|| Error::Connection { reason: "stdout pipe missing".into() })?;
402 let stderr = child
403 .stderr
404 .take()
405 .ok_or_else(|| Error::Connection { reason: "stderr pipe missing".into() })?;
406
407 let stderr_task = tokio::spawn(tracing::Instrument::instrument(
408 drain_stderr(stderr),
409 tracing::info_span!("forge_sdk::stderr_reader"),
410 ));
411
412 let (reader_tx, reader_rx) = mpsc::unbounded_channel();
413 spawn_reader_task(stdout, options.tee_inbound.clone(), reader_tx);
414
415 let (writer_tx, writer_rx) = mpsc::unbounded_channel();
416 let writer_task = spawn_writer_task(
417 stdin,
418 options.tee_outbound.clone(),
419 writer_rx,
420 STDIN_WRITE_BOUND,
421 session.clone(),
422 );
423
424 Ok(Self {
425 writer_tx,
426 write_bound: STDIN_WRITE_BOUND,
427 reader_rx,
428 writer_task: Some(writer_task),
429 stderr_task: Some(stderr_task),
430 child: Some(child),
431 pgid,
432 session,
433 closed: false,
434 })
435 }
436
437 pub fn child_pid(&self) -> Option<u32> {
451 self.child.as_ref().and_then(tokio::process::Child::id)
452 }
453
454 pub(crate) fn session_id(&self) -> Arc<parking_lot::RwLock<String>> {
459 Arc::clone(&self.session)
460 }
461
462 pub async fn read_line(&mut self) -> Result<Option<String>, Error> {
468 self.reader_rx.recv().await.unwrap_or(Ok(None))
471 }
472
473 fn ack_wait(&self) -> std::time::Duration {
477 self.write_bound * 2
478 }
479
480 pub async fn write_line(&mut self, line: &str) -> Result<(), Error> {
491 let (ack_tx, ack_rx) = oneshot::channel();
492 self.writer_tx.send(WriterCmd::Write(line.to_owned(), ack_tx)).map_err(|_| {
493 Error::Io(std::io::Error::new(
494 std::io::ErrorKind::BrokenPipe,
495 "Subprocess writer task gone",
496 ))
497 })?;
498 match tokio::time::timeout(self.ack_wait(), ack_rx).await {
499 Ok(Ok(result)) => result,
500 Ok(Err(_)) => Err(Error::Io(std::io::Error::new(
501 std::io::ErrorKind::BrokenPipe,
502 "Subprocess writer task dropped ack",
503 ))),
504 Err(_) => {
505 Err(write_stalled(self.ack_wait(), "the caller's wait for a stdin write's ack"))
506 }
507 }
508 }
509
510 pub async fn end_input(&mut self) -> Result<(), Error> {
518 let (ack_tx, ack_rx) = oneshot::channel();
519 if self.writer_tx.send(WriterCmd::EndInput(ack_tx)).is_err() {
522 return Ok(());
523 }
524 match tokio::time::timeout(self.ack_wait(), ack_rx).await {
525 Ok(Ok(result)) => result,
526 Ok(Err(_)) => {
527 warn!("Subprocess::end_input: ack channel dropped");
528 Ok(())
529 }
530 Err(_) => {
531 Err(write_stalled(self.ack_wait(), "the caller's wait for the end-input ack"))
532 }
533 }
534 }
535
536 pub(crate) fn clone_writer(&self) -> Arc<SharedWriter> {
542 Arc::new(SharedWriter { writer_tx: self.writer_tx.clone(), bound: self.write_bound })
543 }
544
545 pub async fn close(&mut self) -> Result<(), Error> {
555 if self.closed {
556 return Ok(());
557 }
558 self.closed = true;
559
560 let (closed_tx, closed_rx) = mpsc::unbounded_channel();
568 self.writer_tx = closed_tx;
569 drop(closed_rx);
574 if let Some(handle) = self.writer_task.take() {
575 handle.abort();
576 if let Err(e) = handle.await
582 && !e.is_cancelled()
583 {
584 debug!(error = %e, "writer task ended abnormally");
585 }
586 }
587
588 if let Some(mut child) = self.child.take() {
591 let waited = tokio::time::timeout(CLOSE_WAIT_TIMEOUT, child.wait()).await;
592 if let Some(pgid) = self.pgid
598 && let Some(error) = kill_group(pgid)
599 {
600 warn!(
601 target: "forge_sdk",
602 event_name = "subprocess_group_signal_failed",
603 session = %self.session.read().as_str(),
604 pgid,
605 error = %error,
606 "the session child's process group could not be signalled; \
607 a descendant may outlive the session"
608 );
609 }
610 if waited.is_err() {
611 warn!("Subprocess::close timed out waiting for child; sending SIGKILL");
612 if let Err(e) = child.kill().await {
613 warn!(error = %e, "Subprocess::close: kill() failed");
614 }
615 }
616 drop(child);
619 let stderr = label_stderr_tail(&self.join_stderr_tail().await);
622 match waited {
623 Ok(Ok(status)) if status.success() => Ok(()),
624 Ok(Ok(status)) => Err(Error::Process { exit_code: status.code(), stderr }),
625 Ok(Err(e)) => Err(Error::Io(e)),
626 Err(_elapsed) => {
627 let mut reason = String::from("close timeout - child killed");
628 if !stderr.is_empty() {
629 reason.push('\n');
630 reason.push_str(&stderr);
631 }
632 Err(Error::Process { exit_code: None, stderr: reason })
633 }
634 }
635 } else {
636 self.join_stderr_tail().await;
637 Ok(())
638 }
639 }
640
641 async fn join_stderr_tail(&mut self) -> String {
645 let Some(task) = self.stderr_task.take() else { return String::new() };
646 match task.await {
647 Ok(tail) => tail,
648 Err(e) => {
649 debug!(error = %e, "stderr drain task ended abnormally");
650 String::new()
651 }
652 }
653 }
654}
655
656#[derive(Debug, Clone)]
663pub(crate) struct SharedWriter {
664 writer_tx: mpsc::UnboundedSender<WriterCmd>,
665 bound: std::time::Duration,
668}
669
670impl SharedWriter {
671 fn ack_wait(&self) -> std::time::Duration {
675 self.bound * 2
676 }
677}
678
679#[cfg(test)]
680impl SharedWriter {
681 pub(crate) fn test_stub() -> (Self, mpsc::UnboundedReceiver<String>) {
684 let (writer_tx, mut cmd_rx) = mpsc::unbounded_channel();
685 let (line_tx, line_rx) = mpsc::unbounded_channel();
686 tokio::spawn(async move {
687 while let Some(cmd) = cmd_rx.recv().await {
688 match cmd {
689 WriterCmd::Write(line, ack) => {
690 let _ = line_tx.send(line);
691 let _ = ack.send(Ok(()));
692 }
693 WriterCmd::EndInput(ack) => {
694 let _ = ack.send(Ok(()));
695 }
696 }
697 }
698 });
699 (Self { writer_tx, bound: STDIN_WRITE_BOUND }, line_rx)
700 }
701}
702
703impl SharedWriter {
704 pub(crate) async fn write_line(&self, line: &str) -> Result<(), Error> {
712 let (ack_tx, ack_rx) = oneshot::channel();
713 self.writer_tx.send(WriterCmd::Write(line.to_owned(), ack_tx)).map_err(|_| {
714 Error::Io(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "SharedWriter task gone"))
715 })?;
716 match tokio::time::timeout(self.ack_wait(), ack_rx).await {
721 Ok(Ok(result)) => result,
722 Ok(Err(_)) => Err(Error::Io(std::io::Error::new(
723 std::io::ErrorKind::BrokenPipe,
724 "SharedWriter task dropped ack",
725 ))),
726 Err(_) => {
727 Err(write_stalled(self.ack_wait(), "the caller's wait for a stdin write's ack"))
728 }
729 }
730 }
731
732 pub(crate) async fn end_input(&self) -> Result<(), Error> {
738 let (ack_tx, ack_rx) = oneshot::channel();
739 if self.writer_tx.send(WriterCmd::EndInput(ack_tx)).is_err() {
742 return Ok(());
743 }
744 match tokio::time::timeout(self.ack_wait(), ack_rx).await {
747 Ok(Ok(result)) => result,
748 Ok(Err(_)) => {
749 warn!("SharedWriter::end_input: ack channel dropped");
750 Ok(())
751 }
752 Err(_) => {
753 Err(write_stalled(self.ack_wait(), "the caller's wait for the end-input ack"))
754 }
755 }
756 }
757}
758
759fn spawn_reader_task(
760 stdout: ChildStdout,
761 tee: Option<WireTee>,
762 tx: mpsc::UnboundedSender<Result<Option<String>, Error>>,
763) {
764 use tracing::Instrument;
765 let span = tracing::info_span!("forge_sdk::stdout_reader");
766 tokio::spawn(
767 async move {
768 let mut reader = BufReader::new(stdout);
769 let mut buf = String::new();
770 loop {
771 buf.clear();
772 match reader.read_line(&mut buf).await {
773 Ok(0) => {
774 let _ = tx.send(Ok(None));
776 break;
777 }
778 Ok(_) => {
779 let mut line = std::mem::take(&mut buf);
780 while matches!(line.chars().last(), Some('\n' | '\r')) {
781 line.pop();
782 }
783 if let Some(cb) = tee.as_ref() {
784 cb(&line);
785 }
786 if tx.send(Ok(Some(line))).is_err() {
787 break;
789 }
790 }
791 Err(e) => {
792 if tx.send(Err(Error::Io(e))).is_err() {
793 tracing::warn!(
794 target: "forge_sdk::transport",
795 "subprocess stdout I/O error after caller dropped reader"
796 );
797 }
798 break;
799 }
800 }
801 }
802 }
803 .instrument(span),
804 );
805}
806
807async fn write_one(stdin: &mut ChildStdin, line: &[u8]) -> std::io::Result<()> {
809 stdin.write_all(line).await?;
810 stdin.flush().await
811}
812
813fn spawn_writer_task(
814 stdin: ChildStdin,
815 tee: Option<WireTee>,
816 mut rx: mpsc::UnboundedReceiver<WriterCmd>,
817 write_bound: std::time::Duration,
818 session: Arc<parking_lot::RwLock<String>>,
819) -> JoinHandle<()> {
820 use tracing::Instrument;
821 let span = tracing::info_span!("forge_sdk::stdin_writer");
822 tokio::spawn(
823 async move {
824 let mut stdin = Some(stdin);
825 let mut closed: Option<Closed> = None;
829 while let Some(cmd) = rx.recv().await {
830 match cmd {
831 WriterCmd::Write(line, ack) => {
832 if let Some(cb) = tee.as_ref() {
833 cb(line.trim_end_matches('\n'));
834 }
835 let result = if let Some(s) = stdin.as_mut() {
836 match tokio::time::timeout(write_bound, write_one(s, line.as_bytes()))
837 .await
838 {
839 Ok(Ok(())) => Ok(()),
840 Ok(Err(e)) => {
841 closed = Some(Closed::Failed);
842 Err(Error::Io(e))
843 }
844 Err(_) => {
845 tracing::warn!(
850 target: "forge_sdk",
851 event_name = "stdin_write_stalled",
852 session = %session.read().as_str(),
853 bound_ms = write_bound.as_millis(),
854 "the CLI stopped reading stdin: a write did not complete \
855 within the bound, so the stream is closed",
856 );
857 closed = Some(Closed::Stalled);
858 Err(write_stalled(write_bound, "a stdin write"))
859 }
860 }
861 } else {
862 Err(closed_stdin(closed))
863 };
864 if closed.is_some() {
865 stdin = None;
872 }
873 let _ = ack.send(result);
874 }
875 WriterCmd::EndInput(ack) => {
876 if closed.is_none() {
880 closed = Some(Closed::EndInput);
881 }
882 drop(stdin.take());
883 let _ = ack.send(Ok(()));
884 }
885 }
886 }
887 }
888 .instrument(span),
889 )
890}
891
892fn label_stderr_tail(tail: &str) -> String {
897 if tail.is_empty() { String::new() } else { format!("last stderr from the CLI: {tail}") }
898}
899
900const STDERR_TAIL_CAP: usize = 8 * 1024;
905
906#[derive(Default)]
910struct StderrTail {
911 lines: std::collections::VecDeque<String>,
912 bytes: usize,
913}
914
915impl StderrTail {
916 fn push(&mut self, line: &str) {
917 let mut start = line.len().saturating_sub(STDERR_TAIL_CAP);
920 while !line.is_char_boundary(start) {
921 start += 1;
922 }
923 let kept = &line[start..];
924 self.bytes += kept.len() + 1;
925 self.lines.push_back(kept.to_owned());
926 while self.bytes > STDERR_TAIL_CAP && self.lines.len() > 1 {
929 let Some(dropped) = self.lines.pop_front() else { break };
930 self.bytes -= dropped.len() + 1;
931 }
932 }
933
934 fn into_string(self) -> String {
935 Vec::from(self.lines).join("\n")
936 }
937}
938
939#[cfg(unix)]
945pub(crate) fn kill_group(pgid: u32) -> Option<std::io::Error> {
946 use rustix::io::Errno;
947 use rustix::process::{Pid, Signal, kill_process_group};
948
949 let Ok(raw) = i32::try_from(pgid) else {
950 debug!(pgid, "process group id does not fit in i32; nothing signalled");
951 return None;
952 };
953 let Some(pgid) = Pid::from_raw(raw) else {
954 debug!(raw, "process group id names no process; nothing signalled");
955 return None;
956 };
957 match kill_process_group(pgid, Signal::KILL) {
958 Ok(()) | Err(Errno::SRCH) => None,
959 Err(error) => Some(std::io::Error::from_raw_os_error(error.raw_os_error())),
960 }
961}
962
963#[cfg(not(unix))]
964fn kill_group(_pgid: u32) -> Option<std::io::Error> {
965 None
966}
967
968async fn drain_stderr(stderr: ChildStderr) -> String {
974 let mut reader = BufReader::new(stderr);
975 let mut raw = Vec::new();
976 let mut tail = StderrTail::default();
977 loop {
978 raw.clear();
979 let n = match reader.read_until(b'\n', &mut raw).await {
983 Ok(n) => n,
984 Err(e) => {
985 debug!(?e, "stderr read failed");
986 break;
987 }
988 };
989 if n == 0 {
990 break;
991 }
992 let mut line = String::from_utf8_lossy(&raw).into_owned();
993 while matches!(line.chars().last(), Some('\n' | '\r')) {
994 line.pop();
995 }
996 tail.push(&line);
997 if line.starts_with("ERROR") || line.starts_with("Error") {
998 tracing::warn!(target: "forge_sdk::stderr", line = %line, "claude stderr");
999 } else {
1000 tracing::info!(target: "forge_sdk::stderr", line = %line, "claude stderr");
1001 }
1002 }
1003 tail.into_string()
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008
1009 use super::*;
1010 use std::time::Duration;
1011
1012 #[derive(Clone, Default)]
1014 struct FieldCapture(Arc<std::sync::Mutex<String>>);
1015
1016 impl FieldCapture {
1017 fn text(&self) -> String {
1018 self.0.lock().expect("capture").clone()
1019 }
1020 }
1021
1022 struct AllFields<'a>(&'a mut String);
1023
1024 impl tracing::field::Visit for AllFields<'_> {
1025 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
1028 use std::fmt::Write;
1029 let _ = write!(self.0, " {}={value:?}", field.name());
1030 }
1031 }
1032
1033 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for FieldCapture {
1034 fn on_event(
1035 &self,
1036 event: &tracing::Event<'_>,
1037 _ctx: tracing_subscriber::layer::Context<'_, S>,
1038 ) {
1039 let mut buf = self.0.lock().expect("capture");
1040 buf.push_str(event.metadata().target());
1041 event.record(&mut AllFields(&mut buf));
1042 buf.push('\n');
1043 }
1044 }
1045
1046 fn options_with_secrets() -> Options {
1051 let mut options = crate::OptionsBuilder::new()
1052 .binary("/nonexistent/claude-for-this-test")
1055 .model("claude-test")
1056 .resume("session-abc")
1057 .system_prompt(crate::SystemPromptKind::preset_append(
1058 "sentinel-append-prompt-must-never-be-logged",
1059 ))
1060 .env("ANTHROPIC_AUTH_TOKEN", "sentinel-auth-must-never-be-logged")
1061 .env("AIRMAIL_TOKEN", "sentinel-airmail-must-never-be-logged")
1062 .extra_arg("some-flag", Some("sentinel-extra-arg-must-never-be-logged".to_owned()))
1063 .extra_arg("odd-flag", Some("--sentinel-dashed-must-never-be-logged".to_owned()))
1066 .build();
1067 options.minimum_cli_version = None;
1070 options.external_mcp_servers.insert(
1071 "billing-http".to_owned(),
1072 forge_primitives::McpServerConfig::Http {
1073 url: "https://mcp.example.com".to_owned(),
1074 headers: std::collections::HashMap::from([(
1075 "Authorization".to_owned(),
1076 "Bearer sentinel-http-header-must-never-be-logged".to_owned(),
1077 )]),
1078 },
1079 );
1080 options.external_mcp_servers.insert(
1081 "billing-sse".to_owned(),
1082 forge_primitives::McpServerConfig::Sse {
1083 url: "https://sse.example.com".to_owned(),
1084 headers: std::collections::HashMap::from([(
1085 "Authorization".to_owned(),
1086 "Bearer sentinel-sse-header-must-never-be-logged".to_owned(),
1087 )]),
1088 },
1089 );
1090 options.external_mcp_servers.insert(
1091 "billing-stdio".to_owned(),
1092 forge_primitives::McpServerConfig::Stdio {
1093 command: "/bin/true".to_owned(),
1094 args: Vec::new(),
1095 env: std::collections::HashMap::from([(
1096 "STDIO_MCP_TOKEN".to_owned(),
1097 "sentinel-stdio-env-must-never-be-logged".to_owned(),
1098 )]),
1099 },
1100 );
1101 options
1102 }
1103
1104 fn declared_secrets(options: &Options) -> Vec<String> {
1108 let mut secrets: Vec<String> = options.env.values().cloned().collect();
1109 secrets.extend(options.extra_args.values().flatten().cloned());
1110 for cfg in options.external_mcp_servers.values() {
1111 match cfg {
1112 forge_primitives::McpServerConfig::Stdio { env, .. } => {
1113 secrets.extend(env.values().cloned());
1114 }
1115 forge_primitives::McpServerConfig::Sse { headers, .. }
1116 | forge_primitives::McpServerConfig::Http { headers, .. } => {
1117 secrets.extend(headers.values().cloned());
1118 }
1119 }
1120 }
1121 if let Some(crate::SystemPromptKind::Preset { append: Some(text), .. }) =
1122 &options.system_prompt
1123 {
1124 secrets.push(text.clone());
1125 }
1126 secrets
1127 }
1128
1129 #[tokio::test]
1134 async fn spawn_names_a_missing_cwd_apart_from_a_missing_binary() {
1135 let missing_cwd = std::env::temp_dir().join("forge-sdk-no-such-working-dir");
1136 assert!(!missing_cwd.exists(), "fixture precondition: {missing_cwd:?} must not exist");
1137 let missing_cwd = missing_cwd.to_string_lossy().into_owned();
1138 let options = Options {
1139 binary: "claude".to_owned(),
1140 cwd: Some(missing_cwd.clone().into()),
1141 minimum_cli_version: None,
1142 ..Options::default()
1143 };
1144 let err = Subprocess::spawn(&options).await.expect_err("a missing cwd must fail the spawn");
1145 assert!(
1146 matches!(&err, Error::CwdNotFound { path } if *path == missing_cwd),
1147 "a spawn that could not enter its working directory reported {err:?} rather than \
1148 naming that directory"
1149 );
1150
1151 let options = Options {
1152 binary: "forge-no-such-binary-on-path".to_owned(),
1153 cwd: Some(std::env::temp_dir()),
1154 minimum_cli_version: None,
1155 ..Options::default()
1156 };
1157 let err =
1158 Subprocess::spawn(&options).await.expect_err("a missing binary must fail the spawn");
1159 assert!(
1160 matches!(err, Error::CliNotFound { .. }),
1161 "a binary that is not on PATH reported {err:?} rather than CliNotFound"
1162 );
1163 }
1164
1165 #[test]
1168 fn spawn_record_renders_no_declared_secret() {
1169 use tracing_subscriber::layer::SubscriberExt;
1170
1171 let options = options_with_secrets();
1172 let capture = FieldCapture::default();
1173 let subscriber = tracing_subscriber::registry()
1174 .with(capture.clone())
1175 .with(tracing_subscriber::filter::LevelFilter::TRACE);
1176 let result = tracing::subscriber::with_default(subscriber, || {
1177 tokio::runtime::Builder::new_current_thread()
1178 .enable_all()
1179 .build()
1180 .expect("runtime")
1181 .block_on(Subprocess::spawn(&options))
1182 });
1183 assert!(
1184 matches!(result, Err(Error::CliNotFound { .. })),
1185 "fixture binary must not exist, got {:?}",
1186 result.as_ref().err()
1187 );
1188
1189 let logged = capture.text();
1190 assert!(
1191 logged.contains("spawning claude subprocess"),
1192 "the record under test never fired, so nothing was proved: {logged:?}",
1193 );
1194
1195 let secrets = declared_secrets(&options);
1198 assert_eq!(secrets.len(), 8, "fixture lost a sentinel: {secrets:?}");
1199
1200 let argv = build_args(&options).expect("fixture argv").join("\u{1}");
1201 for secret in &secrets {
1202 assert!(
1205 argv.contains(secret) || options.env.values().any(|v| v == secret),
1206 "sentinel {secret:?} reaches neither argv nor the child env",
1207 );
1208 let bare = secret.trim_start_matches('-');
1213 assert!(
1214 !logged.contains(secret) && !logged.contains(bare),
1215 "spawn record leaked {secret:?}: {logged:?}",
1216 );
1217 }
1218 }
1219
1220 fn spawn_sleep_subprocess(secs: u64) -> Subprocess {
1221 spawn_sleep_subprocess_with_bound(secs, STDIN_WRITE_BOUND)
1222 }
1223
1224 fn spawn_sleep_subprocess_with_bound(secs: u64, write_bound: Duration) -> Subprocess {
1225 let mut cmd = Command::new("/bin/sleep");
1226 cmd.arg(secs.to_string());
1227 spawn_test_subprocess_with_bound(cmd, write_bound)
1228 }
1229
1230 fn spawn_test_subprocess(cmd: Command) -> Subprocess {
1231 spawn_test_subprocess_with_bound(cmd, STDIN_WRITE_BOUND)
1232 }
1233
1234 fn spawn_test_subprocess_with_bound(mut cmd: Command, write_bound: Duration) -> Subprocess {
1235 cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
1236 cmd.kill_on_drop(true);
1237 let mut child = cmd.spawn().expect("spawn test child");
1238 let stdin = child.stdin.take().expect("stdin");
1239 let stdout = child.stdout.take().expect("stdout");
1240 let stderr = child.stderr.take().expect("stderr");
1241
1242 let stderr_task = tokio::spawn(drain_stderr(stderr));
1243 let (reader_tx, reader_rx) = mpsc::unbounded_channel();
1244 spawn_reader_task(stdout, None, reader_tx);
1245 let (writer_tx, writer_rx) = mpsc::unbounded_channel();
1246 let session = Arc::new(parking_lot::RwLock::new(String::new()));
1247 let writer_task =
1248 spawn_writer_task(stdin, None, writer_rx, write_bound, Arc::clone(&session));
1249
1250 Subprocess {
1251 writer_tx,
1252 write_bound,
1253 reader_rx,
1254 writer_task: Some(writer_task),
1255 stderr_task: Some(stderr_task),
1256 child: Some(child),
1257 pgid: None,
1258 session,
1259 closed: false,
1260 }
1261 }
1262
1263 #[tokio::test]
1267 async fn close_kills_unresponsive_child_within_5s() {
1268 let mut sub = spawn_sleep_subprocess(30);
1269
1270 let start = tokio::time::Instant::now();
1271 let result = sub.close().await;
1272 let elapsed = start.elapsed();
1273
1274 assert!(elapsed <= Duration::from_secs(30), "close() took {elapsed:?}, expected <= 30s");
1278 assert!(
1279 elapsed >= Duration::from_secs(5),
1280 "close() returned in {elapsed:?}, expected >= 5s (close timeout fired)"
1281 );
1282 match result {
1283 Err(Error::Process { stderr, .. }) => {
1284 assert!(
1285 stderr.contains("close timeout"),
1286 "expected stderr to mention close timeout, got: {stderr}"
1287 );
1288 }
1289 other => panic!("expected Process error from kill, got {other:?}"),
1290 }
1291 }
1292
1293 #[tokio::test(start_paused = true)]
1303 async fn a_child_that_stops_reading_stdin_fails_the_write_within_the_bound() {
1304 let sub = spawn_sleep_subprocess_with_bound(30, Duration::from_millis(300));
1307 let writer = sub.clone_writer();
1308 let line = format!("{}\n", "x".repeat(1 << 20));
1310
1311 let started = tokio::time::Instant::now();
1312 let outcome = tokio::time::timeout(Duration::from_secs(5), writer.write_line(&line))
1313 .await
1314 .expect("the write did not fail within its bound");
1315 let waited = started.elapsed();
1316 let err = outcome.expect_err("a child that stopped reading stdin must fail the write");
1317 let Error::Io(io) = err else { panic!("expected an I/O error, got {err:?}") };
1318 assert_eq!(io.kind(), std::io::ErrorKind::TimedOut, "the bound is what failed it: {io}");
1319 assert!(
1320 waited >= Duration::from_millis(300),
1321 "the writer's own bound is what fired: {waited:?}"
1322 );
1323 assert!(
1327 io.to_string().contains("a stdin write did not complete"),
1328 "the writer's own failure must be the answer, not the caller's wait: {io}"
1329 );
1330
1331 let again = tokio::time::timeout(Duration::from_secs(5), writer.write_line("more\n"))
1336 .await
1337 .expect("a write after a stalled one must answer, not hang");
1338 let err = again.expect_err("a stalled stream must not accept another line");
1339 let Error::Io(io) = err else { panic!("expected an I/O error, got {err:?}") };
1340 assert_eq!(io.kind(), std::io::ErrorKind::BrokenPipe, "the task did not drop stdin: {io}");
1341 assert!(
1342 io.to_string().contains("outlived its bound"),
1343 "a later write must name the stall, not a call that never ran: {io}"
1344 );
1345 }
1346
1347 #[tokio::test(start_paused = true)]
1353 async fn every_wait_on_a_writer_that_never_acks_fails_within_its_bound() {
1354 async fn fails_within<F>(what: &str, fut: F) -> (Duration, String)
1357 where
1358 F: std::future::Future<Output = Result<(), Error>>,
1359 {
1360 let started = tokio::time::Instant::now();
1361 let outcome = tokio::time::timeout(Duration::from_secs(5), fut).await;
1362 let outcome =
1363 outcome.unwrap_or_else(|_| panic!("{what} did not fail within its bound"));
1364 let waited = started.elapsed();
1365 let err = match outcome {
1366 Ok(()) => panic!("{what} answered Ok from a writer that never acks"),
1367 Err(err) => err,
1368 };
1369 let Error::Io(io) = err else { panic!("{what}: expected an I/O error, got {err:?}") };
1370 assert_eq!(io.kind(), std::io::ErrorKind::TimedOut, "{what}: {io}");
1371 (waited, io.to_string())
1372 }
1373
1374 let (writer_tx, queue) = mpsc::unbounded_channel();
1377 let bound = Duration::from_millis(100);
1378 let (_reader_tx, reader_rx) = mpsc::unbounded_channel();
1379 let mut subprocess = Subprocess {
1380 writer_tx: writer_tx.clone(),
1381 write_bound: bound,
1382 reader_rx,
1383 writer_task: None,
1384 stderr_task: None,
1385 child: None,
1386 pgid: None,
1387 session: Arc::new(parking_lot::RwLock::new(String::new())),
1388 closed: false,
1389 };
1390 let shared = SharedWriter { writer_tx, bound };
1391
1392 let (waited, why) =
1396 fails_within("Subprocess::write_line", subprocess.write_line("line\n")).await;
1397 assert!(waited >= bound * 2, "a caller waits twice the writer's bound: {waited:?}");
1398 assert!(
1399 why.contains(&format!("{:?}", bound * 2)),
1400 "the record names the wait it fired at: {why}"
1401 );
1402
1403 fails_within("Subprocess::end_input", subprocess.end_input()).await;
1404 fails_within("SharedWriter::write_line", shared.write_line("line\n")).await;
1405 fails_within("SharedWriter::end_input", shared.end_input()).await;
1406 drop(queue);
1407 }
1408
1409 #[test]
1413 fn a_closed_stream_names_why_it_closed() {
1414 for (reason, needle) in [
1415 (Closed::EndInput, "end_input"),
1416 (Closed::Stalled, "outlived its bound"),
1417 (Closed::Failed, "a write failed"),
1418 ] {
1419 let Error::Io(io) = closed_stdin(Some(reason)) else {
1420 panic!("expected an I/O error for {reason:?}")
1421 };
1422 assert_eq!(io.kind(), std::io::ErrorKind::BrokenPipe, "{reason:?}: {io}");
1423 assert!(io.to_string().contains(needle), "{reason:?}: {io}");
1424 }
1425 let Error::Io(io) = closed_stdin(None) else { panic!("expected an I/O error") };
1426 assert!(io.to_string().contains("already closed"), "{io}");
1427 }
1428
1429 #[test]
1437 fn a_stdin_stall_records_the_current_session_and_its_bound() {
1438 use tracing_subscriber::layer::SubscriberExt;
1439
1440 let capture = FieldCapture::default();
1441 let subscriber = tracing_subscriber::registry()
1442 .with(capture.clone())
1443 .with(tracing_subscriber::filter::LevelFilter::TRACE);
1444 tracing::subscriber::with_default(subscriber, || {
1445 tokio::runtime::Builder::new_current_thread()
1446 .enable_all()
1447 .build()
1448 .expect("runtime")
1449 .block_on(async {
1450 let sub = spawn_sleep_subprocess_with_bound(30, Duration::from_millis(100));
1451 *sub.session_id().write() = "session-after-new".to_owned();
1453 let writer = sub.clone_writer();
1454 let line = format!("{}\n", "x".repeat(1 << 20));
1455 let _ = tokio::time::timeout(Duration::from_secs(5), writer.write_line(&line))
1456 .await;
1457 });
1458 });
1459
1460 let logged = capture.text();
1461 assert!(
1462 logged.contains("stdin_write_stalled"),
1463 "the stall's record never fired, so nothing was proved: {logged:?}",
1464 );
1465 assert!(
1466 logged.contains("session-after-new"),
1467 "the record must name the session the process carries now: {logged:?}",
1468 );
1469 assert!(
1470 logged.contains("bound_ms=100"),
1471 "the record must carry the bound it fired at: {logged:?}"
1472 );
1473 }
1474
1475 #[cfg(unix)]
1478 #[test]
1479 fn kill_group_treats_an_empty_group_as_nothing_to_report() {
1480 assert!(kill_group(2_000_000_000).is_none(), "an empty group is not a failure");
1483 }
1484
1485 #[cfg(unix)]
1488 #[tokio::test]
1489 async fn kill_group_signals_a_live_group_without_reporting() {
1490 let mut command = Command::new("/bin/sleep");
1491 command.arg("30").process_group(0);
1492 let mut child = command.spawn().expect("a child in its own group");
1493 let pid = child.id().expect("a live child has a pid");
1494
1495 assert!(kill_group(pid).is_none(), "a landed group kill is not a failure");
1496
1497 let status = child.wait().await.expect("the child is reaped");
1498 assert!(!status.success(), "the group signal killed the child: {status}");
1499 }
1500
1501 #[test]
1502 fn stderr_tail_keeps_the_last_lines_within_cap() {
1503 let mut tail = StderrTail::default();
1504 for i in 0..20_000 {
1505 tail.push(&format!("line {i}"));
1506 }
1507 let out = tail.into_string();
1508 assert!(out.len() <= STDERR_TAIL_CAP, "tail grew to {} bytes", out.len());
1509 assert!(out.contains("line 19999"), "tail dropped the newest line");
1510 assert!(!out.contains("line 0\n"), "tail kept the oldest line");
1511
1512 let mut tail = StderrTail::default();
1513 tail.push(&format!("{}REASON_AT_END", "x".repeat(STDERR_TAIL_CAP * 2)));
1514 let out = tail.into_string();
1515 assert!(out.len() <= STDERR_TAIL_CAP, "tail grew to {} bytes", out.len());
1516 assert!(!out.is_empty(), "an oversized sole line was evicted to nothing");
1517 assert!(out.ends_with("REASON_AT_END"), "kept the head of an over-cap line, not the end");
1518 }
1519
1520 #[tokio::test]
1521 async fn close_reports_drained_stderr_on_nonzero_exit() {
1522 let mut cmd = Command::new("/bin/sh");
1523 cmd.arg("-c").arg("echo 'boom: unknown flag --nope' >&2; exit 3");
1524
1525 match spawn_test_subprocess(cmd).close().await {
1526 Err(Error::Process { exit_code, stderr }) => {
1527 assert_eq!(exit_code, Some(3));
1528 assert!(
1529 stderr.contains("boom: unknown flag --nope"),
1530 "expected drained stderr in the error, got: {stderr:?}"
1531 );
1532 assert!(
1533 stderr.starts_with("last stderr from the CLI:"),
1534 "tail must be labelled, not presented as the cause: {stderr:?}"
1535 );
1536 }
1537 other => panic!("expected Process error, got {other:?}"),
1538 }
1539 }
1540
1541 #[tokio::test]
1545 async fn close_reports_stderr_past_invalid_utf8() {
1546 let mut cmd = Command::new("/bin/sh");
1547 cmd.arg("-c").arg(
1548 "printf 'early-line\\n\\377\\376bad\\n' >&2; \
1549 printf 'after-bad\\n' >&2; \
1550 printf 'THE REAL REASON' >&2; \
1551 exit 9",
1552 );
1553
1554 match spawn_test_subprocess(cmd).close().await {
1555 Err(Error::Process { exit_code, stderr }) => {
1556 assert_eq!(exit_code, Some(9));
1557 assert!(
1558 stderr.contains("after-bad"),
1559 "drain died at the invalid byte, losing the rest: {stderr:?}"
1560 );
1561 assert!(
1562 stderr.contains("THE REAL REASON"),
1563 "final line without a trailing newline was dropped: {stderr:?}"
1564 );
1565 }
1566 other => panic!("expected Process error, got {other:?}"),
1567 }
1568 }
1569}