Skip to main content

codex_wrapper/
exec.rs

1//! Process execution layer for spawning and communicating with the `codex`
2//! binary, including timeout and retry support.
3
4use 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
13/// Build the span covering one invocation.
14///
15/// The fields carry what identifies a run, never what it contains: no prompt
16/// and no environment. Both routinely hold content a host would not want in
17/// its logs, and a span is recorded whenever any subscriber is installed.
18pub(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
37/// Records how a run ended on its span, including when it does not end at all.
38///
39/// The dropped-future case is the one worth the machinery. Cancellation kills
40/// the process and runs nothing else, so without this the span would close
41/// with no outcome and an abandoned run would be indistinguishable from one
42/// still in progress. The span is passed in and held rather than read from
43/// [`Span::current`]: current-span tracking is the subscriber's job, and a
44/// subscriber that does not implement it would silently drop every record,
45/// including the drop-time one this exists for.
46pub(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            // Covers the classified failures too, which are no longer
80            // CommandFailed but did still come from a process exit.
81            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/// Put the child in its own process group, so the whole run can be signalled
98/// as a unit.
99///
100/// `kill_on_drop` reaps the direct child only, and codex spawns its own
101/// subprocesses for tool use. Without a group of its own, cancelling leaves
102/// those running (#78).
103#[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/// Signal a process group, given the group leader's pid.
114///
115/// Unix only: there is no non-unix counterpart, because every caller of this
116/// is itself gated on unix. A stub would only be dead code.
117///
118/// Negating the pid is what makes this reach the group rather than the leader
119/// alone. Errors are ignored: the only interesting failure is that the group
120/// is already gone, which is the desired state.
121#[cfg(unix)]
122pub(crate) fn signal_group(pid: u32, signal: i32) {
123    let Ok(pid) = i32::try_from(pid) else {
124        return;
125    };
126    // SAFETY: `kill` with a negative pid signals a process group. Passing a
127    // pid the OS has already reaped is defined and simply fails.
128    unsafe {
129        libc::kill(-pid, signal);
130    }
131}
132
133/// SIGKILLs the run's process group when dropped.
134///
135/// `kill_on_drop` handles the direct child; this is what reaches the
136/// subprocesses codex started. Drop cannot await, so this is the abrupt path.
137/// For a graceful one, see
138/// [`ExecCommand::execute_cancellable`](crate::ExecCommand::execute_cancellable).
139pub(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    /// Stop killing on drop, once the run has finished on its own.
149    pub(crate) fn disarm(&mut self) {
150        self.pid = None;
151    }
152
153    /// Ask the group to stop, then insist after `grace`.
154    ///
155    /// Async, so it can wait, which is why it cannot live in `Drop`.
156    #[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    /// No process groups here, so there is nothing to ask politely. The
167    /// child still dies with the dropped future via `kill_on_drop`.
168    #[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        // Taken unconditionally so the field is read on every platform, not
177        // just the one that can act on it.
178        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/// Raw output from a Codex CLI invocation.
188///
189/// Contains captured stdout/stderr, the process exit code, and a convenience
190/// `success` flag.
191#[derive(Clone)]
192pub struct CommandOutput {
193    /// Standard output as a UTF-8 string.
194    pub stdout: String,
195    /// Standard error as a UTF-8 string.
196    pub stderr: String,
197    /// Process exit code (`-1` if the process was killed by a signal).
198    pub exit_code: i32,
199    /// `true` when the process exited with code 0.
200    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
224/// Run a codex command with the given arguments.
225///
226/// If the [`Codex`] client has a retry policy set, transient errors will be
227/// retried according to that policy. A per-command retry policy can be passed
228/// to override the client default.
229pub async fn run_codex(codex: &Codex, args: Vec<String>) -> Result<CommandOutput> {
230    run_codex_with_retry(codex, args, None).await
231}
232
233/// Run a codex command with an optional per-command retry policy override.
234pub 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            // A parent span so the retry events and each attempt's own span
244            // nest under one run rather than arriving as unrelated lines.
245            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
258/// Assemble the full argument list for an invocation: the client's global
259/// args first, since they precede the subcommand, then the command's own.
260///
261/// Both spawn paths and [`CodexCommand::to_command_string`] go through here,
262/// so a previewed command cannot drift from the one that actually runs.
263///
264/// [`CodexCommand::to_command_string`]: crate::command::CodexCommand::to_command_string
265pub(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    command_args.extend(codex.global_args.iter().cloned());
268    command_args.extend(args);
269    command_args
270}
271
272/// Render an invocation as a copy-pasteable shell command.
273pub(crate) fn command_string(codex: &Codex, args: Vec<String>) -> String {
274    let mut out = shell_quote(&codex.binary.display().to_string());
275    for arg in assemble_args(codex, args) {
276        out.push(' ');
277        out.push_str(&shell_quote(&arg));
278    }
279    out
280}
281
282/// Quote a single argument for a POSIX shell, if it needs it.
283///
284/// The empty string is quoted: unquoted it would vanish from the rendered
285/// command, turning a preview into something that runs differently from what
286/// it describes.
287pub(crate) fn shell_quote(arg: &str) -> String {
288    if arg.is_empty() {
289        return "''".to_string();
290    }
291    if arg.contains(|c: char| c.is_whitespace() || "\"'$\\`|;<>&()[]{}*?!~#".contains(c)) {
292        return format!("'{}'", arg.replace('\'', r"'\''"));
293    }
294    arg.to_string()
295}
296
297async fn run_codex_once(codex: &Codex, args: Vec<String>) -> Result<CommandOutput> {
298    let span = command_span("codex.exec", codex, &args);
299    let outcome_span = span.clone();
300    let command_args = assemble_args(codex, args);
301
302    async move {
303        debug!(binary = %codex.binary.display(), args = ?command_args, "executing codex command");
304
305        let mut outcome = SpanOutcome::start(outcome_span);
306        let result = match codex.timeout {
307            Some(timeout) => {
308                run_with_timeout(
309                    &codex.binary,
310                    &command_args,
311                    &codex.env,
312                    codex.working_dir.as_deref(),
313                    timeout,
314                    codex.process_group,
315                )
316                .await
317            }
318            None => {
319                run_internal(
320                    &codex.binary,
321                    &command_args,
322                    &codex.env,
323                    codex.working_dir.as_deref(),
324                    codex.process_group,
325                )
326                .await
327            }
328        };
329        outcome.settle_from(&result);
330        result
331    }
332    .instrument(span)
333    .await
334}
335
336/// Run a codex command, stopping it gracefully if `cancel` resolves first.
337///
338/// On cancellation the run's process group is sent SIGTERM, given the client's
339/// [`termination_grace`](crate::CodexBuilder::termination_grace), then killed.
340/// Signalling the group rather than the pid is what reaches the subprocesses
341/// codex started for tool use; killing only the direct child leaves those
342/// running (#78).
343///
344/// Dropping the future instead is still safe, and still kills the group, but
345/// abruptly: `Drop` cannot wait out a grace period.
346///
347/// Retry does not apply. A cancelled run is a decision, not a transient
348/// failure.
349///
350/// On platforms without process groups this degrades to killing the child.
351pub async fn run_codex_cancellable<C>(
352    codex: &Codex,
353    args: Vec<String>,
354    cancel: C,
355) -> Result<CommandOutput>
356where
357    C: std::future::Future<Output = ()> + Send,
358{
359    let span = command_span("codex.exec", codex, &args);
360    let outcome_span = span.clone();
361    let command_args = assemble_args(codex, args);
362
363    async move {
364        debug!(binary = %codex.binary.display(), args = ?command_args, "executing cancellable codex command");
365
366        let mut outcome = SpanOutcome::start(outcome_span);
367        let result = run_internal_inner(
368            SpawnSpec {
369                binary: &codex.binary,
370                args: &command_args,
371                env: &codex.env,
372                working_dir: codex.working_dir.as_deref(),
373                stdin_prompt: None,
374                process_group: codex.process_group,
375            },
376            Some(Box::pin(cancel)),
377            codex.termination_grace,
378        )
379        .await;
380
381        match &result {
382            Err(Error::Cancelled { .. }) => outcome.settle("cancelled", None),
383            other => outcome.settle_from_ref(other),
384        }
385        result
386    }
387    .instrument(span)
388    .await
389}
390
391/// Run a codex command and allow specific non-zero exit codes.
392pub async fn run_codex_allow_exit_codes(
393    codex: &Codex,
394    args: Vec<String>,
395    allowed_codes: &[i32],
396) -> Result<CommandOutput> {
397    let output = run_codex(codex, args).await;
398
399    match output {
400        // Matched on the exit code rather than the variant: classification
401        // moves some failures off CommandFailed, and an allowed code is
402        // allowed whatever the wrapper made of the message.
403        Err(e)
404            if e.exit_code()
405                .is_some_and(|code| allowed_codes.contains(&code)) =>
406        {
407            let exit_code = e.exit_code().unwrap_or(-1);
408            let (stdout, stderr) = match &e {
409                Error::CommandFailed { stdout, stderr, .. } => (stdout.clone(), stderr.clone()),
410                Error::Auth { message, .. }
411                | Error::Config { message, .. }
412                | Error::NotTrustedDirectory { message, .. }
413                | Error::SessionNotFound { message, .. } => (String::new(), message.clone()),
414                _ => (String::new(), String::new()),
415            };
416            Ok(CommandOutput {
417                stdout,
418                stderr,
419                exit_code,
420                success: false,
421            })
422        }
423        other => other,
424    }
425}
426
427/// Run a codex command, writing `prompt` to the child's stdin.
428///
429/// For `codex exec -`, where the prompt is delivered on stdin rather than as
430/// an argument. The prompt is written and the handle dropped, so the CLI sees
431/// EOF and stops waiting for more.
432///
433/// Retry does not apply. The policy is deliberately ignored rather than
434/// honored: a retry would have to write the prompt again, and the first
435/// attempt has already moved the caller's data into a pipe that cannot be
436/// rewound. Silently retrying with an empty stdin would be worse than not
437/// retrying at all.
438pub async fn run_codex_with_stdin_prompt(
439    codex: &Codex,
440    args: Vec<String>,
441    prompt: &str,
442) -> Result<CommandOutput> {
443    let span = command_span("codex.exec", codex, &args);
444    let outcome_span = span.clone();
445    let command_args = assemble_args(codex, args);
446
447    async move {
448        debug!(
449            binary = %codex.binary.display(),
450            args = ?command_args,
451            prompt_bytes = prompt.len(),
452            "executing codex command with a stdin prompt"
453        );
454
455        let mut outcome = SpanOutcome::start(outcome_span);
456        let run = run_internal_inner(
457            SpawnSpec {
458                binary: &codex.binary,
459                args: &command_args,
460                env: &codex.env,
461                working_dir: codex.working_dir.as_deref(),
462                stdin_prompt: Some(prompt),
463                process_group: codex.process_group,
464            },
465            None,
466            Duration::from_secs(0),
467        );
468
469        let result = match codex.timeout {
470            Some(timeout) => match tokio::time::timeout(timeout, run).await {
471                Ok(result) => result,
472                Err(_) => Err(Error::Timeout {
473                    timeout_seconds: timeout.as_secs(),
474                }),
475            },
476            None => run.await,
477        };
478        outcome.settle_from(&result);
479        result
480    }
481    .instrument(span)
482    .await
483}
484
485async fn run_internal(
486    binary: &std::path::Path,
487    args: &[String],
488    env: &std::collections::HashMap<String, String>,
489    working_dir: Option<&std::path::Path>,
490    process_group: bool,
491) -> Result<CommandOutput> {
492    run_internal_inner(
493        SpawnSpec {
494            binary,
495            args,
496            env,
497            working_dir,
498            stdin_prompt: None,
499            process_group,
500        },
501        None,
502        Duration::from_secs(0),
503    )
504    .await
505}
506
507type CancelFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send + 'a>>;
508
509/// Everything one spawn needs, gathered so the signature stays readable.
510struct SpawnSpec<'a> {
511    binary: &'a std::path::Path,
512    args: &'a [String],
513    env: &'a std::collections::HashMap<String, String>,
514    working_dir: Option<&'a std::path::Path>,
515    /// A prompt to deliver on stdin, for `codex exec -`.
516    stdin_prompt: Option<&'a str>,
517    /// Whether the run leads its own process group.
518    process_group: bool,
519}
520
521async fn run_internal_inner(
522    spec: SpawnSpec<'_>,
523    cancel: Option<CancelFuture<'_>>,
524    grace: Duration,
525) -> Result<CommandOutput> {
526    let SpawnSpec {
527        binary,
528        args,
529        env,
530        working_dir,
531        stdin_prompt,
532        process_group,
533    } = spec;
534    let mut cmd = Command::new(binary);
535    cmd.args(args);
536
537    // Pipe stdin only when there is a prompt to write. Otherwise close it, so
538    // the child neither inherits nor blocks on the parent's.
539    if stdin_prompt.is_some() {
540        cmd.stdin(std::process::Stdio::piped());
541    } else {
542        cmd.stdin(std::process::Stdio::null());
543    }
544
545    // Kill the child if this future is dropped: on timeout, on caller
546    // cancellation, or on task abort. Without this, tokio detaches the child
547    // and codex keeps running with no handle left to stop it.
548    cmd.kill_on_drop(true);
549    own_process_group(&mut cmd, process_group);
550
551    if let Some(dir) = working_dir {
552        cmd.current_dir(dir);
553    }
554
555    for (key, value) in env {
556        cmd.env(key, value);
557    }
558
559    // Always spawn rather than using `Command::output`: the pid is needed to
560    // signal the process group, and `output` does not surrender it. It also
561    // forces stdin to null, which the stdin-prompt path cannot use.
562    cmd.stdout(std::process::Stdio::piped());
563    cmd.stderr(std::process::Stdio::piped());
564
565    let mut child = cmd.spawn().map_err(|e| Error::Io {
566        message: format!("failed to spawn codex: {e}"),
567        source: e,
568        working_dir: working_dir.map(|p| p.to_path_buf()),
569    })?;
570
571    // Armed for the whole run. If this future is dropped, its Drop signals the
572    // group, which is what reaches the subprocesses codex started.
573    // Only meaningful when the run leads its own group. Sharing the parent's
574    // means signalling it would hit the parent too.
575    let mut group = GroupKillGuard::new(process_group.then(|| child.id()).flatten());
576    let child_stdin = child.stdin.take();
577
578    let write = async move {
579        let (Some(prompt), Some(mut stdin)) = (stdin_prompt, child_stdin) else {
580            return Ok(());
581        };
582        use tokio::io::AsyncWriteExt;
583        stdin.write_all(prompt.as_bytes()).await?;
584        // Closing the write half is what tells the CLI the prompt is
585        // complete. Without it the child waits for more input.
586        stdin.shutdown().await
587    };
588
589    // Concurrently, not sequentially: a prompt larger than the pipe buffer
590    // blocks until the child reads it, and a child that writes to stdout
591    // meanwhile blocks until we read that. Waiting for the write to finish
592    // before draining stdout would deadlock both.
593    let run = async { tokio::join!(write, child.wait_with_output()) };
594
595    let finished = match cancel {
596        None => Some(run.await),
597        // Racing the run against the caller's signal. Cancellation here is
598        // graceful, which is the whole reason it cannot live in `Drop`:
599        // asking a process to stop and then waiting requires awaiting.
600        Some(cancel) => tokio::select! {
601            outcome = run => Some(outcome),
602            () = cancel => None,
603        },
604    };
605
606    let Some((write_result, output_result)) = finished else {
607        group.terminate(grace).await;
608        return Err(Error::Cancelled {
609            grace_seconds: grace.as_secs(),
610        });
611    };
612
613    write_result.map_err(|e| Error::Io {
614        message: format!("failed to write the prompt to codex stdin: {e}"),
615        source: e,
616        working_dir: working_dir.map(|p| p.to_path_buf()),
617    })?;
618    let output = output_result.map_err(|e| Error::Io {
619        message: format!("failed to wait on codex: {e}"),
620        source: e,
621        working_dir: working_dir.map(|p| p.to_path_buf()),
622    })?;
623
624    // Finished on its own, so there is no group left to kill.
625    group.disarm();
626
627    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
628    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
629    let exit_code = output.status.code().unwrap_or(-1);
630
631    if !output.status.success() {
632        return Err(Error::from_command_failure(
633            format!("{} {}", binary.display(), args.join(" ")),
634            exit_code,
635            stdout,
636            stderr,
637            working_dir.map(|p| p.to_path_buf()),
638        ));
639    }
640
641    Ok(CommandOutput {
642        stdout,
643        stderr,
644        exit_code,
645        success: true,
646    })
647}
648
649async fn run_with_timeout(
650    binary: &std::path::Path,
651    args: &[String],
652    env: &std::collections::HashMap<String, String>,
653    working_dir: Option<&std::path::Path>,
654    timeout: Duration,
655    process_group: bool,
656) -> Result<CommandOutput> {
657    tokio::time::timeout(
658        timeout,
659        run_internal(binary, args, env, working_dir, process_group),
660    )
661    .await
662    .map_err(|_| Error::Timeout {
663        timeout_seconds: timeout.as_secs(),
664    })?
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    fn make_output(stdout: &str, stderr: &str) -> CommandOutput {
672        CommandOutput {
673            stdout: stdout.to_string(),
674            stderr: stderr.to_string(),
675            exit_code: 0,
676            success: true,
677        }
678    }
679
680    #[test]
681    fn shell_quote_leaves_plain_words_alone() {
682        assert_eq!(shell_quote("exec"), "exec");
683        assert_eq!(shell_quote("--ephemeral"), "--ephemeral");
684        assert_eq!(shell_quote("model=gpt-5"), "model=gpt-5");
685    }
686
687    #[test]
688    fn shell_quote_wraps_anything_a_shell_would_read() {
689        assert_eq!(shell_quote("fix the tests"), "'fix the tests'");
690        assert_eq!(shell_quote("$HOME"), "'$HOME'");
691        assert_eq!(shell_quote("a;b"), "'a;b'");
692        assert_eq!(shell_quote("*.rs"), "'*.rs'");
693        assert_eq!(shell_quote("it's"), r"'it'\''s'");
694    }
695
696    /// An unquoted empty string would disappear from the rendered command,
697    /// making the preview describe a different invocation than it previews.
698    #[test]
699    fn shell_quote_keeps_the_empty_argument_visible() {
700        assert_eq!(shell_quote(""), "''");
701    }
702
703    #[test]
704    fn debug_short_output_not_truncated() {
705        let output = make_output("hello", "world");
706        let debug = format!("{output:?}");
707        assert!(debug.contains("hello"));
708        assert!(debug.contains("world"));
709        assert!(!debug.contains("bytes total"));
710    }
711
712    #[test]
713    fn debug_long_output_truncated() {
714        let long = "x".repeat(300);
715        let output = make_output(&long, &long);
716        let debug = format!("{output:?}");
717        assert!(debug.contains("... (300 bytes total)"));
718        assert!(!debug.contains(&long));
719    }
720
721    /// A wrapper timeout drops `run_internal`'s future. Without
722    /// `kill_on_drop`, `Error::Timeout` would mean "we stopped waiting" while
723    /// codex kept running.
724    #[cfg(unix)]
725    #[tokio::test]
726    async fn timeout_kills_the_spawned_process() {
727        use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
728
729        let pid_file = PidFile::new("exec-timeout");
730        let codex = blocking_codex(&pid_file)
731            .timeout(Duration::from_millis(500))
732            .build()
733            .expect("bash must exist");
734
735        let result = run_codex(&codex, vec!["exec".into(), "probe".into()]).await;
736        assert!(
737            matches!(result, Err(Error::Timeout { .. })),
738            "expected timeout error, got: {result:?}"
739        );
740
741        let pid = pid_file.read_pid().await;
742        assert!(
743            wait_until_gone(pid).await,
744            "codex ({pid}) survived the timeout"
745        );
746    }
747
748    /// The caller dropping the future is the case an operator kill or a
749    /// graceful shutdown produces, with no wrapper timeout involved.
750    #[cfg(unix)]
751    #[tokio::test]
752    async fn cancellation_kills_the_spawned_process() {
753        use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
754
755        let pid_file = PidFile::new("exec-cancel");
756        let codex = blocking_codex(&pid_file).build().expect("bash must exist");
757
758        let cancelled = tokio::time::timeout(
759            Duration::from_millis(500),
760            run_codex(&codex, vec!["exec".into(), "probe".into()]),
761        )
762        .await;
763        assert!(
764            cancelled.is_err(),
765            "fake codex should still have been running, got: {cancelled:?}"
766        );
767
768        let pid = pid_file.read_pid().await;
769        assert!(
770            wait_until_gone(pid).await,
771            "codex ({pid}) survived the dropped future"
772        );
773    }
774
775    /// Minimal recording subscriber, written by hand because #63 rules out a
776    /// new dependency and `tracing-subscriber` would be one.
777    ///
778    /// Installed once as the **global** default, fanning out to a per-thread
779    /// sink. A thread-local `set_default` is not enough: tracing caches
780    /// callsite interest globally, so a test running concurrently against no
781    /// subscriber caches this crate's span callsites as `never` and the
782    /// recording test then sees nothing. That produced a real flake, about one
783    /// run in three. A single always-enabled global subscriber keeps interest
784    /// stable, and the per-thread sink keeps tests isolated.
785    #[cfg(unix)]
786    mod recorder {
787        use std::cell::RefCell;
788        use std::sync::{Arc, Mutex, Once};
789
790        use tracing::field::{Field, Visit};
791        use tracing::span::{Attributes, Id, Record};
792        use tracing::{Event, Metadata, Subscriber};
793
794        type Sink = Arc<Mutex<Vec<(String, String)>>>;
795
796        thread_local! {
797            static SINK: RefCell<Option<Sink>> = const { RefCell::new(None) };
798        }
799
800        struct Global;
801
802        impl Global {
803            fn collect(f: impl FnOnce(&mut Vec<(String, String)>)) {
804                SINK.with(|sink| {
805                    if let Some(sink) = sink.borrow().as_ref() {
806                        f(&mut sink.lock().unwrap());
807                    }
808                });
809            }
810        }
811
812        impl Subscriber for Global {
813            /// Always true, so callsite interest is never cached as `never`.
814            fn enabled(&self, _: &Metadata<'_>) -> bool {
815                true
816            }
817            fn new_span(&self, attrs: &Attributes<'_>) -> Id {
818                Self::collect(|fields| attrs.record(&mut Collect(fields)));
819                Id::from_u64(1)
820            }
821            fn record(&self, _: &Id, values: &Record<'_>) {
822                Self::collect(|fields| values.record(&mut Collect(fields)));
823            }
824            fn record_follows_from(&self, _: &Id, _: &Id) {}
825            fn event(&self, _: &Event<'_>) {}
826            fn enter(&self, _: &Id) {}
827            fn exit(&self, _: &Id) {}
828        }
829
830        struct Collect<'a>(&'a mut Vec<(String, String)>);
831
832        impl Visit for Collect<'_> {
833            fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
834                self.0.push((field.name().into(), format!("{value:?}")));
835            }
836            fn record_str(&mut self, field: &Field, value: &str) {
837                self.0.push((field.name().into(), value.into()));
838            }
839            fn record_i64(&mut self, field: &Field, value: i64) {
840                self.0.push((field.name().into(), value.to_string()));
841            }
842            fn record_u64(&mut self, field: &Field, value: u64) {
843                self.0.push((field.name().into(), value.to_string()));
844            }
845        }
846
847        pub(super) struct Recorder(Sink);
848
849        impl Recorder {
850            /// Start recording spans raised on this thread.
851            pub(super) fn install() -> Self {
852                static INIT: Once = Once::new();
853                INIT.call_once(|| {
854                    let _ = tracing::subscriber::set_global_default(Global);
855                });
856                let sink: Sink = Arc::new(Mutex::new(Vec::new()));
857                SINK.with(|slot| *slot.borrow_mut() = Some(Arc::clone(&sink)));
858                Self(sink)
859            }
860
861            pub(super) fn dump(&self) -> String {
862                format!("{:?}", self.0.lock().unwrap())
863            }
864
865            pub(super) fn value(&self, field: &str) -> Option<String> {
866                self.0
867                    .lock()
868                    .unwrap()
869                    .iter()
870                    .rev()
871                    .find(|(name, _)| name == field)
872                    .map(|(_, value)| value.clone())
873            }
874        }
875
876        impl Drop for Recorder {
877            fn drop(&mut self) {
878                SINK.with(|slot| *slot.borrow_mut() = None);
879            }
880        }
881    }
882
883    #[cfg(unix)]
884    #[tokio::test]
885    async fn span_records_the_subcommand_and_a_clean_outcome() {
886        let recorder = recorder::Recorder::install();
887
888        let codex = Codex::builder()
889            .binary("/bin/echo")
890            .build()
891            .expect("echo must exist");
892        run_codex(&codex, vec!["exec".into()]).await.unwrap();
893
894        assert_eq!(recorder.value("subcommand").as_deref(), Some("exec"));
895        assert_eq!(recorder.value("outcome").as_deref(), Some("ok"));
896        assert_eq!(recorder.value("exit_code").as_deref(), Some("0"));
897        assert!(recorder.value("duration_ms").is_some());
898    }
899
900    /// The prompt must never reach the span. It is in argv, so recording the
901    /// arguments would leak it into any host's logs.
902    #[cfg(unix)]
903    #[tokio::test]
904    async fn span_does_not_carry_the_prompt() {
905        let recorder = recorder::Recorder::install();
906
907        let codex = Codex::builder()
908            .binary("/bin/echo")
909            .build()
910            .expect("echo must exist");
911        run_codex(&codex, vec!["exec".into(), "a very secret prompt".into()])
912            .await
913            .unwrap();
914
915        let recorded = format!("{:?}", recorder.value("subcommand"));
916        assert!(!recorded.contains("secret"));
917        for field in ["binary", "working_dir", "outcome", "exit_code"] {
918            let value = recorder.value(field).unwrap_or_default();
919            assert!(
920                !value.contains("secret"),
921                "{field} leaked the prompt: {value}"
922            );
923        }
924    }
925
926    /// A dropped future records an outcome rather than leaving the span open,
927    /// so an abandoned run is distinguishable from one still in progress.
928    #[cfg(unix)]
929    #[tokio::test]
930    async fn a_cancelled_run_is_recorded_as_cancelled() {
931        let recorder = recorder::Recorder::install();
932
933        let pid_file = crate::test_support::PidFile::new("span-cancel");
934        let codex = crate::test_support::blocking_codex(&pid_file)
935            .build()
936            .expect("bash must exist");
937
938        let cancelled = tokio::time::timeout(
939            Duration::from_millis(300),
940            run_codex(&codex, vec!["exec".into()]),
941        )
942        .await;
943        assert!(cancelled.is_err(), "the run should still have been going");
944
945        assert_eq!(
946            recorder.value("outcome").as_deref(),
947            Some("cancelled"),
948            "recorded: {}",
949            recorder.dump()
950        );
951    }
952
953    /// A wrapper timeout is its own outcome, distinct from a caller
954    /// cancelling: the run reached its deadline rather than being abandoned.
955    #[cfg(unix)]
956    #[tokio::test]
957    async fn a_timed_out_run_is_recorded_as_timeout() {
958        let recorder = recorder::Recorder::install();
959
960        let pid_file = crate::test_support::PidFile::new("span-timeout");
961        let codex = crate::test_support::blocking_codex(&pid_file)
962            .timeout(Duration::from_millis(300))
963            .build()
964            .expect("bash must exist");
965
966        let result = run_codex(&codex, vec!["exec".into()]).await;
967        assert!(matches!(result, Err(Error::Timeout { .. })), "{result:?}");
968
969        assert_eq!(recorder.value("outcome").as_deref(), Some("timeout"));
970    }
971
972    // -----------------------------------------------------------------
973    // Classification end to end (#85)
974    // -----------------------------------------------------------------
975
976    #[cfg(unix)]
977    fn failing_codex(case: &str) -> Codex {
978        let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
979            .join("tests")
980            .join("fake-codex-failure.sh");
981        Codex::builder()
982            .binary("/bin/bash")
983            .arg(script.to_str().unwrap())
984            .env("CODEX_WRAPPER_TEST_FAILURE", case)
985            .build()
986            .expect("bash must exist")
987    }
988
989    /// Classification has to happen on the spawn path, not only in the
990    /// constructor, or a caller still gets a bare CommandFailed.
991    #[cfg(unix)]
992    #[tokio::test]
993    async fn a_real_spawn_returns_a_classified_error() {
994        use crate::error::FailureKind;
995
996        for (case, expected) in [
997            ("auth", FailureKind::Auth),
998            ("not-trusted", FailureKind::NotTrustedDirectory),
999            ("config", FailureKind::Config),
1000            ("session", FailureKind::SessionNotFound),
1001            ("mystery", FailureKind::Unclassified),
1002        ] {
1003            let codex = failing_codex(case);
1004            let err = run_codex(&codex, vec!["exec".into()]).await.unwrap_err();
1005            assert_eq!(err.failure_kind(), Some(expected), "case {case}: {err}");
1006        }
1007    }
1008
1009    /// A deterministic failure must not be retried even when its exit code is
1010    /// on the retry list. Re-running gets the same rejection, and the auth
1011    /// case has already been retried inside the CLI before it reaches here.
1012    #[cfg(unix)]
1013    #[tokio::test]
1014    async fn a_classified_failure_is_not_retried() {
1015        let policy = crate::retry::RetryPolicy::new()
1016            .max_attempts(3)
1017            .initial_backoff(Duration::from_millis(1))
1018            .retry_on_exit_codes([1]);
1019
1020        let started = Instant::now();
1021        let codex = failing_codex("auth");
1022        let err = run_codex_with_retry(&codex, vec!["exec".into()], Some(&policy))
1023            .await
1024            .unwrap_err();
1025
1026        assert!(matches!(err, Error::Auth { .. }), "{err}");
1027        // Three attempts with backoff would be visibly slower; this asserts
1028        // the shape rather than the timing, but the timing corroborates it.
1029        assert!(
1030            started.elapsed() < Duration::from_secs(2),
1031            "looks like it retried: {:?}",
1032            started.elapsed()
1033        );
1034    }
1035
1036    /// An unclassified failure keeps the old retry behaviour.
1037    #[cfg(unix)]
1038    #[tokio::test]
1039    async fn an_unclassified_failure_still_retries() {
1040        let policy = crate::retry::RetryPolicy::new()
1041            .max_attempts(2)
1042            .initial_backoff(Duration::from_millis(1))
1043            .retry_on_exit_codes([1]);
1044
1045        let codex = failing_codex("mystery");
1046        let err = run_codex_with_retry(&codex, vec!["exec".into()], Some(&policy))
1047            .await
1048            .unwrap_err();
1049
1050        assert!(matches!(err, Error::CommandFailed { .. }), "{err}");
1051    }
1052
1053    /// The allow-list works on the exit code, so it still applies to a
1054    /// failure that classification moved off CommandFailed.
1055    #[cfg(unix)]
1056    #[tokio::test]
1057    async fn allowed_exit_codes_still_apply_to_a_classified_failure() {
1058        let codex = failing_codex("auth");
1059        let output = run_codex_allow_exit_codes(&codex, vec!["exec".into()], &[1])
1060            .await
1061            .expect("exit code 1 was allowed");
1062
1063        assert_eq!(output.exit_code, 1);
1064        assert!(!output.success);
1065        assert!(
1066            output.stderr.contains("401 Unauthorized"),
1067            "{}",
1068            output.stderr
1069        );
1070    }
1071
1072    // -----------------------------------------------------------------
1073    // Process groups and cancellation (#78)
1074    // -----------------------------------------------------------------
1075
1076    /// A fake codex that spawns a child of its own, the way the real CLI does
1077    /// for tool use, and the pids of both.
1078    #[cfg(unix)]
1079    fn spawning_codex(label: &str) -> (Codex, crate::test_support::PidFile) {
1080        let pid_file = crate::test_support::PidFile::new(label);
1081        let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1082            .join("tests")
1083            .join("fake-codex-spawns-child.sh");
1084        let codex = Codex::builder()
1085            .binary("/bin/bash")
1086            .arg(script.to_str().unwrap())
1087            .env(
1088                "CODEX_WRAPPER_TEST_PIDFILE",
1089                pid_file.path().to_str().unwrap(),
1090            )
1091            .build()
1092            .expect("bash must exist");
1093        (codex, pid_file)
1094    }
1095
1096    #[cfg(unix)]
1097    async fn read_pids(pid_file: &crate::test_support::PidFile) -> (u32, u32) {
1098        for _ in 0..200 {
1099            if let Ok(contents) = std::fs::read_to_string(pid_file.path()) {
1100                let parse = |prefix: &str| -> Option<u32> {
1101                    contents
1102                        .lines()
1103                        .find_map(|l| l.strip_prefix(prefix))
1104                        .and_then(|v| v.trim().parse().ok())
1105                };
1106                if let (Some(parent), Some(child)) = (parse("parent="), parse("child=")) {
1107                    return (parent, child);
1108                }
1109            }
1110            tokio::time::sleep(Duration::from_millis(10)).await;
1111        }
1112        panic!("the fake codex never recorded both pids");
1113    }
1114
1115    /// The point of #78. `kill_on_drop` reaps the direct child only, so before
1116    /// process groups the grandchild outlived a cancelled run.
1117    #[cfg(unix)]
1118    #[tokio::test]
1119    async fn cancelling_kills_the_whole_process_group() {
1120        use crate::test_support::wait_until_gone;
1121
1122        let (codex, pid_file) = spawning_codex("group-drop");
1123
1124        let cancelled = tokio::time::timeout(
1125            Duration::from_millis(400),
1126            run_codex(&codex, vec!["exec".into()]),
1127        )
1128        .await;
1129        assert!(cancelled.is_err(), "the run should still have been going");
1130
1131        let (parent, child) = read_pids(&pid_file).await;
1132        assert!(wait_until_gone(parent).await, "codex ({parent}) survived");
1133        assert!(
1134            wait_until_gone(child).await,
1135            "the subprocess ({child}) survived the cancelled run"
1136        );
1137    }
1138
1139    /// The graceful path: SIGTERM, a grace period, then SIGKILL. It cannot
1140    /// live in `Drop`, which is why there is an explicit entry point.
1141    #[cfg(unix)]
1142    #[tokio::test]
1143    async fn run_codex_cancellable_stops_the_group_gracefully() {
1144        use crate::test_support::wait_until_gone;
1145
1146        let (codex, pid_file) = spawning_codex("group-cancel");
1147        let codex = Codex::builder()
1148            .binary(codex.binary())
1149            .arg(
1150                std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1151                    .join("tests")
1152                    .join("fake-codex-spawns-child.sh")
1153                    .to_str()
1154                    .unwrap(),
1155            )
1156            .env(
1157                "CODEX_WRAPPER_TEST_PIDFILE",
1158                pid_file.path().to_str().unwrap(),
1159            )
1160            .termination_grace(Duration::from_millis(50))
1161            .build()
1162            .unwrap();
1163
1164        let cancel = async {
1165            tokio::time::sleep(Duration::from_millis(300)).await;
1166        };
1167        let result = run_codex_cancellable(&codex, vec!["exec".into()], cancel).await;
1168
1169        assert!(
1170            matches!(result, Err(Error::Cancelled { .. })),
1171            "expected a cancellation, got: {result:?}"
1172        );
1173
1174        let (parent, child) = read_pids(&pid_file).await;
1175        assert!(wait_until_gone(parent).await, "codex ({parent}) survived");
1176        assert!(
1177            wait_until_gone(child).await,
1178            "the subprocess ({child}) survived cancellation"
1179        );
1180    }
1181
1182    /// A run that finishes before the signal must not be reported as
1183    /// cancelled, and must not be killed on the way out.
1184    #[cfg(unix)]
1185    #[tokio::test]
1186    async fn a_run_that_finishes_first_is_not_cancelled() {
1187        let codex = Codex::builder()
1188            .binary("/bin/echo")
1189            .build()
1190            .expect("echo must exist");
1191
1192        let never = std::future::pending::<()>();
1193        let output = run_codex_cancellable(&codex, vec!["exec".into()], never)
1194            .await
1195            .unwrap();
1196        assert!(output.success);
1197    }
1198
1199    /// Opting out is not cosmetic: without a group of its own, cancelling
1200    /// reaches the direct child only and its subprocesses survive. That is the
1201    /// terminal-attached contract, where the terminal is the supervisor and
1202    /// Ctrl-C reaches the whole run directly instead.
1203    #[cfg(unix)]
1204    #[tokio::test]
1205    async fn opting_out_of_process_groups_leaves_the_subprocess() {
1206        use crate::test_support::wait_until_gone;
1207
1208        let pid_file = crate::test_support::PidFile::new("group-optout");
1209        let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1210            .join("tests")
1211            .join("fake-codex-spawns-child.sh");
1212        let codex = Codex::builder()
1213            .binary("/bin/bash")
1214            .arg(script.to_str().unwrap())
1215            .env(
1216                "CODEX_WRAPPER_TEST_PIDFILE",
1217                pid_file.path().to_str().unwrap(),
1218            )
1219            .process_group(false)
1220            .build()
1221            .expect("bash must exist");
1222
1223        let cancelled = tokio::time::timeout(
1224            Duration::from_millis(400),
1225            run_codex(&codex, vec!["exec".into()]),
1226        )
1227        .await;
1228        assert!(cancelled.is_err(), "the run should still have been going");
1229
1230        let (parent, child) = read_pids(&pid_file).await;
1231        assert!(
1232            wait_until_gone(parent).await,
1233            "kill_on_drop still reaps the direct child ({parent})"
1234        );
1235        // The point of the contrast with
1236        // `cancelling_kills_the_whole_process_group`.
1237        assert!(
1238            crate::test_support::is_running_for_test(child),
1239            "with groups off, the subprocess ({child}) is expected to survive"
1240        );
1241        // Do not leave it behind.
1242        signal_group(child, libc::SIGKILL);
1243        unsafe { libc::kill(i32::try_from(child).unwrap_or(0), libc::SIGKILL) };
1244    }
1245}