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