1use std::fmt;
5use std::time::{Duration, Instant};
6
7use tokio::process::Command;
8use tracing::{Instrument, Span, debug, field, info_span};
9
10use crate::Codex;
11use crate::error::{Error, Result};
12
13pub(crate) fn command_span(name: &'static str, codex: &Codex, args: &[String]) -> Span {
19 let working_dir = codex
20 .working_dir
21 .as_ref()
22 .map_or_else(|| "(inherited)".to_string(), |p| p.display().to_string());
23
24 info_span!(
25 parent: Span::current(),
26 "codex",
27 otel.name = name,
28 subcommand = args.first().map_or("(none)", String::as_str),
29 binary = %codex.binary.display(),
30 working_dir = %working_dir,
31 outcome = field::Empty,
32 exit_code = field::Empty,
33 duration_ms = field::Empty,
34 )
35}
36
37pub(crate) struct SpanOutcome {
47 span: Span,
48 started: Instant,
49 settled: bool,
50}
51
52impl SpanOutcome {
53 pub(crate) fn start(span: Span) -> Self {
54 Self {
55 span,
56 started: Instant::now(),
57 settled: false,
58 }
59 }
60
61 pub(crate) fn settle(&mut self, outcome: &'static str, exit_code: Option<i32>) {
62 self.settled = true;
63 self.span.record("outcome", outcome);
64 self.span
65 .record("duration_ms", self.started.elapsed().as_millis() as u64);
66 if let Some(code) = exit_code {
67 self.span.record("exit_code", code);
68 }
69 }
70
71 fn settle_from_ref(&mut self, result: &Result<CommandOutput>) {
72 self.settle_from(result);
73 }
74
75 fn settle_from(&mut self, result: &Result<CommandOutput>) {
76 match result {
77 Ok(output) => self.settle("ok", Some(output.exit_code)),
78 Err(Error::Timeout { .. }) => self.settle("timeout", None),
79 Err(e) => match e.exit_code() {
82 Some(code) => self.settle("failed", Some(code)),
83 None => self.settle("error", None),
84 },
85 }
86 }
87}
88
89impl Drop for SpanOutcome {
90 fn drop(&mut self) {
91 if !self.settled {
92 self.settle("cancelled", None);
93 }
94 }
95}
96
97#[cfg(unix)]
104pub(crate) fn own_process_group(cmd: &mut Command, enabled: bool) {
105 if enabled {
106 cmd.process_group(0);
107 }
108}
109
110#[cfg(not(unix))]
111pub(crate) fn own_process_group(_cmd: &mut Command, _enabled: bool) {}
112
113#[cfg(unix)]
122pub(crate) fn signal_group(pid: u32, signal: i32) {
123 let Ok(pid) = i32::try_from(pid) else {
124 return;
125 };
126 unsafe {
129 libc::kill(-pid, signal);
130 }
131}
132
133pub(crate) struct GroupKillGuard {
140 pid: Option<u32>,
141}
142
143impl GroupKillGuard {
144 pub(crate) fn new(pid: Option<u32>) -> Self {
145 Self { pid }
146 }
147
148 pub(crate) fn disarm(&mut self) {
150 self.pid = None;
151 }
152
153 #[cfg(unix)]
157 pub(crate) async fn terminate(&mut self, grace: Duration) {
158 let Some(pid) = self.pid.take() else {
159 return;
160 };
161 signal_group(pid, libc::SIGTERM);
162 tokio::time::sleep(grace).await;
163 signal_group(pid, libc::SIGKILL);
164 }
165
166 #[cfg(not(unix))]
169 pub(crate) async fn terminate(&mut self, _grace: Duration) {
170 let _ = self.pid.take();
171 }
172}
173
174impl Drop for GroupKillGuard {
175 fn drop(&mut self) {
176 if let Some(pid) = self.pid.take() {
179 #[cfg(unix)]
180 signal_group(pid, libc::SIGKILL);
181 #[cfg(not(unix))]
182 let _ = pid;
183 }
184 }
185}
186
187#[derive(Clone)]
192pub struct CommandOutput {
193 pub stdout: String,
195 pub stderr: String,
197 pub exit_code: i32,
199 pub success: bool,
201}
202
203const DEBUG_TRUNCATE_LEN: usize = 200;
204
205fn truncate_for_debug(s: &str) -> String {
206 if s.len() > DEBUG_TRUNCATE_LEN {
207 format!("{}... ({} bytes total)", &s[..DEBUG_TRUNCATE_LEN], s.len())
208 } else {
209 s.to_string()
210 }
211}
212
213impl fmt::Debug for CommandOutput {
214 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215 f.debug_struct("CommandOutput")
216 .field("stdout", &truncate_for_debug(&self.stdout))
217 .field("stderr", &truncate_for_debug(&self.stderr))
218 .field("exit_code", &self.exit_code)
219 .field("success", &self.success)
220 .finish()
221 }
222}
223
224pub async fn run_codex(codex: &Codex, args: Vec<String>) -> Result<CommandOutput> {
230 run_codex_with_retry(codex, args, None).await
231}
232
233pub async fn run_codex_with_retry(
235 codex: &Codex,
236 args: Vec<String>,
237 retry_override: Option<&crate::retry::RetryPolicy>,
238) -> Result<CommandOutput> {
239 let policy = retry_override.or(codex.retry_policy.as_ref());
240
241 match policy {
242 Some(policy) => {
243 let span = info_span!(
246 "codex.retry",
247 subcommand = args.first().map_or("(none)", String::as_str),
248 max_attempts = policy.max_attempts,
249 );
250 crate::retry::with_retry(policy, || run_codex_once(codex, args.clone()))
251 .instrument(span)
252 .await
253 }
254 None => run_codex_once(codex, args).await,
255 }
256}
257
258pub(crate) fn assemble_args(codex: &Codex, args: Vec<String>) -> Vec<String> {
266 let mut command_args = Vec::with_capacity(codex.global_args.len() + args.len());
267 let protects_rollout_budget = args
268 .windows(2)
269 .any(|pair| pair[0] == "-c" && crate::RolloutBudgetConfig::is_config_override(&pair[1]));
270 let mut global_args = codex.global_args.iter().peekable();
271 while let Some(arg) = global_args.next() {
272 if protects_rollout_budget
273 && matches!(arg.as_str(), "--enable" | "--disable")
274 && global_args
275 .peek()
276 .is_some_and(|feature| feature.as_str() == "rollout_budget")
277 {
278 global_args.next();
279 continue;
280 }
281 if protects_rollout_budget
282 && matches!(
283 arg.as_str(),
284 "--enable=rollout_budget" | "--disable=rollout_budget"
285 )
286 {
287 continue;
288 }
289 command_args.push(arg.clone());
290 }
291 command_args.extend(args);
292 command_args
293}
294
295pub(crate) fn command_string(codex: &Codex, args: Vec<String>) -> String {
297 let mut out = shell_quote(&codex.binary.display().to_string());
298 for arg in assemble_args(codex, args) {
299 out.push(' ');
300 out.push_str(&shell_quote(&arg));
301 }
302 out
303}
304
305pub(crate) fn shell_quote(arg: &str) -> String {
311 if arg.is_empty() {
312 return "''".to_string();
313 }
314 if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}*?!~#".contains(c)) {
315 return format!("'{}'", arg.replace('\'', r"'\''"));
316 }
317 arg.to_string()
318}
319
320async fn run_codex_once(codex: &Codex, args: Vec<String>) -> Result<CommandOutput> {
321 let span = command_span("codex.exec", codex, &args);
322 let outcome_span = span.clone();
323 let command_args = assemble_args(codex, args);
324
325 async move {
326 debug!(binary = %codex.binary.display(), args = ?command_args, "executing codex command");
327
328 let mut outcome = SpanOutcome::start(outcome_span);
329 let result = match codex.timeout {
330 Some(timeout) => {
331 run_with_timeout(
332 &codex.binary,
333 &command_args,
334 &codex.env,
335 codex.clear_env,
336 codex.working_dir.as_deref(),
337 timeout,
338 codex.process_group,
339 )
340 .await
341 }
342 None => {
343 run_internal(
344 &codex.binary,
345 &command_args,
346 &codex.env,
347 codex.clear_env,
348 codex.working_dir.as_deref(),
349 codex.process_group,
350 )
351 .await
352 }
353 };
354 outcome.settle_from(&result);
355 result
356 }
357 .instrument(span)
358 .await
359}
360
361pub async fn run_codex_cancellable<C>(
377 codex: &Codex,
378 args: Vec<String>,
379 cancel: C,
380) -> Result<CommandOutput>
381where
382 C: std::future::Future<Output = ()> + Send,
383{
384 let span = command_span("codex.exec", codex, &args);
385 let outcome_span = span.clone();
386 let command_args = assemble_args(codex, args);
387
388 async move {
389 debug!(binary = %codex.binary.display(), args = ?command_args, "executing cancellable codex command");
390
391 let mut outcome = SpanOutcome::start(outcome_span);
392 let result = run_internal_inner(
393 SpawnSpec {
394 binary: &codex.binary,
395 args: &command_args,
396 env: &codex.env,
397 clear_env: codex.clear_env,
398 working_dir: codex.working_dir.as_deref(),
399 stdin_prompt: None,
400 process_group: codex.process_group,
401 },
402 Some(Box::pin(cancel)),
403 codex.termination_grace,
404 )
405 .await;
406
407 match &result {
408 Err(Error::Cancelled { .. }) => outcome.settle("cancelled", None),
409 other => outcome.settle_from_ref(other),
410 }
411 result
412 }
413 .instrument(span)
414 .await
415}
416
417pub async fn run_codex_allow_exit_codes(
419 codex: &Codex,
420 args: Vec<String>,
421 allowed_codes: &[i32],
422) -> Result<CommandOutput> {
423 let output = run_codex(codex, args).await;
424
425 match output {
426 Err(e)
430 if e.exit_code()
431 .is_some_and(|code| allowed_codes.contains(&code)) =>
432 {
433 let exit_code = e.exit_code().unwrap_or(-1);
434 let (stdout, stderr) = match &e {
435 Error::CommandFailed { stdout, stderr, .. } => (stdout.clone(), stderr.clone()),
436 Error::Auth { message, .. }
437 | Error::Config { message, .. }
438 | Error::NotTrustedDirectory { message, .. }
439 | Error::SessionNotFound { message, .. } => (String::new(), message.clone()),
440 _ => (String::new(), String::new()),
441 };
442 Ok(CommandOutput {
443 stdout,
444 stderr,
445 exit_code,
446 success: false,
447 })
448 }
449 other => other,
450 }
451}
452
453pub async fn run_codex_with_stdin_prompt(
465 codex: &Codex,
466 args: Vec<String>,
467 prompt: &str,
468) -> Result<CommandOutput> {
469 let span = command_span("codex.exec", codex, &args);
470 let outcome_span = span.clone();
471 let command_args = assemble_args(codex, args);
472
473 async move {
474 debug!(
475 binary = %codex.binary.display(),
476 args = ?command_args,
477 prompt_bytes = prompt.len(),
478 "executing codex command with a stdin prompt"
479 );
480
481 let mut outcome = SpanOutcome::start(outcome_span);
482 let run = run_internal_inner(
483 SpawnSpec {
484 binary: &codex.binary,
485 args: &command_args,
486 env: &codex.env,
487 clear_env: codex.clear_env,
488 working_dir: codex.working_dir.as_deref(),
489 stdin_prompt: Some(prompt),
490 process_group: codex.process_group,
491 },
492 None,
493 Duration::from_secs(0),
494 );
495
496 let result = match codex.timeout {
497 Some(timeout) => match tokio::time::timeout(timeout, run).await {
498 Ok(result) => result,
499 Err(_) => Err(Error::Timeout {
500 timeout_seconds: timeout.as_secs(),
501 }),
502 },
503 None => run.await,
504 };
505 outcome.settle_from(&result);
506 result
507 }
508 .instrument(span)
509 .await
510}
511
512async fn run_internal(
513 binary: &std::path::Path,
514 args: &[String],
515 env: &std::collections::HashMap<String, String>,
516 clear_env: bool,
517 working_dir: Option<&std::path::Path>,
518 process_group: bool,
519) -> Result<CommandOutput> {
520 run_internal_inner(
521 SpawnSpec {
522 binary,
523 args,
524 env,
525 clear_env,
526 working_dir,
527 stdin_prompt: None,
528 process_group,
529 },
530 None,
531 Duration::from_secs(0),
532 )
533 .await
534}
535
536type CancelFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>>;
537
538struct SpawnSpec<'a> {
540 binary: &'a std::path::Path,
541 args: &'a [String],
542 env: &'a std::collections::HashMap<String, String>,
543 clear_env: bool,
545 working_dir: Option<&'a std::path::Path>,
546 stdin_prompt: Option<&'a str>,
548 process_group: bool,
550}
551
552async fn run_internal_inner(
553 spec: SpawnSpec<'_>,
554 cancel: Option<CancelFuture<'_>>,
555 grace: Duration,
556) -> Result<CommandOutput> {
557 let SpawnSpec {
558 binary,
559 args,
560 env,
561 clear_env,
562 working_dir,
563 stdin_prompt,
564 process_group,
565 } = spec;
566 let mut cmd = Command::new(binary);
567 cmd.args(args);
568
569 if stdin_prompt.is_some() {
572 cmd.stdin(std::process::Stdio::piped());
573 } else {
574 cmd.stdin(std::process::Stdio::null());
575 }
576
577 cmd.kill_on_drop(true);
581 own_process_group(&mut cmd, process_group);
582
583 if let Some(dir) = working_dir {
584 cmd.current_dir(dir);
585 }
586
587 apply_child_environment(&mut cmd, clear_env, env);
588
589 cmd.stdout(std::process::Stdio::piped());
593 cmd.stderr(std::process::Stdio::piped());
594
595 let mut child = cmd.spawn().map_err(|e| Error::Io {
596 message: format!("failed to spawn codex: {e}"),
597 source: e,
598 working_dir: working_dir.map(|p| p.to_path_buf()),
599 })?;
600
601 let mut group = GroupKillGuard::new(process_group.then(|| child.id()).flatten());
606 let child_stdin = child.stdin.take();
607
608 let write = async move {
609 let (Some(prompt), Some(mut stdin)) = (stdin_prompt, child_stdin) else {
610 return Ok(());
611 };
612 use tokio::io::AsyncWriteExt;
613 stdin.write_all(prompt.as_bytes()).await?;
614 stdin.shutdown().await
617 };
618
619 let run = async { tokio::join!(write, child.wait_with_output()) };
624
625 let finished = match cancel {
626 None => Some(run.await),
627 Some(cancel) => tokio::select! {
631 outcome = run => Some(outcome),
632 () = cancel => None,
633 },
634 };
635
636 let Some((write_result, output_result)) = finished else {
637 group.terminate(grace).await;
638 return Err(Error::Cancelled {
639 grace_seconds: grace.as_secs(),
640 });
641 };
642
643 write_result.map_err(|e| Error::Io {
644 message: format!("failed to write the prompt to codex stdin: {e}"),
645 source: e,
646 working_dir: working_dir.map(|p| p.to_path_buf()),
647 })?;
648 let output = output_result.map_err(|e| Error::Io {
649 message: format!("failed to wait on codex: {e}"),
650 source: e,
651 working_dir: working_dir.map(|p| p.to_path_buf()),
652 })?;
653
654 group.disarm();
656
657 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
658 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
659 let exit_code = output.status.code().unwrap_or(-1);
660
661 if !output.status.success() {
662 return Err(Error::from_command_failure(
663 format!("{} {}", binary.display(), args.join(" ")),
664 exit_code,
665 stdout,
666 stderr,
667 working_dir.map(|p| p.to_path_buf()),
668 ));
669 }
670
671 Ok(CommandOutput {
672 stdout,
673 stderr,
674 exit_code,
675 success: true,
676 })
677}
678
679pub(crate) fn apply_child_environment(
684 cmd: &mut Command,
685 clear_env: bool,
686 env: &std::collections::HashMap<String, String>,
687) {
688 if clear_env {
689 cmd.env_clear();
690 }
691 cmd.envs(env);
692}
693
694async fn run_with_timeout(
695 binary: &std::path::Path,
696 args: &[String],
697 env: &std::collections::HashMap<String, String>,
698 clear_env: bool,
699 working_dir: Option<&std::path::Path>,
700 timeout: Duration,
701 process_group: bool,
702) -> Result<CommandOutput> {
703 tokio::time::timeout(
704 timeout,
705 run_internal(binary, args, env, clear_env, working_dir, process_group),
706 )
707 .await
708 .map_err(|_| Error::Timeout {
709 timeout_seconds: timeout.as_secs(),
710 })?
711}
712
713#[cfg(test)]
714mod tests {
715 use super::*;
716 use crate::CodexCommand;
717
718 #[test]
719 fn typed_rollout_budget_suppresses_conflicting_client_global_toggles() {
720 let codex = Codex::builder()
721 .binary("/bin/echo")
722 .config("features.rollout_budget=false")
723 .enable("rollout_budget")
724 .disable("rollout_budget")
725 .arg("--enable=rollout_budget")
726 .arg("--disable=rollout_budget")
727 .arg("--disable")
728 .arg("rollout_budget")
729 .enable("keep-enabled")
730 .disable("keep-disabled")
731 .build()
732 .expect("echo must exist");
733 let budget = crate::RolloutBudgetConfig::builder(10_000)
734 .build()
735 .expect("valid budget");
736 let expected = budget.config_override();
737 let opening = crate::ExecCommand::new("hi")
738 .rollout_budget(budget.clone())
739 .args();
740 let resumed = crate::ExecResumeCommand::new()
741 .session_id("thread")
742 .rollout_budget(budget)
743 .args();
744
745 for args in [opening, resumed] {
746 let assembled = assemble_args(&codex, args);
747 assert!(assembled.iter().any(|arg| arg == &expected));
748 assert!(
749 !assembled.windows(2).any(|pair| {
750 matches!(pair[0].as_str(), "--enable" | "--disable")
751 && pair[1] == "rollout_budget"
752 }),
753 "typed budget must suppress paired client toggles: {assembled:?}"
754 );
755 assert!(
756 !assembled.iter().any(|arg| {
757 matches!(
758 arg.as_str(),
759 "--enable=rollout_budget" | "--disable=rollout_budget"
760 )
761 }),
762 "typed budget must suppress equals-form client toggles: {assembled:?}"
763 );
764 assert!(
765 assembled
766 .windows(2)
767 .any(|pair| pair == ["--enable", "keep-enabled"])
768 );
769 assert!(
770 assembled
771 .windows(2)
772 .any(|pair| pair == ["--disable", "keep-disabled"])
773 );
774 }
775 }
776
777 fn make_output(stdout: &str, stderr: &str) -> CommandOutput {
778 CommandOutput {
779 stdout: stdout.to_string(),
780 stderr: stderr.to_string(),
781 exit_code: 0,
782 success: true,
783 }
784 }
785
786 #[test]
787 fn shell_quote_leaves_plain_words_alone() {
788 assert_eq!(shell_quote("exec"), "exec");
789 assert_eq!(shell_quote("--ephemeral"), "--ephemeral");
790 assert_eq!(shell_quote("model=gpt-5"), "model=gpt-5");
791 }
792
793 #[test]
794 fn shell_quote_wraps_anything_a_shell_would_read() {
795 assert_eq!(shell_quote("fix the tests"), "'fix the tests'");
796 assert_eq!(shell_quote("$HOME"), "'$HOME'");
797 assert_eq!(shell_quote("a;b"), "'a;b'");
798 assert_eq!(shell_quote("*.rs"), "'*.rs'");
799 assert_eq!(shell_quote("it's"), r"'it'\''s'");
800 }
801
802 #[test]
805 fn shell_quote_keeps_the_empty_argument_visible() {
806 assert_eq!(shell_quote(""), "''");
807 }
808
809 #[test]
810 fn debug_short_output_not_truncated() {
811 let output = make_output("hello", "world");
812 let debug = format!("{output:?}");
813 assert!(debug.contains("hello"));
814 assert!(debug.contains("world"));
815 assert!(!debug.contains("bytes total"));
816 }
817
818 #[test]
819 fn debug_long_output_truncated() {
820 let long = "x".repeat(300);
821 let output = make_output(&long, &long);
822 let debug = format!("{output:?}");
823 assert!(debug.contains("... (300 bytes total)"));
824 assert!(!debug.contains(&long));
825 }
826
827 #[cfg(unix)]
830 #[tokio::test]
831 async fn child_environment_is_inherited_by_default() {
832 let capture = crate::test_support::EnvCapture::new("env-default");
833 let codex = crate::test_support::env_capturing_codex(&capture)
834 .build()
835 .expect("bash must exist");
836
837 crate::ExecCommand::new("probe")
838 .execute(&codex)
839 .await
840 .unwrap();
841
842 let environment = capture.read();
843 assert_eq!(
844 environment.get("PATH"),
845 Some(&std::env::var("PATH").expect("test process must have PATH"))
846 );
847 }
848
849 #[cfg(unix)]
853 #[tokio::test]
854 async fn cleared_environment_reaches_buffered_open_and_resume() {
855 let capture = crate::test_support::EnvCapture::new("env-buffered");
856 let opening_client = crate::test_support::env_capturing_codex(&capture)
857 .clear_env()
858 .env("CODEX_WRAPPER_EXPLICIT", "opening")
859 .timeout(Duration::from_secs(2))
860 .build()
861 .expect("bash must exist");
862
863 crate::ExecCommand::new("probe")
864 .execute(&opening_client)
865 .await
866 .unwrap();
867 let opening_environment = capture.read();
868 assert!(!opening_environment.contains_key("PATH"));
869 assert_eq!(
870 opening_environment
871 .get("CODEX_WRAPPER_EXPLICIT")
872 .map(String::as_str),
873 Some("opening")
874 );
875 assert!(opening_environment.contains_key("CODEX_WRAPPER_ENV_CAPTURE"));
876
877 let resume_client = crate::test_support::env_capturing_codex(&capture)
878 .clear_env()
879 .env("CODEX_WRAPPER_EXPLICIT", "resume")
880 .build()
881 .expect("bash must exist");
882 crate::ExecResumeCommand::new()
883 .last()
884 .execute(&resume_client)
885 .await
886 .unwrap();
887 let resume_environment = capture.read();
888 assert!(!resume_environment.contains_key("PATH"));
889 assert_eq!(
890 resume_environment
891 .get("CODEX_WRAPPER_EXPLICIT")
892 .map(String::as_str),
893 Some("resume")
894 );
895 }
896
897 #[cfg(unix)]
901 #[tokio::test]
902 async fn cleared_environment_reaches_stdin_and_cancellable_runs() {
903 let capture = crate::test_support::EnvCapture::new("env-specialized");
904 let codex = crate::test_support::env_capturing_codex(&capture)
905 .env("CODEX_WRAPPER_EXPLICIT", "specialized")
906 .clear_env()
907 .build()
908 .expect("bash must exist");
909
910 crate::ExecCommand::new("stdin prompt")
911 .prompt_via_stdin()
912 .execute(&codex)
913 .await
914 .unwrap();
915 let stdin_environment = capture.read();
916 assert!(!stdin_environment.contains_key("PATH"));
917 assert_eq!(
918 stdin_environment
919 .get("CODEX_WRAPPER_EXPLICIT")
920 .map(String::as_str),
921 Some("specialized")
922 );
923
924 let never = std::future::pending::<()>();
925 run_codex_cancellable(&codex, crate::ExecCommand::new("cancellable").args(), never)
926 .await
927 .unwrap();
928 let cancellable_environment = capture.read();
929 assert!(!cancellable_environment.contains_key("PATH"));
930 assert_eq!(
931 cancellable_environment
932 .get("CODEX_WRAPPER_EXPLICIT")
933 .map(String::as_str),
934 Some("specialized")
935 );
936 }
937
938 #[cfg(unix)]
939 #[tokio::test]
940 async fn environment_values_do_not_leak_into_spawn_errors() {
941 let secret = "spawn-error-must-not-leak-this";
942 let codex = Codex::builder()
943 .binary("/codex-wrapper/this-binary-does-not-exist")
944 .clear_env()
945 .env("CODEX_WRAPPER_SECRET", secret)
946 .build()
947 .unwrap();
948
949 let error = run_codex(&codex, vec!["exec".into()])
950 .await
951 .expect_err("the fake path must not spawn");
952 assert!(!error.to_string().contains(secret));
953 assert!(!format!("{error:?}").contains(secret));
954 }
955
956 #[cfg(unix)]
960 #[tokio::test]
961 async fn timeout_kills_the_spawned_process() {
962 use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
963
964 let pid_file = PidFile::new("exec-timeout");
965 let codex = blocking_codex(&pid_file)
966 .timeout(Duration::from_millis(500))
967 .build()
968 .expect("bash must exist");
969
970 let result = run_codex(&codex, vec!["exec".into(), "probe".into()]).await;
971 assert!(
972 matches!(result, Err(Error::Timeout { .. })),
973 "expected timeout error, got: {result:?}"
974 );
975
976 let pid = pid_file.read_pid().await;
977 assert!(
978 wait_until_gone(pid).await,
979 "codex ({pid}) survived the timeout"
980 );
981 }
982
983 #[cfg(unix)]
986 #[tokio::test]
987 async fn cancellation_kills_the_spawned_process() {
988 use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
989
990 let pid_file = PidFile::new("exec-cancel");
991 let codex = blocking_codex(&pid_file).build().expect("bash must exist");
992
993 let cancelled = tokio::time::timeout(
994 Duration::from_millis(500),
995 run_codex(&codex, vec!["exec".into(), "probe".into()]),
996 )
997 .await;
998 assert!(
999 cancelled.is_err(),
1000 "fake codex should still have been running, got: {cancelled:?}"
1001 );
1002
1003 let pid = pid_file.read_pid().await;
1004 assert!(
1005 wait_until_gone(pid).await,
1006 "codex ({pid}) survived the dropped future"
1007 );
1008 }
1009
1010 #[cfg(unix)]
1021 mod recorder {
1022 use std::cell::RefCell;
1023 use std::sync::{Arc, Mutex, Once};
1024
1025 use tracing::field::{Field, Visit};
1026 use tracing::span::{Attributes, Id, Record};
1027 use tracing::{Event, Metadata, Subscriber};
1028
1029 type Sink = Arc<Mutex<Vec<(String, String)>>>;
1030
1031 thread_local! {
1032 static SINK: RefCell<Option<Sink>> = const { RefCell::new(None) };
1033 }
1034
1035 struct Global;
1036
1037 impl Global {
1038 fn collect(f: impl FnOnce(&mut Vec<(String, String)>)) {
1039 SINK.with(|sink| {
1040 if let Some(sink) = sink.borrow().as_ref() {
1041 f(&mut sink.lock().unwrap());
1042 }
1043 });
1044 }
1045 }
1046
1047 impl Subscriber for Global {
1048 fn enabled(&self, _: &Metadata<'_>) -> bool {
1050 true
1051 }
1052 fn new_span(&self, attrs: &Attributes<'_>) -> Id {
1053 Self::collect(|fields| attrs.record(&mut Collect(fields)));
1054 Id::from_u64(1)
1055 }
1056 fn record(&self, _: &Id, values: &Record<'_>) {
1057 Self::collect(|fields| values.record(&mut Collect(fields)));
1058 }
1059 fn record_follows_from(&self, _: &Id, _: &Id) {}
1060 fn event(&self, _: &Event<'_>) {}
1061 fn enter(&self, _: &Id) {}
1062 fn exit(&self, _: &Id) {}
1063 }
1064
1065 struct Collect<'a>(&'a mut Vec<(String, String)>);
1066
1067 impl Visit for Collect<'_> {
1068 fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
1069 self.0.push((field.name().into(), format!("{value:?}")));
1070 }
1071 fn record_str(&mut self, field: &Field, value: &str) {
1072 self.0.push((field.name().into(), value.into()));
1073 }
1074 fn record_i64(&mut self, field: &Field, value: i64) {
1075 self.0.push((field.name().into(), value.to_string()));
1076 }
1077 fn record_u64(&mut self, field: &Field, value: u64) {
1078 self.0.push((field.name().into(), value.to_string()));
1079 }
1080 }
1081
1082 pub(super) struct Recorder(Sink);
1083
1084 impl Recorder {
1085 pub(super) fn install() -> Self {
1087 static INIT: Once = Once::new();
1088 INIT.call_once(|| {
1089 let _ = tracing::subscriber::set_global_default(Global);
1090 });
1091 let sink: Sink = Arc::new(Mutex::new(Vec::new()));
1092 SINK.with(|slot| *slot.borrow_mut() = Some(Arc::clone(&sink)));
1093 Self(sink)
1094 }
1095
1096 pub(super) fn dump(&self) -> String {
1097 format!("{:?}", self.0.lock().unwrap())
1098 }
1099
1100 pub(super) fn value(&self, field: &str) -> Option<String> {
1101 self.0
1102 .lock()
1103 .unwrap()
1104 .iter()
1105 .rev()
1106 .find(|(name, _)| name == field)
1107 .map(|(_, value)| value.clone())
1108 }
1109 }
1110
1111 impl Drop for Recorder {
1112 fn drop(&mut self) {
1113 SINK.with(|slot| *slot.borrow_mut() = None);
1114 }
1115 }
1116 }
1117
1118 #[cfg(unix)]
1119 #[tokio::test]
1120 async fn span_records_the_subcommand_and_a_clean_outcome() {
1121 let recorder = recorder::Recorder::install();
1122
1123 let codex = Codex::builder()
1124 .binary("/bin/echo")
1125 .build()
1126 .expect("echo must exist");
1127 run_codex(&codex, vec!["exec".into()]).await.unwrap();
1128
1129 assert_eq!(recorder.value("subcommand").as_deref(), Some("exec"));
1130 assert_eq!(recorder.value("outcome").as_deref(), Some("ok"));
1131 assert_eq!(recorder.value("exit_code").as_deref(), Some("0"));
1132 assert!(recorder.value("duration_ms").is_some());
1133 }
1134
1135 #[cfg(unix)]
1138 #[tokio::test]
1139 async fn span_does_not_carry_the_prompt() {
1140 let recorder = recorder::Recorder::install();
1141
1142 let codex = Codex::builder()
1143 .binary("/bin/echo")
1144 .build()
1145 .expect("echo must exist");
1146 run_codex(&codex, vec!["exec".into(), "a very secret prompt".into()])
1147 .await
1148 .unwrap();
1149
1150 let recorded = format!("{:?}", recorder.value("subcommand"));
1151 assert!(!recorded.contains("secret"));
1152 for field in ["binary", "working_dir", "outcome", "exit_code"] {
1153 let value = recorder.value(field).unwrap_or_default();
1154 assert!(
1155 !value.contains("secret"),
1156 "{field} leaked the prompt: {value}"
1157 );
1158 }
1159 }
1160
1161 #[cfg(unix)]
1164 #[tokio::test]
1165 async fn a_cancelled_run_is_recorded_as_cancelled() {
1166 let recorder = recorder::Recorder::install();
1167
1168 let pid_file = crate::test_support::PidFile::new("span-cancel");
1169 let codex = crate::test_support::blocking_codex(&pid_file)
1170 .build()
1171 .expect("bash must exist");
1172
1173 let cancelled = tokio::time::timeout(
1174 Duration::from_millis(300),
1175 run_codex(&codex, vec!["exec".into()]),
1176 )
1177 .await;
1178 assert!(cancelled.is_err(), "the run should still have been going");
1179
1180 assert_eq!(
1181 recorder.value("outcome").as_deref(),
1182 Some("cancelled"),
1183 "recorded: {}",
1184 recorder.dump()
1185 );
1186 }
1187
1188 #[cfg(unix)]
1191 #[tokio::test]
1192 async fn a_timed_out_run_is_recorded_as_timeout() {
1193 let recorder = recorder::Recorder::install();
1194
1195 let pid_file = crate::test_support::PidFile::new("span-timeout");
1196 let codex = crate::test_support::blocking_codex(&pid_file)
1197 .timeout(Duration::from_millis(300))
1198 .build()
1199 .expect("bash must exist");
1200
1201 let result = run_codex(&codex, vec!["exec".into()]).await;
1202 assert!(matches!(result, Err(Error::Timeout { .. })), "{result:?}");
1203
1204 assert_eq!(recorder.value("outcome").as_deref(), Some("timeout"));
1205 }
1206
1207 #[cfg(unix)]
1212 fn failing_codex(case: &str) -> Codex {
1213 let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1214 .join("tests")
1215 .join("fake-codex-failure.sh");
1216 Codex::builder()
1217 .binary("/bin/bash")
1218 .arg(script.to_str().unwrap())
1219 .env("CODEX_WRAPPER_TEST_FAILURE", case)
1220 .build()
1221 .expect("bash must exist")
1222 }
1223
1224 #[cfg(unix)]
1227 #[tokio::test]
1228 async fn a_real_spawn_returns_a_classified_error() {
1229 use crate::error::FailureKind;
1230
1231 for (case, expected) in [
1232 ("auth", FailureKind::Auth),
1233 ("not-trusted", FailureKind::NotTrustedDirectory),
1234 ("config", FailureKind::Config),
1235 ("session", FailureKind::SessionNotFound),
1236 ("mystery", FailureKind::Unclassified),
1237 ] {
1238 let codex = failing_codex(case);
1239 let err = run_codex(&codex, vec!["exec".into()]).await.unwrap_err();
1240 assert_eq!(err.failure_kind(), Some(expected), "case {case}: {err}");
1241 }
1242 }
1243
1244 #[cfg(unix)]
1248 #[tokio::test]
1249 async fn a_classified_failure_is_not_retried() {
1250 let policy = crate::retry::RetryPolicy::new()
1251 .max_attempts(3)
1252 .initial_backoff(Duration::from_millis(1))
1253 .retry_on_exit_codes([1]);
1254
1255 let started = Instant::now();
1256 let codex = failing_codex("auth");
1257 let err = run_codex_with_retry(&codex, vec!["exec".into()], Some(&policy))
1258 .await
1259 .unwrap_err();
1260
1261 assert!(matches!(err, Error::Auth { .. }), "{err}");
1262 assert!(
1265 started.elapsed() < Duration::from_secs(2),
1266 "looks like it retried: {:?}",
1267 started.elapsed()
1268 );
1269 }
1270
1271 #[cfg(unix)]
1273 #[tokio::test]
1274 async fn an_unclassified_failure_still_retries() {
1275 let policy = crate::retry::RetryPolicy::new()
1276 .max_attempts(2)
1277 .initial_backoff(Duration::from_millis(1))
1278 .retry_on_exit_codes([1]);
1279
1280 let codex = failing_codex("mystery");
1281 let err = run_codex_with_retry(&codex, vec!["exec".into()], Some(&policy))
1282 .await
1283 .unwrap_err();
1284
1285 assert!(matches!(err, Error::CommandFailed { .. }), "{err}");
1286 }
1287
1288 #[cfg(unix)]
1291 #[tokio::test]
1292 async fn allowed_exit_codes_still_apply_to_a_classified_failure() {
1293 let codex = failing_codex("auth");
1294 let output = run_codex_allow_exit_codes(&codex, vec!["exec".into()], &[1])
1295 .await
1296 .expect("exit code 1 was allowed");
1297
1298 assert_eq!(output.exit_code, 1);
1299 assert!(!output.success);
1300 assert!(
1301 output.stderr.contains("401 Unauthorized"),
1302 "{}",
1303 output.stderr
1304 );
1305 }
1306
1307 #[cfg(unix)]
1314 fn spawning_codex(label: &str) -> (Codex, crate::test_support::PidFile) {
1315 let pid_file = crate::test_support::PidFile::new(label);
1316 let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1317 .join("tests")
1318 .join("fake-codex-spawns-child.sh");
1319 let codex = Codex::builder()
1320 .binary("/bin/bash")
1321 .arg(script.to_str().unwrap())
1322 .env(
1323 "CODEX_WRAPPER_TEST_PIDFILE",
1324 pid_file.path().to_str().unwrap(),
1325 )
1326 .build()
1327 .expect("bash must exist");
1328 (codex, pid_file)
1329 }
1330
1331 #[cfg(unix)]
1332 async fn read_pids(pid_file: &crate::test_support::PidFile) -> (u32, u32) {
1333 for _ in 0..200 {
1334 if let Ok(contents) = std::fs::read_to_string(pid_file.path()) {
1335 let parse = |prefix: &str| -> Option<u32> {
1336 contents
1337 .lines()
1338 .find_map(|l| l.strip_prefix(prefix))
1339 .and_then(|v| v.trim().parse().ok())
1340 };
1341 if let (Some(parent), Some(child)) = (parse("parent="), parse("child=")) {
1342 return (parent, child);
1343 }
1344 }
1345 tokio::time::sleep(Duration::from_millis(10)).await;
1346 }
1347 panic!("the fake codex never recorded both pids");
1348 }
1349
1350 #[cfg(unix)]
1353 #[tokio::test]
1354 async fn cancelling_kills_the_whole_process_group() {
1355 use crate::test_support::wait_until_gone;
1356
1357 let (codex, pid_file) = spawning_codex("group-drop");
1358
1359 let cancelled = tokio::time::timeout(
1360 Duration::from_millis(400),
1361 run_codex(&codex, vec!["exec".into()]),
1362 )
1363 .await;
1364 assert!(cancelled.is_err(), "the run should still have been going");
1365
1366 let (parent, child) = read_pids(&pid_file).await;
1367 assert!(wait_until_gone(parent).await, "codex ({parent}) survived");
1368 assert!(
1369 wait_until_gone(child).await,
1370 "the subprocess ({child}) survived the cancelled run"
1371 );
1372 }
1373
1374 #[cfg(unix)]
1377 #[tokio::test]
1378 async fn run_codex_cancellable_stops_the_group_gracefully() {
1379 use crate::test_support::wait_until_gone;
1380
1381 let (codex, pid_file) = spawning_codex("group-cancel");
1382 let codex = Codex::builder()
1383 .binary(codex.binary())
1384 .arg(
1385 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1386 .join("tests")
1387 .join("fake-codex-spawns-child.sh")
1388 .to_str()
1389 .unwrap(),
1390 )
1391 .env(
1392 "CODEX_WRAPPER_TEST_PIDFILE",
1393 pid_file.path().to_str().unwrap(),
1394 )
1395 .termination_grace(Duration::from_millis(50))
1396 .build()
1397 .unwrap();
1398
1399 let cancel = async {
1400 tokio::time::sleep(Duration::from_millis(300)).await;
1401 };
1402 let result = run_codex_cancellable(&codex, vec!["exec".into()], cancel).await;
1403
1404 assert!(
1405 matches!(result, Err(Error::Cancelled { .. })),
1406 "expected a cancellation, got: {result:?}"
1407 );
1408
1409 let (parent, child) = read_pids(&pid_file).await;
1410 assert!(wait_until_gone(parent).await, "codex ({parent}) survived");
1411 assert!(
1412 wait_until_gone(child).await,
1413 "the subprocess ({child}) survived cancellation"
1414 );
1415 }
1416
1417 #[cfg(unix)]
1420 #[tokio::test]
1421 async fn a_run_that_finishes_first_is_not_cancelled() {
1422 let codex = Codex::builder()
1423 .binary("/bin/echo")
1424 .build()
1425 .expect("echo must exist");
1426
1427 let never = std::future::pending::<()>();
1428 let output = run_codex_cancellable(&codex, vec!["exec".into()], never)
1429 .await
1430 .unwrap();
1431 assert!(output.success);
1432 }
1433
1434 #[cfg(unix)]
1439 #[tokio::test]
1440 async fn opting_out_of_process_groups_leaves_the_subprocess() {
1441 use crate::test_support::wait_until_gone;
1442
1443 let pid_file = crate::test_support::PidFile::new("group-optout");
1444 let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1445 .join("tests")
1446 .join("fake-codex-spawns-child.sh");
1447 let codex = Codex::builder()
1448 .binary("/bin/bash")
1449 .arg(script.to_str().unwrap())
1450 .env(
1451 "CODEX_WRAPPER_TEST_PIDFILE",
1452 pid_file.path().to_str().unwrap(),
1453 )
1454 .process_group(false)
1455 .build()
1456 .expect("bash must exist");
1457
1458 let cancelled = tokio::time::timeout(
1459 Duration::from_millis(400),
1460 run_codex(&codex, vec!["exec".into()]),
1461 )
1462 .await;
1463 assert!(cancelled.is_err(), "the run should still have been going");
1464
1465 let (parent, child) = read_pids(&pid_file).await;
1466 assert!(
1467 wait_until_gone(parent).await,
1468 "kill_on_drop still reaps the direct child ({parent})"
1469 );
1470 assert!(
1473 crate::test_support::is_running_for_test(child),
1474 "with groups off, the subprocess ({child}) is expected to survive"
1475 );
1476 signal_group(child, libc::SIGKILL);
1478 unsafe { libc::kill(i32::try_from(child).unwrap_or(0), libc::SIGKILL) };
1479 }
1480}