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    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
295/// Render an invocation as a copy-pasteable shell command.
296pub(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
305/// Quote a single argument for a POSIX shell, if it needs it.
306///
307/// The empty string is quoted: unquoted it would vanish from the rendered
308/// command, turning a preview into something that runs differently from what
309/// it describes.
310pub(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_internal_inner(
332                    SpawnSpec {
333                        binary: &codex.binary,
334                        args: &command_args,
335                        env: &codex.env,
336                        clear_env: codex.clear_env,
337                        working_dir: codex.working_dir.as_deref(),
338                        stdin_prompt: None,
339                        process_group: codex.process_group,
340                    },
341                    Some(timeout_stop(timeout)),
342                    codex.termination_grace,
343                )
344                .await
345            }
346            None => {
347                run_internal(
348                    &codex.binary,
349                    &command_args,
350                    &codex.env,
351                    codex.clear_env,
352                    codex.working_dir.as_deref(),
353                    codex.process_group,
354                )
355                .await
356            }
357        };
358        outcome.settle_from(&result);
359        result
360    }
361    .instrument(span)
362    .await
363}
364
365/// Run a codex command, stopping it gracefully if `cancel` resolves first.
366///
367/// On cancellation the run's process group is sent SIGTERM, given the client's
368/// [`termination_grace`](crate::CodexBuilder::termination_grace), then killed.
369/// Signalling the group rather than the pid is what reaches the subprocesses
370/// codex started for tool use; killing only the direct child leaves those
371/// running (#78).
372///
373/// Dropping the future instead is still safe, and still kills the group, but
374/// abruptly: `Drop` cannot wait out a grace period.
375///
376/// Retry does not apply. A cancelled run is a decision, not a transient
377/// failure.
378///
379/// On platforms without process groups this degrades to killing the child.
380pub async fn run_codex_cancellable<C>(
381    codex: &Codex,
382    args: Vec<String>,
383    cancel: C,
384) -> Result<CommandOutput>
385where
386    C: std::future::Future<Output = ()> + Send,
387{
388    let span = command_span("codex.exec", codex, &args);
389    let outcome_span = span.clone();
390    let command_args = assemble_args(codex, args);
391
392    async move {
393        debug!(binary = %codex.binary.display(), args = ?command_args, "executing cancellable codex command");
394
395        let mut outcome = SpanOutcome::start(outcome_span);
396        let stop = cancellation_or_timeout(cancel, codex.timeout, codex.termination_grace);
397        let result = run_internal_inner(
398            SpawnSpec {
399                binary: &codex.binary,
400                args: &command_args,
401                env: &codex.env,
402                clear_env: codex.clear_env,
403                working_dir: codex.working_dir.as_deref(),
404                stdin_prompt: None,
405                process_group: codex.process_group,
406            },
407            Some(stop),
408            codex.termination_grace,
409        )
410        .await;
411
412        match &result {
413            Err(Error::Cancelled { .. }) => outcome.settle("cancelled", None),
414            other => outcome.settle_from_ref(other),
415        }
416        result
417    }
418    .instrument(span)
419    .await
420}
421
422/// Run a codex command and allow specific non-zero exit codes.
423pub async fn run_codex_allow_exit_codes(
424    codex: &Codex,
425    args: Vec<String>,
426    allowed_codes: &[i32],
427) -> Result<CommandOutput> {
428    let output = run_codex(codex, args).await;
429
430    match output {
431        // Matched on the exit code rather than the variant: classification
432        // moves some failures off CommandFailed, and an allowed code is
433        // allowed whatever the wrapper made of the message.
434        Err(e)
435            if e.exit_code()
436                .is_some_and(|code| allowed_codes.contains(&code)) =>
437        {
438            let exit_code = e.exit_code().unwrap_or(-1);
439            let (stdout, stderr) = match &e {
440                Error::CommandFailed { stdout, stderr, .. } => (stdout.clone(), stderr.clone()),
441                Error::Auth { message, .. }
442                | Error::Config { message, .. }
443                | Error::NotTrustedDirectory { message, .. }
444                | Error::SessionNotFound { message, .. } => (String::new(), message.clone()),
445                _ => (String::new(), String::new()),
446            };
447            Ok(CommandOutput {
448                stdout,
449                stderr,
450                exit_code,
451                success: false,
452            })
453        }
454        other => other,
455    }
456}
457
458/// Run a codex command, writing `prompt` to the child's stdin.
459///
460/// For `codex exec -`, where the prompt is delivered on stdin rather than as
461/// an argument. The prompt is written and the handle dropped, so the CLI sees
462/// EOF and stops waiting for more.
463///
464/// Retry does not apply. The policy is deliberately ignored rather than
465/// honored: a retry would have to write the prompt again, and the first
466/// attempt has already moved the caller's data into a pipe that cannot be
467/// rewound. Silently retrying with an empty stdin would be worse than not
468/// retrying at all.
469pub async fn run_codex_with_stdin_prompt(
470    codex: &Codex,
471    args: Vec<String>,
472    prompt: &str,
473) -> Result<CommandOutput> {
474    let span = command_span("codex.exec", codex, &args);
475    let outcome_span = span.clone();
476    let command_args = assemble_args(codex, args);
477
478    async move {
479        debug!(
480            binary = %codex.binary.display(),
481            args = ?command_args,
482            prompt_bytes = prompt.len(),
483            "executing codex command with a stdin prompt"
484        );
485
486        let mut outcome = SpanOutcome::start(outcome_span);
487        let stop = codex.timeout.map(timeout_stop);
488        let result = run_internal_inner(
489            SpawnSpec {
490                binary: &codex.binary,
491                args: &command_args,
492                env: &codex.env,
493                clear_env: codex.clear_env,
494                working_dir: codex.working_dir.as_deref(),
495                stdin_prompt: Some(prompt),
496                process_group: codex.process_group,
497            },
498            stop,
499            codex.termination_grace,
500        )
501        .await;
502        outcome.settle_from(&result);
503        result
504    }
505    .instrument(span)
506    .await
507}
508
509/// Run a codex command with a stdin prompt and an explicit cancellation signal.
510///
511/// Cancellation and the client's timeout both terminate the owned process
512/// group and await the direct child before returning. Retry does not apply.
513pub async fn run_codex_with_stdin_prompt_cancellable<C>(
514    codex: &Codex,
515    args: Vec<String>,
516    prompt: &str,
517    cancel: C,
518) -> Result<CommandOutput>
519where
520    C: std::future::Future<Output = ()> + Send,
521{
522    let span = command_span("codex.exec", codex, &args);
523    let outcome_span = span.clone();
524    let command_args = assemble_args(codex, args);
525
526    async move {
527        debug!(
528            binary = %codex.binary.display(),
529            args = ?command_args,
530            prompt_bytes = prompt.len(),
531            "executing cancellable codex command with a stdin prompt"
532        );
533
534        let mut outcome = SpanOutcome::start(outcome_span);
535        let stop = cancellation_or_timeout(cancel, codex.timeout, codex.termination_grace);
536        let result = run_internal_inner(
537            SpawnSpec {
538                binary: &codex.binary,
539                args: &command_args,
540                env: &codex.env,
541                clear_env: codex.clear_env,
542                working_dir: codex.working_dir.as_deref(),
543                stdin_prompt: Some(prompt),
544                process_group: codex.process_group,
545            },
546            Some(stop),
547            codex.termination_grace,
548        )
549        .await;
550
551        match &result {
552            Err(Error::Cancelled { .. }) => outcome.settle("cancelled", None),
553            other => outcome.settle_from_ref(other),
554        }
555        result
556    }
557    .instrument(span)
558    .await
559}
560
561async fn run_internal(
562    binary: &std::path::Path,
563    args: &[String],
564    env: &std::collections::HashMap<String, String>,
565    clear_env: bool,
566    working_dir: Option<&std::path::Path>,
567    process_group: bool,
568) -> Result<CommandOutput> {
569    run_internal_inner(
570        SpawnSpec {
571            binary,
572            args,
573            env,
574            clear_env,
575            working_dir,
576            stdin_prompt: None,
577            process_group,
578        },
579        None,
580        Duration::from_secs(0),
581    )
582    .await
583}
584
585#[derive(Clone, Copy, Debug)]
586enum StopReason {
587    Cancelled { grace_seconds: u64 },
588    Timeout { timeout_seconds: u64 },
589}
590
591impl StopReason {
592    fn into_error(self) -> Error {
593        match self {
594            Self::Cancelled { grace_seconds } => Error::Cancelled { grace_seconds },
595            Self::Timeout { timeout_seconds } => Error::Timeout { timeout_seconds },
596        }
597    }
598}
599
600type StopFuture<'a> = std::pin::Pin<Box<dyn std::future::Future<Output = StopReason> + Send + 'a>>;
601
602fn timeout_stop(timeout: Duration) -> StopFuture<'static> {
603    Box::pin(async move {
604        tokio::time::sleep(timeout).await;
605        StopReason::Timeout {
606            timeout_seconds: timeout.as_secs(),
607        }
608    })
609}
610
611fn cancellation_or_timeout<'a, C>(
612    cancel: C,
613    timeout: Option<Duration>,
614    grace: Duration,
615) -> StopFuture<'a>
616where
617    C: std::future::Future<Output = ()> + Send + 'a,
618{
619    Box::pin(async move {
620        match timeout {
621            Some(timeout) => tokio::select! {
622                () = cancel => StopReason::Cancelled {
623                    grace_seconds: grace.as_secs(),
624                },
625                () = tokio::time::sleep(timeout) => StopReason::Timeout {
626                    timeout_seconds: timeout.as_secs(),
627                },
628            },
629            None => {
630                cancel.await;
631                StopReason::Cancelled {
632                    grace_seconds: grace.as_secs(),
633                }
634            }
635        }
636    })
637}
638
639/// Everything one spawn needs, gathered so the signature stays readable.
640struct SpawnSpec<'a> {
641    binary: &'a std::path::Path,
642    args: &'a [String],
643    env: &'a std::collections::HashMap<String, String>,
644    /// Whether the child starts without the wrapper's ambient environment.
645    clear_env: bool,
646    working_dir: Option<&'a std::path::Path>,
647    /// A prompt to deliver on stdin, for `codex exec -`.
648    stdin_prompt: Option<&'a str>,
649    /// Whether the run leads its own process group.
650    process_group: bool,
651}
652
653async fn run_internal_inner(
654    spec: SpawnSpec<'_>,
655    stop: Option<StopFuture<'_>>,
656    grace: Duration,
657) -> Result<CommandOutput> {
658    let SpawnSpec {
659        binary,
660        args,
661        env,
662        clear_env,
663        working_dir,
664        stdin_prompt,
665        process_group,
666    } = spec;
667    let mut cmd = Command::new(binary);
668    cmd.args(args);
669
670    // Pipe stdin only when there is a prompt to write. Otherwise close it, so
671    // the child neither inherits nor blocks on the parent's.
672    if stdin_prompt.is_some() {
673        cmd.stdin(std::process::Stdio::piped());
674    } else {
675        cmd.stdin(std::process::Stdio::null());
676    }
677
678    // Kill the child if this future is dropped: on timeout, on caller
679    // cancellation, or on task abort. Without this, tokio detaches the child
680    // and codex keeps running with no handle left to stop it.
681    cmd.kill_on_drop(true);
682    own_process_group(&mut cmd, process_group);
683
684    if let Some(dir) = working_dir {
685        cmd.current_dir(dir);
686    }
687
688    apply_child_environment(&mut cmd, clear_env, env);
689
690    // Always spawn rather than using `Command::output`: the pid is needed to
691    // signal the process group, and `output` does not surrender it. It also
692    // forces stdin to null, which the stdin-prompt path cannot use.
693    cmd.stdout(std::process::Stdio::piped());
694    cmd.stderr(std::process::Stdio::piped());
695
696    let mut child = cmd.spawn().map_err(|e| Error::Io {
697        message: format!("failed to spawn codex: {e}"),
698        source: e,
699        working_dir: working_dir.map(|p| p.to_path_buf()),
700    })?;
701
702    // Armed for the whole run. If this future is dropped, its Drop signals the
703    // group, which is what reaches the subprocesses codex started.
704    // Only meaningful when the run leads its own group. Sharing the parent's
705    // means signalling it would hit the parent too.
706    let mut group = GroupKillGuard::new(process_group.then(|| child.id()).flatten());
707    let child_stdin = child.stdin.take();
708    let mut child_stdout = child.stdout.take().expect("stdout was configured as piped");
709    let mut child_stderr = child.stderr.take().expect("stderr was configured as piped");
710
711    let write = async move {
712        let (Some(prompt), Some(mut stdin)) = (stdin_prompt, child_stdin) else {
713            return Ok(());
714        };
715        use tokio::io::AsyncWriteExt;
716        stdin
717            .write_all(prompt.as_bytes())
718            .await
719            .map_err(|e| Error::Io {
720                message: format!("failed to write the prompt to codex stdin: {e}"),
721                source: e,
722                working_dir: working_dir.map(|p| p.to_path_buf()),
723            })?;
724        // Closing the write half is what tells the CLI the prompt is
725        // complete. Without it the child waits for more input.
726        stdin.shutdown().await.map_err(|e| Error::Io {
727            message: format!("failed to close codex stdin: {e}"),
728            source: e,
729            working_dir: working_dir.map(|p| p.to_path_buf()),
730        })
731    };
732
733    // Concurrently, not sequentially: a prompt larger than the pipe buffer
734    // blocks until the child reads it, and a child that writes to stdout
735    // meanwhile blocks until we read that. Waiting for the write to finish
736    // before draining stdout would deadlock both.
737    let read_stdout = async move {
738        use tokio::io::AsyncReadExt;
739        let mut bytes = Vec::new();
740        child_stdout
741            .read_to_end(&mut bytes)
742            .await
743            .map(|_| bytes)
744            .map_err(|e| Error::Io {
745                message: format!("failed to read codex stdout: {e}"),
746                source: e,
747                working_dir: working_dir.map(|p| p.to_path_buf()),
748            })
749    };
750    let read_stderr = async move {
751        use tokio::io::AsyncReadExt;
752        let mut bytes = Vec::new();
753        child_stderr
754            .read_to_end(&mut bytes)
755            .await
756            .map(|_| bytes)
757            .map_err(|e| Error::Io {
758                message: format!("failed to read codex stderr: {e}"),
759                source: e,
760                working_dir: working_dir.map(|p| p.to_path_buf()),
761            })
762    };
763    let wait = async { child.wait().await.map_err(|e| wait_error(e, working_dir)) };
764    let run = async {
765        let ((), stdout, stderr, status) = tokio::try_join!(write, read_stdout, read_stderr, wait)?;
766        Ok::<_, Error>((stdout, stderr, status))
767    };
768
769    let finished = match stop {
770        None => Ok(run.await),
771        // Racing the run against the caller's signal. Cancellation here is
772        // graceful, which is the whole reason it cannot live in `Drop`:
773        // asking a process to stop and then waiting requires awaiting.
774        Some(stop) => tokio::select! {
775            outcome = run => Ok(outcome),
776            reason = stop => Err(reason),
777        },
778    };
779
780    let (stdout, stderr, status) = match finished {
781        Ok(Ok(finished)) => finished,
782        Ok(Err(error)) => {
783            // A stdin or pipe failure can happen while the child continues
784            // running. Treat it as a stop request and settle ownership before
785            // exposing the I/O error.
786            terminate_and_reap(&mut child, &mut group, grace, working_dir).await?;
787            return Err(error);
788        }
789        Err(reason) => {
790            // The run future is gone before this branch executes, returning
791            // ownership of `child` so cleanup can explicitly reap it.
792            // `GroupKillGuard::terminate` reaches descendants on Unix. The
793            // direct-child kill below is the portable fallback and makes the
794            // wait authoritative on every platform.
795            terminate_and_reap(&mut child, &mut group, grace, working_dir).await?;
796            return Err(reason.into_error());
797        }
798    };
799
800    // Finished on its own, so there is no group left to kill.
801    group.disarm();
802
803    let stdout = String::from_utf8_lossy(&stdout).to_string();
804    let stderr = String::from_utf8_lossy(&stderr).to_string();
805    let exit_code = status.code().unwrap_or(-1);
806
807    if !status.success() {
808        return Err(Error::from_command_failure(
809            format!("{} {}", binary.display(), args.join(" ")),
810            exit_code,
811            stdout,
812            stderr,
813            working_dir.map(|p| p.to_path_buf()),
814        ));
815    }
816
817    Ok(CommandOutput {
818        stdout,
819        stderr,
820        exit_code,
821        success: true,
822    })
823}
824
825async fn terminate_and_reap(
826    child: &mut tokio::process::Child,
827    group: &mut GroupKillGuard,
828    grace: Duration,
829    working_dir: Option<&std::path::Path>,
830) -> Result<()> {
831    group.terminate(grace).await;
832    if child
833        .try_wait()
834        .map_err(|e| wait_error(e, working_dir))?
835        .is_none()
836        && let Err(error) = child.start_kill()
837        && error.kind() != std::io::ErrorKind::InvalidInput
838    {
839        return Err(wait_error(error, working_dir));
840    }
841    child.wait().await.map_err(|e| wait_error(e, working_dir))?;
842    Ok(())
843}
844
845fn wait_error(error: std::io::Error, working_dir: Option<&std::path::Path>) -> Error {
846    Error::Io {
847        message: format!("failed to wait on codex: {error}"),
848        source: error,
849        working_dir: working_dir.map(|p| p.to_path_buf()),
850    }
851}
852
853/// Apply the client environment policy to a direct child process.
854///
855/// Kept in one helper because buffered and streaming execution construct
856/// separate commands. Clearing must happen before explicit values are added.
857pub(crate) fn apply_child_environment(
858    cmd: &mut Command,
859    clear_env: bool,
860    env: &std::collections::HashMap<String, String>,
861) {
862    if clear_env {
863        cmd.env_clear();
864    }
865    cmd.envs(env);
866}
867
868#[cfg(test)]
869mod tests {
870    use super::*;
871    use crate::CodexCommand;
872
873    #[test]
874    fn typed_rollout_budget_suppresses_conflicting_client_global_toggles() {
875        let codex = Codex::builder()
876            .binary("/bin/echo")
877            .config("features.rollout_budget=false")
878            .enable("rollout_budget")
879            .disable("rollout_budget")
880            .arg("--enable=rollout_budget")
881            .arg("--disable=rollout_budget")
882            .arg("--disable")
883            .arg("rollout_budget")
884            .enable("keep-enabled")
885            .disable("keep-disabled")
886            .build()
887            .expect("echo must exist");
888        let budget = crate::RolloutBudgetConfig::builder(10_000)
889            .build()
890            .expect("valid budget");
891        let expected = budget.config_override();
892        let opening = crate::ExecCommand::new("hi")
893            .rollout_budget(budget.clone())
894            .args();
895        let resumed = crate::ExecResumeCommand::new()
896            .session_id("thread")
897            .rollout_budget(budget)
898            .args();
899
900        for args in [opening, resumed] {
901            let assembled = assemble_args(&codex, args);
902            assert!(assembled.iter().any(|arg| arg == &expected));
903            assert!(
904                !assembled.windows(2).any(|pair| {
905                    matches!(pair[0].as_str(), "--enable" | "--disable")
906                        && pair[1] == "rollout_budget"
907                }),
908                "typed budget must suppress paired client toggles: {assembled:?}"
909            );
910            assert!(
911                !assembled.iter().any(|arg| {
912                    matches!(
913                        arg.as_str(),
914                        "--enable=rollout_budget" | "--disable=rollout_budget"
915                    )
916                }),
917                "typed budget must suppress equals-form client toggles: {assembled:?}"
918            );
919            assert!(
920                assembled
921                    .windows(2)
922                    .any(|pair| pair == ["--enable", "keep-enabled"])
923            );
924            assert!(
925                assembled
926                    .windows(2)
927                    .any(|pair| pair == ["--disable", "keep-disabled"])
928            );
929        }
930    }
931
932    fn make_output(stdout: &str, stderr: &str) -> CommandOutput {
933        CommandOutput {
934            stdout: stdout.to_string(),
935            stderr: stderr.to_string(),
936            exit_code: 0,
937            success: true,
938        }
939    }
940
941    #[test]
942    fn shell_quote_leaves_plain_words_alone() {
943        assert_eq!(shell_quote("exec"), "exec");
944        assert_eq!(shell_quote("--ephemeral"), "--ephemeral");
945        assert_eq!(shell_quote("model=gpt-5"), "model=gpt-5");
946    }
947
948    #[test]
949    fn shell_quote_wraps_anything_a_shell_would_read() {
950        assert_eq!(shell_quote("fix the tests"), "'fix the tests'");
951        assert_eq!(shell_quote("$HOME"), "'$HOME'");
952        assert_eq!(shell_quote("a;b"), "'a;b'");
953        assert_eq!(shell_quote("*.rs"), "'*.rs'");
954        assert_eq!(shell_quote("it's"), r"'it'\''s'");
955    }
956
957    /// An unquoted empty string would disappear from the rendered command,
958    /// making the preview describe a different invocation than it previews.
959    #[test]
960    fn shell_quote_keeps_the_empty_argument_visible() {
961        assert_eq!(shell_quote(""), "''");
962    }
963
964    #[test]
965    fn debug_short_output_not_truncated() {
966        let output = make_output("hello", "world");
967        let debug = format!("{output:?}");
968        assert!(debug.contains("hello"));
969        assert!(debug.contains("world"));
970        assert!(!debug.contains("bytes total"));
971    }
972
973    #[test]
974    fn debug_long_output_truncated() {
975        let long = "x".repeat(300);
976        let output = make_output(&long, &long);
977        let debug = format!("{output:?}");
978        assert!(debug.contains("... (300 bytes total)"));
979        assert!(!debug.contains(&long));
980    }
981
982    /// Compatibility is explicit: callers that do not opt in keep the
983    /// ambient environment they relied on before `clear_env` existed.
984    #[cfg(unix)]
985    #[tokio::test]
986    async fn child_environment_is_inherited_by_default() {
987        let capture = crate::test_support::EnvCapture::new("env-default");
988        let codex = crate::test_support::env_capturing_codex(&capture)
989            .build()
990            .expect("bash must exist");
991
992        crate::ExecCommand::new("probe")
993            .execute(&codex)
994            .await
995            .unwrap();
996
997        let environment = capture.read();
998        assert_eq!(
999            environment.get("PATH"),
1000            Some(&std::env::var("PATH").expect("test process must have PATH"))
1001        );
1002    }
1003
1004    /// The timeout and ordinary buffered paths use different wrapper entry
1005    /// points. Opening and resume both have to reach the same environment
1006    /// policy, and explicit values on either side of `clear_env` must survive.
1007    #[cfg(unix)]
1008    #[tokio::test]
1009    async fn cleared_environment_reaches_buffered_open_and_resume() {
1010        let capture = crate::test_support::EnvCapture::new("env-buffered");
1011        let opening_client = crate::test_support::env_capturing_codex(&capture)
1012            .clear_env()
1013            .env("CODEX_WRAPPER_EXPLICIT", "opening")
1014            .timeout(Duration::from_secs(2))
1015            .build()
1016            .expect("bash must exist");
1017
1018        crate::ExecCommand::new("probe")
1019            .execute(&opening_client)
1020            .await
1021            .unwrap();
1022        let opening_environment = capture.read();
1023        assert!(!opening_environment.contains_key("PATH"));
1024        assert_eq!(
1025            opening_environment
1026                .get("CODEX_WRAPPER_EXPLICIT")
1027                .map(String::as_str),
1028            Some("opening")
1029        );
1030        assert!(opening_environment.contains_key("CODEX_WRAPPER_ENV_CAPTURE"));
1031
1032        let resume_client = crate::test_support::env_capturing_codex(&capture)
1033            .clear_env()
1034            .env("CODEX_WRAPPER_EXPLICIT", "resume")
1035            .build()
1036            .expect("bash must exist");
1037        crate::ExecResumeCommand::new()
1038            .last()
1039            .execute(&resume_client)
1040            .await
1041            .unwrap();
1042        let resume_environment = capture.read();
1043        assert!(!resume_environment.contains_key("PATH"));
1044        assert_eq!(
1045            resume_environment
1046                .get("CODEX_WRAPPER_EXPLICIT")
1047                .map(String::as_str),
1048            Some("resume")
1049        );
1050    }
1051
1052    /// Stdin delivery and graceful cancellation each construct their own
1053    /// spawn specification, so cover both instead of treating the buffered
1054    /// test as proof for them.
1055    #[cfg(unix)]
1056    #[tokio::test]
1057    async fn cleared_environment_reaches_stdin_and_cancellable_runs() {
1058        let capture = crate::test_support::EnvCapture::new("env-specialized");
1059        let codex = crate::test_support::env_capturing_codex(&capture)
1060            .env("CODEX_WRAPPER_EXPLICIT", "specialized")
1061            .clear_env()
1062            .build()
1063            .expect("bash must exist");
1064
1065        crate::ExecCommand::new("stdin prompt")
1066            .prompt_via_stdin()
1067            .execute(&codex)
1068            .await
1069            .unwrap();
1070        let stdin_environment = capture.read();
1071        assert!(!stdin_environment.contains_key("PATH"));
1072        assert_eq!(
1073            stdin_environment
1074                .get("CODEX_WRAPPER_EXPLICIT")
1075                .map(String::as_str),
1076            Some("specialized")
1077        );
1078
1079        let never = std::future::pending::<()>();
1080        run_codex_cancellable(&codex, crate::ExecCommand::new("cancellable").args(), never)
1081            .await
1082            .unwrap();
1083        let cancellable_environment = capture.read();
1084        assert!(!cancellable_environment.contains_key("PATH"));
1085        assert_eq!(
1086            cancellable_environment
1087                .get("CODEX_WRAPPER_EXPLICIT")
1088                .map(String::as_str),
1089            Some("specialized")
1090        );
1091    }
1092
1093    #[cfg(unix)]
1094    #[tokio::test]
1095    async fn environment_values_do_not_leak_into_spawn_errors() {
1096        let secret = "spawn-error-must-not-leak-this";
1097        let codex = Codex::builder()
1098            .binary("/codex-wrapper/this-binary-does-not-exist")
1099            .clear_env()
1100            .env("CODEX_WRAPPER_SECRET", secret)
1101            .build()
1102            .unwrap();
1103
1104        let error = run_codex(&codex, vec!["exec".into()])
1105            .await
1106            .expect_err("the fake path must not spawn");
1107        assert!(!error.to_string().contains(secret));
1108        assert!(!format!("{error:?}").contains(secret));
1109    }
1110
1111    /// A wrapper timeout is not terminal until the direct child is reaped.
1112    #[cfg(unix)]
1113    #[tokio::test]
1114    async fn timeout_kills_the_spawned_process() {
1115        use crate::test_support::{PidFile, blocking_codex, is_running_for_test};
1116
1117        let pid_file = PidFile::new("exec-timeout");
1118        let codex = blocking_codex(&pid_file)
1119            .timeout(Duration::from_millis(500))
1120            .build()
1121            .expect("bash must exist");
1122
1123        let result = run_codex(&codex, vec!["exec".into(), "probe".into()]).await;
1124        assert!(
1125            matches!(result, Err(Error::Timeout { .. })),
1126            "expected timeout error, got: {result:?}"
1127        );
1128
1129        let pid = pid_file.read_pid().await;
1130        assert!(
1131            !is_running_for_test(pid),
1132            "codex ({pid}) survived the timeout"
1133        );
1134    }
1135
1136    /// The caller dropping the future is the case an operator kill or a
1137    /// graceful shutdown produces, with no wrapper timeout involved.
1138    #[cfg(unix)]
1139    #[tokio::test]
1140    async fn cancellation_kills_the_spawned_process() {
1141        use crate::test_support::{PidFile, blocking_codex, wait_until_gone};
1142
1143        let pid_file = PidFile::new("exec-cancel");
1144        let codex = blocking_codex(&pid_file).build().expect("bash must exist");
1145
1146        let cancelled = tokio::time::timeout(
1147            Duration::from_millis(500),
1148            run_codex(&codex, vec!["exec".into(), "probe".into()]),
1149        )
1150        .await;
1151        assert!(
1152            cancelled.is_err(),
1153            "fake codex should still have been running, got: {cancelled:?}"
1154        );
1155
1156        let pid = pid_file.read_pid().await;
1157        assert!(
1158            wait_until_gone(pid).await,
1159            "codex ({pid}) survived the dropped future"
1160        );
1161    }
1162
1163    /// Minimal recording subscriber, written by hand because #63 rules out a
1164    /// new dependency and `tracing-subscriber` would be one.
1165    ///
1166    /// Installed once as the **global** default, fanning out to a per-thread
1167    /// sink. A thread-local `set_default` is not enough: tracing caches
1168    /// callsite interest globally, so a test running concurrently against no
1169    /// subscriber caches this crate's span callsites as `never` and the
1170    /// recording test then sees nothing. That produced a real flake, about one
1171    /// run in three. A single always-enabled global subscriber keeps interest
1172    /// stable, and the per-thread sink keeps tests isolated.
1173    #[cfg(unix)]
1174    mod recorder {
1175        use std::cell::RefCell;
1176        use std::sync::{Arc, Mutex, Once};
1177
1178        use tracing::field::{Field, Visit};
1179        use tracing::span::{Attributes, Id, Record};
1180        use tracing::{Event, Metadata, Subscriber};
1181
1182        type Sink = Arc<Mutex<Vec<(String, String)>>>;
1183
1184        thread_local! {
1185            static SINK: RefCell<Option<Sink>> = const { RefCell::new(None) };
1186        }
1187
1188        struct Global;
1189
1190        impl Global {
1191            fn collect(f: impl FnOnce(&mut Vec<(String, String)>)) {
1192                SINK.with(|sink| {
1193                    if let Some(sink) = sink.borrow().as_ref() {
1194                        f(&mut sink.lock().unwrap());
1195                    }
1196                });
1197            }
1198        }
1199
1200        impl Subscriber for Global {
1201            /// Always true, so callsite interest is never cached as `never`.
1202            fn enabled(&self, _: &Metadata<'_>) -> bool {
1203                true
1204            }
1205            fn new_span(&self, attrs: &Attributes<'_>) -> Id {
1206                Self::collect(|fields| attrs.record(&mut Collect(fields)));
1207                Id::from_u64(1)
1208            }
1209            fn record(&self, _: &Id, values: &Record<'_>) {
1210                Self::collect(|fields| values.record(&mut Collect(fields)));
1211            }
1212            fn record_follows_from(&self, _: &Id, _: &Id) {}
1213            fn event(&self, _: &Event<'_>) {}
1214            fn enter(&self, _: &Id) {}
1215            fn exit(&self, _: &Id) {}
1216        }
1217
1218        struct Collect<'a>(&'a mut Vec<(String, String)>);
1219
1220        impl Visit for Collect<'_> {
1221            fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
1222                self.0.push((field.name().into(), format!("{value:?}")));
1223            }
1224            fn record_str(&mut self, field: &Field, value: &str) {
1225                self.0.push((field.name().into(), value.into()));
1226            }
1227            fn record_i64(&mut self, field: &Field, value: i64) {
1228                self.0.push((field.name().into(), value.to_string()));
1229            }
1230            fn record_u64(&mut self, field: &Field, value: u64) {
1231                self.0.push((field.name().into(), value.to_string()));
1232            }
1233        }
1234
1235        pub(super) struct Recorder(Sink);
1236
1237        impl Recorder {
1238            /// Start recording spans raised on this thread.
1239            pub(super) fn install() -> Self {
1240                static INIT: Once = Once::new();
1241                INIT.call_once(|| {
1242                    let _ = tracing::subscriber::set_global_default(Global);
1243                });
1244                let sink: Sink = Arc::new(Mutex::new(Vec::new()));
1245                SINK.with(|slot| *slot.borrow_mut() = Some(Arc::clone(&sink)));
1246                Self(sink)
1247            }
1248
1249            pub(super) fn dump(&self) -> String {
1250                format!("{:?}", self.0.lock().unwrap())
1251            }
1252
1253            pub(super) fn value(&self, field: &str) -> Option<String> {
1254                self.0
1255                    .lock()
1256                    .unwrap()
1257                    .iter()
1258                    .rev()
1259                    .find(|(name, _)| name == field)
1260                    .map(|(_, value)| value.clone())
1261            }
1262        }
1263
1264        impl Drop for Recorder {
1265            fn drop(&mut self) {
1266                SINK.with(|slot| *slot.borrow_mut() = None);
1267            }
1268        }
1269    }
1270
1271    #[cfg(unix)]
1272    #[tokio::test]
1273    async fn span_records_the_subcommand_and_a_clean_outcome() {
1274        let recorder = recorder::Recorder::install();
1275
1276        let codex = Codex::builder()
1277            .binary("/bin/echo")
1278            .build()
1279            .expect("echo must exist");
1280        run_codex(&codex, vec!["exec".into()]).await.unwrap();
1281
1282        assert_eq!(recorder.value("subcommand").as_deref(), Some("exec"));
1283        assert_eq!(recorder.value("outcome").as_deref(), Some("ok"));
1284        assert_eq!(recorder.value("exit_code").as_deref(), Some("0"));
1285        assert!(recorder.value("duration_ms").is_some());
1286    }
1287
1288    /// The prompt must never reach the span. It is in argv, so recording the
1289    /// arguments would leak it into any host's logs.
1290    #[cfg(unix)]
1291    #[tokio::test]
1292    async fn span_does_not_carry_the_prompt() {
1293        let recorder = recorder::Recorder::install();
1294
1295        let codex = Codex::builder()
1296            .binary("/bin/echo")
1297            .build()
1298            .expect("echo must exist");
1299        run_codex(&codex, vec!["exec".into(), "a very secret prompt".into()])
1300            .await
1301            .unwrap();
1302
1303        let recorded = format!("{:?}", recorder.value("subcommand"));
1304        assert!(!recorded.contains("secret"));
1305        for field in ["binary", "working_dir", "outcome", "exit_code"] {
1306            let value = recorder.value(field).unwrap_or_default();
1307            assert!(
1308                !value.contains("secret"),
1309                "{field} leaked the prompt: {value}"
1310            );
1311        }
1312    }
1313
1314    /// A dropped future records an outcome rather than leaving the span open,
1315    /// so an abandoned run is distinguishable from one still in progress.
1316    #[cfg(unix)]
1317    #[tokio::test]
1318    async fn a_cancelled_run_is_recorded_as_cancelled() {
1319        let recorder = recorder::Recorder::install();
1320
1321        let pid_file = crate::test_support::PidFile::new("span-cancel");
1322        let codex = crate::test_support::blocking_codex(&pid_file)
1323            .build()
1324            .expect("bash must exist");
1325
1326        let cancelled = tokio::time::timeout(
1327            Duration::from_millis(300),
1328            run_codex(&codex, vec!["exec".into()]),
1329        )
1330        .await;
1331        assert!(cancelled.is_err(), "the run should still have been going");
1332
1333        assert_eq!(
1334            recorder.value("outcome").as_deref(),
1335            Some("cancelled"),
1336            "recorded: {}",
1337            recorder.dump()
1338        );
1339    }
1340
1341    /// A wrapper timeout is its own outcome, distinct from a caller
1342    /// cancelling: the run reached its deadline rather than being abandoned.
1343    #[cfg(unix)]
1344    #[tokio::test]
1345    async fn a_timed_out_run_is_recorded_as_timeout() {
1346        let recorder = recorder::Recorder::install();
1347
1348        let pid_file = crate::test_support::PidFile::new("span-timeout");
1349        let codex = crate::test_support::blocking_codex(&pid_file)
1350            .timeout(Duration::from_millis(300))
1351            .build()
1352            .expect("bash must exist");
1353
1354        let result = run_codex(&codex, vec!["exec".into()]).await;
1355        assert!(matches!(result, Err(Error::Timeout { .. })), "{result:?}");
1356
1357        assert_eq!(recorder.value("outcome").as_deref(), Some("timeout"));
1358    }
1359
1360    // -----------------------------------------------------------------
1361    // Classification end to end (#85)
1362    // -----------------------------------------------------------------
1363
1364    #[cfg(unix)]
1365    fn failing_codex(case: &str) -> Codex {
1366        let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1367            .join("tests")
1368            .join("fake-codex-failure.sh");
1369        Codex::builder()
1370            .binary("/bin/bash")
1371            .arg(script.to_str().unwrap())
1372            .env("CODEX_WRAPPER_TEST_FAILURE", case)
1373            .build()
1374            .expect("bash must exist")
1375    }
1376
1377    /// Classification has to happen on the spawn path, not only in the
1378    /// constructor, or a caller still gets a bare CommandFailed.
1379    #[cfg(unix)]
1380    #[tokio::test]
1381    async fn a_real_spawn_returns_a_classified_error() {
1382        use crate::error::FailureKind;
1383
1384        for (case, expected) in [
1385            ("auth", FailureKind::Auth),
1386            ("not-trusted", FailureKind::NotTrustedDirectory),
1387            ("config", FailureKind::Config),
1388            ("session", FailureKind::SessionNotFound),
1389            ("mystery", FailureKind::Unclassified),
1390        ] {
1391            let codex = failing_codex(case);
1392            let err = run_codex(&codex, vec!["exec".into()]).await.unwrap_err();
1393            assert_eq!(err.failure_kind(), Some(expected), "case {case}: {err}");
1394        }
1395    }
1396
1397    /// A deterministic failure must not be retried even when its exit code is
1398    /// on the retry list. Re-running gets the same rejection, and the auth
1399    /// case has already been retried inside the CLI before it reaches here.
1400    #[cfg(unix)]
1401    #[tokio::test]
1402    async fn a_classified_failure_is_not_retried() {
1403        let policy = crate::retry::RetryPolicy::new()
1404            .max_attempts(3)
1405            .initial_backoff(Duration::from_millis(1))
1406            .retry_on_exit_codes([1]);
1407
1408        let started = Instant::now();
1409        let codex = failing_codex("auth");
1410        let err = run_codex_with_retry(&codex, vec!["exec".into()], Some(&policy))
1411            .await
1412            .unwrap_err();
1413
1414        assert!(matches!(err, Error::Auth { .. }), "{err}");
1415        // Three attempts with backoff would be visibly slower; this asserts
1416        // the shape rather than the timing, but the timing corroborates it.
1417        assert!(
1418            started.elapsed() < Duration::from_secs(2),
1419            "looks like it retried: {:?}",
1420            started.elapsed()
1421        );
1422    }
1423
1424    /// An unclassified failure keeps the old retry behaviour.
1425    #[cfg(unix)]
1426    #[tokio::test]
1427    async fn an_unclassified_failure_still_retries() {
1428        let policy = crate::retry::RetryPolicy::new()
1429            .max_attempts(2)
1430            .initial_backoff(Duration::from_millis(1))
1431            .retry_on_exit_codes([1]);
1432
1433        let codex = failing_codex("mystery");
1434        let err = run_codex_with_retry(&codex, vec!["exec".into()], Some(&policy))
1435            .await
1436            .unwrap_err();
1437
1438        assert!(matches!(err, Error::CommandFailed { .. }), "{err}");
1439    }
1440
1441    /// The allow-list works on the exit code, so it still applies to a
1442    /// failure that classification moved off CommandFailed.
1443    #[cfg(unix)]
1444    #[tokio::test]
1445    async fn allowed_exit_codes_still_apply_to_a_classified_failure() {
1446        let codex = failing_codex("auth");
1447        let output = run_codex_allow_exit_codes(&codex, vec!["exec".into()], &[1])
1448            .await
1449            .expect("exit code 1 was allowed");
1450
1451        assert_eq!(output.exit_code, 1);
1452        assert!(!output.success);
1453        assert!(
1454            output.stderr.contains("401 Unauthorized"),
1455            "{}",
1456            output.stderr
1457        );
1458    }
1459
1460    // -----------------------------------------------------------------
1461    // Process groups and cancellation (#78)
1462    // -----------------------------------------------------------------
1463
1464    /// A fake codex that spawns a child of its own, the way the real CLI does
1465    /// for tool use, and the pids of both.
1466    #[cfg(unix)]
1467    fn spawning_codex(label: &str) -> (Codex, crate::test_support::PidFile) {
1468        let pid_file = crate::test_support::PidFile::new(label);
1469        let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1470            .join("tests")
1471            .join("fake-codex-spawns-child.sh");
1472        let codex = Codex::builder()
1473            .binary("/bin/bash")
1474            .arg(script.to_str().unwrap())
1475            .env(
1476                "CODEX_WRAPPER_TEST_PIDFILE",
1477                pid_file.path().to_str().unwrap(),
1478            )
1479            .build()
1480            .expect("bash must exist");
1481        (codex, pid_file)
1482    }
1483
1484    #[cfg(unix)]
1485    async fn read_pids(pid_file: &crate::test_support::PidFile) -> (u32, u32) {
1486        for _ in 0..200 {
1487            if let Ok(contents) = std::fs::read_to_string(pid_file.path()) {
1488                let parse = |prefix: &str| -> Option<u32> {
1489                    contents
1490                        .lines()
1491                        .find_map(|l| l.strip_prefix(prefix))
1492                        .and_then(|v| v.trim().parse().ok())
1493                };
1494                if let (Some(parent), Some(child)) = (parse("parent="), parse("child=")) {
1495                    return (parent, child);
1496                }
1497            }
1498            tokio::time::sleep(Duration::from_millis(10)).await;
1499        }
1500        panic!("the fake codex never recorded both pids");
1501    }
1502
1503    /// The point of #78. `kill_on_drop` reaps the direct child only, so before
1504    /// process groups the grandchild outlived a cancelled run.
1505    #[cfg(unix)]
1506    #[tokio::test]
1507    async fn cancelling_kills_the_whole_process_group() {
1508        use crate::test_support::wait_until_gone;
1509
1510        let (codex, pid_file) = spawning_codex("group-drop");
1511
1512        let cancelled = tokio::time::timeout(
1513            Duration::from_millis(400),
1514            run_codex(&codex, vec!["exec".into()]),
1515        )
1516        .await;
1517        assert!(cancelled.is_err(), "the run should still have been going");
1518
1519        let (parent, child) = read_pids(&pid_file).await;
1520        assert!(wait_until_gone(parent).await, "codex ({parent}) survived");
1521        assert!(
1522            wait_until_gone(child).await,
1523            "the subprocess ({child}) survived the cancelled run"
1524        );
1525    }
1526
1527    /// The graceful path: SIGTERM, a grace period, then SIGKILL. It cannot
1528    /// live in `Drop`, which is why there is an explicit entry point.
1529    #[cfg(unix)]
1530    #[tokio::test]
1531    async fn run_codex_cancellable_stops_the_group_gracefully() {
1532        use crate::test_support::is_running_for_test;
1533
1534        let (codex, pid_file) = spawning_codex("group-cancel");
1535        let codex = Codex::builder()
1536            .binary(codex.binary())
1537            .arg(
1538                std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1539                    .join("tests")
1540                    .join("fake-codex-spawns-child.sh")
1541                    .to_str()
1542                    .unwrap(),
1543            )
1544            .env(
1545                "CODEX_WRAPPER_TEST_PIDFILE",
1546                pid_file.path().to_str().unwrap(),
1547            )
1548            .termination_grace(Duration::from_millis(50))
1549            .build()
1550            .unwrap();
1551
1552        let cancel = async {
1553            tokio::time::sleep(Duration::from_millis(300)).await;
1554        };
1555        let result = run_codex_cancellable(&codex, vec!["exec".into()], cancel).await;
1556
1557        assert!(
1558            matches!(result, Err(Error::Cancelled { .. })),
1559            "expected a cancellation, got: {result:?}"
1560        );
1561
1562        let (parent, child) = read_pids(&pid_file).await;
1563        assert!(!is_running_for_test(parent), "codex ({parent}) survived");
1564        assert!(
1565            !is_running_for_test(child),
1566            "the subprocess ({child}) survived cancellation"
1567        );
1568    }
1569
1570    /// Buffered timeout uses the same settled process-tree cleanup as an
1571    /// explicit cancellation signal.
1572    #[cfg(unix)]
1573    #[tokio::test]
1574    async fn timeout_stops_the_group_before_returning() {
1575        use crate::test_support::is_running_for_test;
1576
1577        let pid_file = crate::test_support::PidFile::new("group-timeout-settled");
1578        let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1579            .join("tests")
1580            .join("fake-codex-spawns-child.sh");
1581        let codex = Codex::builder()
1582            .binary("/bin/bash")
1583            .arg(script.to_str().unwrap())
1584            .env(
1585                "CODEX_WRAPPER_TEST_PIDFILE",
1586                pid_file.path().to_str().unwrap(),
1587            )
1588            .timeout(Duration::from_millis(300))
1589            .termination_grace(Duration::from_millis(10))
1590            .build()
1591            .unwrap();
1592
1593        let result = run_codex(&codex, vec!["exec".into()]).await;
1594        assert!(
1595            matches!(result, Err(Error::Timeout { .. })),
1596            "expected a timeout, got: {result:?}"
1597        );
1598
1599        let (parent, child) = read_pids(&pid_file).await;
1600        assert!(!is_running_for_test(parent), "codex ({parent}) survived");
1601        assert!(
1602            !is_running_for_test(child),
1603            "the subprocess ({child}) survived the timeout"
1604        );
1605    }
1606
1607    /// A broken stdin pipe is terminal only after the process tree has been
1608    /// stopped. Returning the write error first would release the caller while
1609    /// the provider could still mutate the workspace.
1610    #[cfg(unix)]
1611    #[tokio::test]
1612    async fn stdin_write_failure_stops_and_reaps_before_returning() {
1613        use crate::test_support::is_running_for_test;
1614
1615        let pid_file = crate::test_support::PidFile::new("stdin-write-failure");
1616        let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1617            .join("tests")
1618            .join("fake-codex-closes-stdin-spawns-child.sh");
1619        let codex = Codex::builder()
1620            .binary("/bin/bash")
1621            .arg(script.to_str().unwrap())
1622            .env(
1623                "CODEX_WRAPPER_TEST_PIDFILE",
1624                pid_file.path().to_str().unwrap(),
1625            )
1626            .termination_grace(Duration::from_millis(10))
1627            .build()
1628            .unwrap();
1629
1630        let prompt = "x".repeat(4 * 1024 * 1024);
1631        let result = run_codex_with_stdin_prompt(
1632            &codex,
1633            crate::ExecCommand::from_stdin(&prompt).args(),
1634            &prompt,
1635        )
1636        .await;
1637        assert!(
1638            matches!(result, Err(Error::Io { ref message, .. }) if message.contains("stdin")),
1639            "expected a stdin error, got: {result:?}"
1640        );
1641
1642        let (parent, child) = read_pids(&pid_file).await;
1643        assert!(!is_running_for_test(parent), "codex ({parent}) survived");
1644        assert!(
1645            !is_running_for_test(child),
1646            "the subprocess ({child}) survived the stdin failure"
1647        );
1648    }
1649
1650    /// A run that finishes before the signal must not be reported as
1651    /// cancelled, and must not be killed on the way out.
1652    #[cfg(unix)]
1653    #[tokio::test]
1654    async fn a_run_that_finishes_first_is_not_cancelled() {
1655        let codex = Codex::builder()
1656            .binary("/bin/echo")
1657            .build()
1658            .expect("echo must exist");
1659
1660        let never = std::future::pending::<()>();
1661        let output = run_codex_cancellable(&codex, vec!["exec".into()], never)
1662            .await
1663            .unwrap();
1664        assert!(output.success);
1665    }
1666
1667    /// Opting out is not cosmetic: without a group of its own, cancelling
1668    /// reaches the direct child only and its subprocesses survive. That is the
1669    /// terminal-attached contract, where the terminal is the supervisor and
1670    /// Ctrl-C reaches the whole run directly instead.
1671    #[cfg(unix)]
1672    #[tokio::test]
1673    async fn opting_out_of_process_groups_leaves_the_subprocess() {
1674        use crate::test_support::wait_until_gone;
1675
1676        let pid_file = crate::test_support::PidFile::new("group-optout");
1677        let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1678            .join("tests")
1679            .join("fake-codex-spawns-child.sh");
1680        let codex = Codex::builder()
1681            .binary("/bin/bash")
1682            .arg(script.to_str().unwrap())
1683            .env(
1684                "CODEX_WRAPPER_TEST_PIDFILE",
1685                pid_file.path().to_str().unwrap(),
1686            )
1687            .process_group(false)
1688            .build()
1689            .expect("bash must exist");
1690
1691        let cancelled = tokio::time::timeout(
1692            Duration::from_millis(400),
1693            run_codex(&codex, vec!["exec".into()]),
1694        )
1695        .await;
1696        assert!(cancelled.is_err(), "the run should still have been going");
1697
1698        let (parent, child) = read_pids(&pid_file).await;
1699        assert!(
1700            wait_until_gone(parent).await,
1701            "kill_on_drop still reaps the direct child ({parent})"
1702        );
1703        // The point of the contrast with
1704        // `cancelling_kills_the_whole_process_group`.
1705        assert!(
1706            crate::test_support::is_running_for_test(child),
1707            "with groups off, the subprocess ({child}) is expected to survive"
1708        );
1709        // Do not leave it behind.
1710        signal_group(child, libc::SIGKILL);
1711        unsafe { libc::kill(i32::try_from(child).unwrap_or(0), libc::SIGKILL) };
1712    }
1713}