Skip to main content

codex_wrapper/command/
exec.rs

1use crate::Codex;
2use crate::command::CodexCommand;
3#[cfg(feature = "json")]
4use crate::error::Error;
5use crate::error::Result;
6use crate::exec::{self, CommandOutput};
7use crate::rollout_budget::RolloutBudgetConfig;
8use crate::types::{ApprovalPolicyConfig, Color, SandboxMode, WebSearchMode};
9#[cfg(feature = "json")]
10use crate::types::{JsonLineEvent, QueryResult};
11
12/// Push the typed config-key overrides shared by the exec-family builders.
13///
14/// `codex-cli` 0.145.0 removed `--ask-for-approval` and `--search` from the
15/// exec family; both settings moved to `-c` config keys. These are pushed
16/// before any caller-supplied [`config`](ExecCommand::config) strings because
17/// `-c` is last-wins, so a raw override still beats the typed setter.
18pub(crate) fn push_typed_config(
19    args: &mut Vec<String>,
20    approval_policy: Option<ApprovalPolicyConfig>,
21    web_search: Option<WebSearchMode>,
22) {
23    if let Some(policy) = approval_policy {
24        args.push("-c".into());
25        args.push(format!("approval_policy=\"{}\"", policy.as_config_value()));
26    }
27    if let Some(mode) = web_search {
28        args.push("-c".into());
29        args.push(format!("web_search=\"{}\"", mode.as_config_value()));
30    }
31}
32
33/// Resolve the sandbox mode, folding in the deprecated `full_auto` shim.
34///
35/// `--full-auto` is hidden on the exec family (and rejected outright by `fork`
36/// and `resume`); the CLI's own advice is `--sandbox workspace-write`. An
37/// explicit `sandbox()` call is more specific and wins.
38pub(crate) fn effective_sandbox(
39    sandbox: Option<SandboxMode>,
40    full_auto: bool,
41) -> Option<SandboxMode> {
42    sandbox.or(full_auto.then_some(SandboxMode::WorkspaceWrite))
43}
44
45/// Run Codex non-interactively (`codex exec <prompt>`).
46///
47/// This is the primary command for programmatic use. It supports the full
48/// range of exec flags: model selection, sandbox policy, images, config
49/// overrides, feature flags, JSON output, and more.
50///
51/// # Example
52///
53/// ```no_run
54/// use codex_wrapper::{Codex, CodexCommand, ExecCommand, SandboxMode};
55///
56/// # async fn example() -> codex_wrapper::Result<()> {
57/// let codex = Codex::builder().build()?;
58/// let output = ExecCommand::new("fix the failing test")
59///     .model("o3")
60///     .sandbox(SandboxMode::WorkspaceWrite)
61///     .ephemeral()
62///     .execute(&codex)
63///     .await?;
64/// println!("{}", output.stdout);
65/// # Ok(())
66/// # }
67/// ```
68#[derive(Debug, Clone)]
69pub struct ExecCommand {
70    approve_for_me: bool,
71    prompt: Option<String>,
72    prompt_via_stdin: bool,
73    approval_policy: Option<ApprovalPolicyConfig>,
74    web_search: Option<WebSearchMode>,
75    config_overrides: Vec<String>,
76    enabled_features: Vec<String>,
77    disabled_features: Vec<String>,
78    rollout_budget: Option<RolloutBudgetConfig>,
79    images: Vec<String>,
80    model: Option<String>,
81    oss: bool,
82    local_provider: Option<String>,
83    sandbox: Option<SandboxMode>,
84    strict_config: bool,
85    dangerously_bypass_hook_trust: bool,
86    ignore_user_config: bool,
87    ignore_rules: bool,
88    profile: Option<String>,
89    full_auto: bool,
90    dangerously_bypass_approvals_and_sandbox: bool,
91    cd: Option<String>,
92    skip_git_repo_check: bool,
93    add_dirs: Vec<String>,
94    ephemeral: bool,
95    output_schema: Option<String>,
96    color: Option<Color>,
97    json: bool,
98    output_last_message: Option<String>,
99    retry_policy: Option<crate::retry::RetryPolicy>,
100}
101
102impl ExecCommand {
103    /// Create a new exec command with the given prompt.
104    #[must_use]
105    pub fn new(prompt: impl Into<String>) -> Self {
106        Self {
107            approve_for_me: false,
108            prompt: Some(prompt.into()),
109            prompt_via_stdin: false,
110            approval_policy: None,
111            web_search: None,
112            config_overrides: Vec::new(),
113            enabled_features: Vec::new(),
114            disabled_features: Vec::new(),
115            rollout_budget: None,
116            images: Vec::new(),
117            model: None,
118            oss: false,
119            local_provider: None,
120            sandbox: None,
121            strict_config: false,
122            dangerously_bypass_hook_trust: false,
123            ignore_user_config: false,
124            ignore_rules: false,
125            profile: None,
126            full_auto: false,
127            dangerously_bypass_approvals_and_sandbox: false,
128            cd: None,
129            skip_git_repo_check: false,
130            add_dirs: Vec::new(),
131            ephemeral: false,
132            output_schema: None,
133            color: None,
134            json: false,
135            output_last_message: None,
136            retry_policy: None,
137        }
138    }
139
140    /// Send the prompt on stdin instead of as an argument (`codex exec -`).
141    ///
142    /// Shorthand for [`new`](Self::new) followed by
143    /// [`prompt_via_stdin`](Self::prompt_via_stdin). Use it for prompts that
144    /// are large or awkward to pass through argv.
145    ///
146    /// ```no_run
147    /// use codex_wrapper::{Codex, CodexCommand, ExecCommand};
148    ///
149    /// # async fn example() -> codex_wrapper::Result<()> {
150    /// let codex = Codex::builder().build()?;
151    /// let diff = std::fs::read_to_string("huge.patch")?;
152    /// let output = ExecCommand::from_stdin(format!("Review this patch:\n{diff}"))
153    ///     .execute(&codex)
154    ///     .await?;
155    /// # let _ = output;
156    /// # Ok(())
157    /// # }
158    /// ```
159    ///
160    /// Before 0.3 this took no argument and set the prompt to the literal
161    /// `-`, which could not work: nothing wrote to the child's stdin, so the
162    /// CLI saw an immediate EOF and an empty prompt (#81).
163    #[must_use]
164    pub fn from_stdin(prompt: impl Into<String>) -> Self {
165        Self::new(prompt).prompt_via_stdin()
166    }
167
168    /// Deliver this command's prompt on stdin rather than in argv.
169    ///
170    /// The prompt is replaced by `-` in the argument list and written to the
171    /// child's stdin instead.
172    ///
173    /// Retry does not apply to a stdin prompt, and any policy set on the
174    /// command or the client is ignored for it. A second attempt would need to
175    /// write the prompt again, into a pipe the first attempt has already
176    /// consumed, and retrying with an empty stdin would be worse than not
177    /// retrying.
178    #[must_use]
179    pub fn prompt_via_stdin(mut self) -> Self {
180        self.prompt_via_stdin = true;
181        self
182    }
183
184    /// The prompt to write to the child's stdin, if this command sends it
185    /// there. `None` when the prompt travels in argv.
186    ///
187    /// Only the streaming path needs this, and that path is `json`-gated.
188    #[cfg(feature = "json")]
189    pub(crate) fn stdin_prompt(&self) -> Option<&str> {
190        self.prompt_via_stdin
191            .then(|| self.prompt.as_deref().unwrap_or_default())
192    }
193
194    /// Override a config key (`-c key=value`).
195    ///
196    /// May be called multiple times to set several keys. Because `-c` is
197    /// last-wins, a key set here overrides the same key set by
198    /// [`approval_policy`](Self::approval_policy) or
199    /// [`search_mode`](Self::search_mode).
200    #[must_use]
201    pub fn config(mut self, key_value: impl Into<String>) -> Self {
202        self.config_overrides.push(key_value.into());
203        self
204    }
205
206    /// Set when the model asks for approval (`-c approval_policy="<value>"`).
207    ///
208    /// `codex-cli` 0.145.0 removed `--ask-for-approval` from `codex exec`; the
209    /// config key is the supported equivalent. Accepts an
210    /// [`ApprovalPolicy`](crate::ApprovalPolicy) directly, or an
211    /// [`ApprovalPolicyConfig`] for the two values the flag never took.
212    ///
213    /// ```
214    /// use codex_wrapper::{ApprovalPolicyConfig, CodexCommand, ExecCommand};
215    ///
216    /// let args = ExecCommand::new("hi")
217    ///     .approval_policy(ApprovalPolicyConfig::Never)
218    ///     .args();
219    /// assert!(args.windows(2).any(|w| w == ["-c", "approval_policy=\"never\""]));
220    /// ```
221    #[must_use]
222    pub fn approval_policy(mut self, policy: impl Into<ApprovalPolicyConfig>) -> Self {
223        self.approval_policy = Some(policy.into());
224        self
225    }
226
227    /// Enable live web search.
228    ///
229    /// Shorthand for `search_mode(WebSearchMode::Live)`, which is what the
230    /// removed `--search` flag meant.
231    #[must_use]
232    pub fn search(self) -> Self {
233        self.search_mode(WebSearchMode::Live)
234    }
235
236    /// Set the web search mode (`-c web_search="<value>"`).
237    ///
238    /// `codex-cli` 0.145.0 removed `--search` from `codex exec`; the config
239    /// key is the supported equivalent, and it is an enum rather than the
240    /// flag's boolean.
241    #[must_use]
242    pub fn search_mode(mut self, mode: WebSearchMode) -> Self {
243        self.web_search = Some(mode);
244        self
245    }
246
247    /// Enable an optional feature flag (`--enable <feature>`).
248    ///
249    /// May be called multiple times.
250    #[must_use]
251    pub fn enable(mut self, feature: impl Into<String>) -> Self {
252        self.enabled_features.push(feature.into());
253        self
254    }
255
256    /// Disable an optional feature flag (`--disable <feature>`).
257    ///
258    /// May be called multiple times.
259    #[must_use]
260    pub fn disable(mut self, feature: impl Into<String>) -> Self {
261        self.disabled_features.push(feature.into());
262        self
263    }
264
265    /// Enforce a Codex-native rollout-unit budget for this execution.
266    ///
267    /// Codex checks the budget at response boundaries, so one response can
268    /// cross the limit before the run stops. Codex 0.145-0.146 use weighted
269    /// output and non-cached input; starting with 0.147, a provider-supplied
270    /// rollout-unit value takes precedence when available. Neither is
271    /// portable total-token usage. See [`RolloutBudgetConfig`] for the exact
272    /// versioned contract.
273    ///
274    /// This typed override is emitted after raw config, and conflicting
275    /// `rollout_budget` feature toggles from both this command and its
276    /// [`crate::Codex`] client are suppressed. Codex applies feature toggles
277    /// after every `-c` value regardless of argv order, so retaining either
278    /// toggle would otherwise disable or replace this table.
279    #[must_use]
280    pub fn rollout_budget(mut self, budget: RolloutBudgetConfig) -> Self {
281        self.rollout_budget = Some(budget);
282        self
283    }
284
285    /// Attach an image to the prompt (`--image <path>`).
286    ///
287    /// May be called multiple times to attach several images.
288    #[must_use]
289    pub fn image(mut self, path: impl Into<String>) -> Self {
290        self.images.push(path.into());
291        self
292    }
293
294    /// Set the model to use (`--model <model>`).
295    ///
296    /// Panics if `model` is an empty string.
297    #[must_use]
298    pub fn model(mut self, model: impl Into<String>) -> Self {
299        let model = model.into();
300        assert!(!model.is_empty(), "model name must not be empty");
301        self.model = Some(model);
302        self
303    }
304
305    /// Use the OSS model tier (`--oss`).
306    #[must_use]
307    pub fn oss(mut self) -> Self {
308        self.oss = true;
309        self
310    }
311
312    /// Use a local model provider (`--local-provider <provider>`).
313    #[must_use]
314    pub fn local_provider(mut self, provider: impl Into<String>) -> Self {
315        self.local_provider = Some(provider.into());
316        self
317    }
318
319    /// Set the sandbox policy (`--sandbox <mode>`).
320    #[must_use]
321    pub fn sandbox(mut self, sandbox: SandboxMode) -> Self {
322        self.sandbox = Some(sandbox);
323        self
324    }
325
326    /// Error on unrecognized config keys (`--strict-config`).
327    #[must_use]
328    pub fn strict_config(mut self) -> Self {
329        self.strict_config = true;
330        self
331    }
332
333    /// Bypass the hook trust prompt (`--dangerously-bypass-hook-trust`).
334    ///
335    /// Allows configured hooks to run without confirmation. Use with caution.
336    #[must_use]
337    pub(crate) fn set_bypass_hook_trust(mut self) -> Self {
338        self.dangerously_bypass_hook_trust = true;
339        self
340    }
341
342    /// Ignore the user-level config file (`--ignore-user-config`).
343    #[must_use]
344    pub fn ignore_user_config(mut self) -> Self {
345        self.ignore_user_config = true;
346        self
347    }
348
349    /// Ignore project rules files (`--ignore-rules`).
350    #[must_use]
351    pub fn ignore_rules(mut self) -> Self {
352        self.ignore_rules = true;
353        self
354    }
355
356    /// Select a named configuration profile (`--profile <name>`).
357    #[must_use]
358    pub fn profile(mut self, profile: impl Into<String>) -> Self {
359        self.profile = Some(profile.into());
360        self
361    }
362
363    /// Run in full-auto mode, emitted as `--sandbox workspace-write`.
364    ///
365    /// `--full-auto` is deprecated upstream. `codex-cli` 0.145.0 hides it from
366    /// `codex exec --help` and warns when it is used:
367    ///
368    /// ```text
369    /// warning: `--full-auto` is deprecated; use `--sandbox workspace-write` instead.
370    /// ```
371    ///
372    /// This method emits the replacement the CLI names. An explicit
373    /// [`sandbox`](Self::sandbox) call is more specific and wins over it.
374    #[must_use]
375    pub fn full_auto(mut self) -> Self {
376        self.full_auto = true;
377        self
378    }
379
380    /// Route approval requests through automatic review, using the
381    /// workspace-write sandbox (`--approve-for-me`).
382    ///
383    /// Added in `codex-cli` 0.147.0. Older releases reject it as an unexpected
384    /// argument, so this is the one builder method with a floor above the
385    /// wrapper's tested minimum. `codex exec review` and `codex exec resume`
386    /// do not accept it.
387    #[must_use]
388    pub fn approve_for_me(mut self) -> Self {
389        self.approve_for_me = true;
390        self
391    }
392
393    /// Bypass all approval prompts and sandbox restrictions.
394    ///
395    /// Passes `--dangerously-bypass-approvals-and-sandbox`. Use with caution.
396    #[must_use]
397    pub(crate) fn set_bypass_approvals_and_sandbox(mut self) -> Self {
398        self.dangerously_bypass_approvals_and_sandbox = true;
399        self
400    }
401
402    /// Change the working directory before running (`--cd <dir>`).
403    #[must_use]
404    pub fn cd(mut self, dir: impl Into<String>) -> Self {
405        self.cd = Some(dir.into());
406        self
407    }
408
409    /// Skip the git repository check (`--skip-git-repo-check`).
410    #[must_use]
411    pub fn skip_git_repo_check(mut self) -> Self {
412        self.skip_git_repo_check = true;
413        self
414    }
415
416    /// Add an extra directory to the context (`--add-dir <dir>`).
417    ///
418    /// May be called multiple times.
419    #[must_use]
420    pub fn add_dir(mut self, dir: impl Into<String>) -> Self {
421        self.add_dirs.push(dir.into());
422        self
423    }
424
425    /// Run in ephemeral mode — no session is persisted (`--ephemeral`).
426    #[must_use]
427    pub fn ephemeral(mut self) -> Self {
428        self.ephemeral = true;
429        self
430    }
431
432    /// Require output to conform to a JSON schema (`--output-schema <path>`).
433    #[must_use]
434    pub fn output_schema(mut self, path: impl Into<String>) -> Self {
435        self.output_schema = Some(path.into());
436        self
437    }
438
439    /// Control terminal color output (`--color <mode>`).
440    #[must_use]
441    pub fn color(mut self, color: Color) -> Self {
442        self.color = Some(color);
443        self
444    }
445
446    /// Emit JSON Lines output (`--json`).
447    ///
448    /// When set, stdout will contain one JSON object per line. Use
449    /// [`execute_json_lines`](ExecCommand::execute_json_lines) to parse the
450    /// events automatically (requires the `json` feature).
451    #[must_use]
452    pub fn json(mut self) -> Self {
453        self.json = true;
454        self
455    }
456
457    /// Write the last assistant message to a file (`--output-last-message <path>`).
458    #[must_use]
459    pub fn output_last_message(mut self, path: impl Into<String>) -> Self {
460        self.output_last_message = Some(path.into());
461        self
462    }
463
464    /// Override the retry policy for this command.
465    ///
466    /// Takes precedence over the client-level policy set on [`Codex`].
467    #[must_use]
468    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
469        self.retry_policy = Some(policy);
470        self
471    }
472
473    /// Stream JSONL events from the command, invoking `handler` for each
474    /// parsed [`JsonLineEvent`] as it arrives.
475    ///
476    /// Automatically appends `--json` if not already set. Requires the `json`
477    /// feature.
478    ///
479    /// # Example
480    ///
481    /// ```no_run
482    /// use codex_wrapper::{Codex, ExecCommand, JsonLineEvent};
483    ///
484    /// # async fn example() -> codex_wrapper::Result<()> {
485    /// let codex = Codex::builder().build()?;
486    /// ExecCommand::new("what is 2+2?")
487    ///     .ephemeral()
488    ///     .stream(&codex, |event: JsonLineEvent| {
489    ///         println!("{}: {:?}", event.event_type, event.extra);
490    ///     })
491    ///     .await?;
492    /// # Ok(())
493    /// # }
494    /// ```
495    #[cfg(feature = "json")]
496    pub async fn stream<F>(&self, codex: &Codex, handler: F) -> Result<()>
497    where
498        F: FnMut(JsonLineEvent),
499    {
500        crate::streaming::stream_exec(codex, self, handler).await
501    }
502
503    /// Execute with an explicit cancellation signal.
504    ///
505    /// When `cancel` resolves, the wrapper terminates the owned process group,
506    /// awaits the direct child, and then returns [`Error::Cancelled`]. The
507    /// client timeout uses the same settled cleanup path. Retry does not
508    /// apply to cancellable execution.
509    pub async fn execute_cancellable<C>(&self, codex: &Codex, cancel: C) -> Result<CommandOutput>
510    where
511        C: std::future::Future<Output = ()> + Send,
512    {
513        if self.prompt_via_stdin {
514            let prompt = self.prompt.as_deref().unwrap_or_default();
515            return exec::run_codex_with_stdin_prompt_cancellable(
516                codex,
517                self.args(),
518                prompt,
519                cancel,
520            )
521            .await;
522        }
523        exec::run_codex_cancellable(codex, self.args(), cancel).await
524    }
525
526    /// Execute the command and parse the output as JSON Lines events.
527    ///
528    /// Automatically appends `--json` if not already set. Requires the `json`
529    /// feature.
530    #[cfg(feature = "json")]
531    pub async fn execute_json_lines(&self, codex: &Codex) -> Result<Vec<JsonLineEvent>> {
532        let mut args = self.args();
533        if !self.json {
534            args.push("--json".into());
535        }
536
537        let output = if self.prompt_via_stdin {
538            let prompt = self.prompt.as_deref().unwrap_or_default();
539            exec::run_codex_with_stdin_prompt(codex, args, prompt).await?
540        } else {
541            exec::run_codex_with_retry(codex, args, self.retry_policy.as_ref()).await?
542        };
543        parse_json_lines(&output.stdout)
544    }
545
546    /// Execute cancellably and parse the output as JSON Lines events.
547    ///
548    /// Automatically appends `--json` if not already set. Process cleanup is
549    /// complete before a cancellation or timeout error is returned.
550    #[cfg(feature = "json")]
551    pub async fn execute_json_lines_cancellable<C>(
552        &self,
553        codex: &Codex,
554        cancel: C,
555    ) -> Result<Vec<JsonLineEvent>>
556    where
557        C: std::future::Future<Output = ()> + Send,
558    {
559        let mut args = self.args();
560        if !self.json {
561            args.push("--json".into());
562        }
563
564        let output = if self.prompt_via_stdin {
565            let prompt = self.prompt.as_deref().unwrap_or_default();
566            exec::run_codex_with_stdin_prompt_cancellable(codex, args, prompt, cancel).await?
567        } else {
568            exec::run_codex_cancellable(codex, args, cancel).await?
569        };
570        parse_json_lines(&output.stdout)
571    }
572
573    /// Execute the command and return a typed [`QueryResult`].
574    ///
575    /// Assembles the final result text, ids, and token usage from the JSONL
576    /// event stream. Use [`execute_json_lines`](ExecCommand::execute_json_lines) for
577    /// the raw event stream. Requires the `json` feature.
578    #[cfg(feature = "json")]
579    pub async fn execute_json(&self, codex: &Codex) -> Result<QueryResult> {
580        let events = self.execute_json_lines(codex).await?;
581        Ok(QueryResult::from_events(events))
582    }
583
584    /// Execute cancellably and return a typed [`QueryResult`].
585    ///
586    /// The wrapper does not return a terminal cancellation or timeout result
587    /// until process cleanup has completed.
588    #[cfg(feature = "json")]
589    pub async fn execute_json_cancellable<C>(&self, codex: &Codex, cancel: C) -> Result<QueryResult>
590    where
591        C: std::future::Future<Output = ()> + Send,
592    {
593        let events = self.execute_json_lines_cancellable(codex, cancel).await?;
594        Ok(QueryResult::from_events(events))
595    }
596}
597
598impl CodexCommand for ExecCommand {
599    type Output = CommandOutput;
600
601    fn args(&self) -> Vec<String> {
602        let mut args = vec!["exec".to_string()];
603
604        push_typed_config(&mut args, self.approval_policy, self.web_search);
605        push_repeat(&mut args, "-c", &self.config_overrides);
606        push_feature_toggles(
607            &mut args,
608            &self.enabled_features,
609            &self.disabled_features,
610            self.rollout_budget.is_some(),
611        );
612        if let Some(budget) = &self.rollout_budget {
613            args.push("-c".into());
614            args.push(budget.config_override());
615        }
616        push_repeat(&mut args, "--image", &self.images);
617
618        if let Some(model) = &self.model {
619            args.push("--model".into());
620            args.push(model.clone());
621        }
622        if self.oss {
623            args.push("--oss".into());
624        }
625        if let Some(local_provider) = &self.local_provider {
626            args.push("--local-provider".into());
627            args.push(local_provider.clone());
628        }
629        if let Some(sandbox) = effective_sandbox(self.sandbox, self.full_auto) {
630            args.push("--sandbox".into());
631            args.push(sandbox.as_arg().into());
632        }
633        if self.strict_config {
634            args.push("--strict-config".into());
635        }
636        if let Some(profile) = &self.profile {
637            args.push("--profile".into());
638            args.push(profile.clone());
639        }
640        if self.approve_for_me {
641            args.push("--approve-for-me".into());
642        }
643        if self.dangerously_bypass_approvals_and_sandbox {
644            args.push("--dangerously-bypass-approvals-and-sandbox".into());
645        }
646        if self.dangerously_bypass_hook_trust {
647            args.push("--dangerously-bypass-hook-trust".into());
648        }
649        if let Some(cd) = &self.cd {
650            args.push("--cd".into());
651            args.push(cd.clone());
652        }
653        if self.skip_git_repo_check {
654            args.push("--skip-git-repo-check".into());
655        }
656        push_repeat(&mut args, "--add-dir", &self.add_dirs);
657        if self.ephemeral {
658            args.push("--ephemeral".into());
659        }
660        if self.ignore_user_config {
661            args.push("--ignore-user-config".into());
662        }
663        if self.ignore_rules {
664            args.push("--ignore-rules".into());
665        }
666        if let Some(output_schema) = &self.output_schema {
667            args.push("--output-schema".into());
668            args.push(output_schema.clone());
669        }
670        if let Some(color) = self.color {
671            args.push("--color".into());
672            args.push(color.as_arg().into());
673        }
674        if self.json {
675            args.push("--json".into());
676        }
677        if let Some(path) = &self.output_last_message {
678            args.push("--output-last-message".into());
679            args.push(path.clone());
680        }
681        if self.prompt_via_stdin {
682            // The prompt travels on stdin; `-` is how the CLI is told to read
683            // it from there.
684            args.push("-".into());
685        } else if let Some(prompt) = &self.prompt {
686            args.push(prompt.clone());
687        }
688
689        args
690    }
691
692    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
693        if self.prompt_via_stdin {
694            let prompt = self.prompt.as_deref().unwrap_or_default();
695            return exec::run_codex_with_stdin_prompt(codex, self.args(), prompt).await;
696        }
697        exec::run_codex_with_retry(codex, self.args(), self.retry_policy.as_ref()).await
698    }
699}
700
701/// Resume a previous non-interactive session (`codex exec resume`).
702///
703/// Use [`session_id`](ExecResumeCommand::session_id) to target a specific
704/// session, or [`last`](ExecResumeCommand::last) to pick the most recent.
705#[derive(Debug, Clone)]
706pub struct ExecResumeCommand {
707    session_id: Option<String>,
708    prompt: Option<String>,
709    prompt_via_stdin: bool,
710    last: bool,
711    all: bool,
712    approval_policy: Option<ApprovalPolicyConfig>,
713    web_search: Option<WebSearchMode>,
714    config_overrides: Vec<String>,
715    enabled_features: Vec<String>,
716    disabled_features: Vec<String>,
717    rollout_budget: Option<RolloutBudgetConfig>,
718    images: Vec<String>,
719    model: Option<String>,
720    strict_config: bool,
721    dangerously_bypass_hook_trust: bool,
722    ignore_user_config: bool,
723    ignore_rules: bool,
724    output_schema: Option<String>,
725    full_auto: bool,
726    dangerously_bypass_approvals_and_sandbox: bool,
727    skip_git_repo_check: bool,
728    ephemeral: bool,
729    json: bool,
730    output_last_message: Option<String>,
731    retry_policy: Option<crate::retry::RetryPolicy>,
732}
733
734impl ExecResumeCommand {
735    /// Create a new resume command with no options set.
736    #[must_use]
737    pub fn new() -> Self {
738        Self {
739            session_id: None,
740            prompt: None,
741            prompt_via_stdin: false,
742            last: false,
743            all: false,
744            approval_policy: None,
745            web_search: None,
746            config_overrides: Vec::new(),
747            enabled_features: Vec::new(),
748            disabled_features: Vec::new(),
749            rollout_budget: None,
750            images: Vec::new(),
751            model: None,
752            strict_config: false,
753            dangerously_bypass_hook_trust: false,
754            ignore_user_config: false,
755            ignore_rules: false,
756            output_schema: None,
757            full_auto: false,
758            dangerously_bypass_approvals_and_sandbox: false,
759            skip_git_repo_check: false,
760            ephemeral: false,
761            json: false,
762            output_last_message: None,
763            retry_policy: None,
764        }
765    }
766
767    /// Resume a specific session by its ID.
768    #[must_use]
769    pub fn session_id(mut self, session_id: impl Into<String>) -> Self {
770        self.session_id = Some(session_id.into());
771        self
772    }
773
774    /// Append an additional prompt to the resumed session.
775    #[must_use]
776    pub fn prompt(mut self, prompt: impl Into<String>) -> Self {
777        self.prompt = Some(prompt.into());
778        self
779    }
780
781    /// Create a resume command whose prompt is delivered on stdin.
782    ///
783    /// The prompt is replaced by `-` in the argument list and written to the
784    /// child's stdin instead. Select the session separately with
785    /// [`session_id`](Self::session_id) or [`last`](Self::last).
786    #[must_use]
787    pub fn from_stdin(prompt: impl Into<String>) -> Self {
788        Self::new().prompt(prompt).prompt_via_stdin()
789    }
790
791    /// Deliver this command's prompt on stdin rather than in argv.
792    ///
793    /// Retry does not apply to a stdin prompt. Any policy set on the command
794    /// or client is ignored because replaying a consumed pipe would not be a
795    /// faithful retry.
796    #[must_use]
797    pub fn prompt_via_stdin(mut self) -> Self {
798        self.prompt_via_stdin = true;
799        self
800    }
801
802    /// The prompt to write to the child's stdin, if this command sends it
803    /// there. `None` when the prompt travels in argv.
804    #[cfg(feature = "json")]
805    pub(crate) fn stdin_prompt(&self) -> Option<&str> {
806        self.prompt_via_stdin
807            .then(|| self.prompt.as_deref().unwrap_or_default())
808    }
809
810    /// Resume the most recent session (`--last`).
811    #[must_use]
812    pub fn last(mut self) -> Self {
813        self.last = true;
814        self
815    }
816
817    /// Resume all sessions (`--all`).
818    #[must_use]
819    pub fn all(mut self) -> Self {
820        self.all = true;
821        self
822    }
823
824    /// Set the model to use (`--model <model>`).
825    ///
826    /// Panics if `model` is an empty string.
827    #[must_use]
828    pub fn model(mut self, model: impl Into<String>) -> Self {
829        let model = model.into();
830        assert!(!model.is_empty(), "model name must not be empty");
831        self.model = Some(model);
832        self
833    }
834
835    /// Attach an image to the prompt (`--image <path>`).
836    ///
837    /// May be called multiple times to attach several images.
838    #[must_use]
839    pub fn image(mut self, path: impl Into<String>) -> Self {
840        self.images.push(path.into());
841        self
842    }
843
844    /// Emit JSON Lines output (`--json`).
845    #[must_use]
846    pub fn json(mut self) -> Self {
847        self.json = true;
848        self
849    }
850
851    /// Write the last assistant message to a file (`--output-last-message <path>`).
852    #[must_use]
853    pub fn output_last_message(mut self, path: impl Into<String>) -> Self {
854        self.output_last_message = Some(path.into());
855        self
856    }
857
858    /// Override a config key (`-c key=value`).
859    ///
860    /// May be called multiple times to set several keys. Because `-c` is
861    /// last-wins, a key set here overrides the same key set by
862    /// [`approval_policy`](Self::approval_policy),
863    /// [`search_mode`](Self::search_mode), or [`full_auto`](Self::full_auto).
864    #[must_use]
865    pub fn config(mut self, key_value: impl Into<String>) -> Self {
866        self.config_overrides.push(key_value.into());
867        self
868    }
869
870    /// Set when the model asks for approval (`-c approval_policy="<value>"`).
871    ///
872    /// `codex-cli` 0.145.0 removed `--ask-for-approval` from the exec family;
873    /// the config key is the supported equivalent. Accepts an
874    /// [`ApprovalPolicy`](crate::ApprovalPolicy) directly, or an
875    /// [`ApprovalPolicyConfig`] for the two values the flag never took.
876    #[must_use]
877    pub fn approval_policy(mut self, policy: impl Into<ApprovalPolicyConfig>) -> Self {
878        self.approval_policy = Some(policy.into());
879        self
880    }
881
882    /// Enable live web search.
883    ///
884    /// Shorthand for `search_mode(WebSearchMode::Live)`, which is what the
885    /// removed `--search` flag meant.
886    #[must_use]
887    pub fn search(self) -> Self {
888        self.search_mode(WebSearchMode::Live)
889    }
890
891    /// Set the web search mode (`-c web_search="<value>"`).
892    ///
893    /// `codex-cli` 0.145.0 removed `--search` from the exec family; the config
894    /// key is the supported equivalent, and it is an enum rather than the
895    /// flag's boolean.
896    #[must_use]
897    pub fn search_mode(mut self, mode: WebSearchMode) -> Self {
898        self.web_search = Some(mode);
899        self
900    }
901
902    /// Enable an optional feature flag (`--enable <feature>`).
903    ///
904    /// May be called multiple times.
905    #[must_use]
906    pub fn enable(mut self, feature: impl Into<String>) -> Self {
907        self.enabled_features.push(feature.into());
908        self
909    }
910
911    /// Disable an optional feature flag (`--disable <feature>`).
912    ///
913    /// May be called multiple times.
914    #[must_use]
915    pub fn disable(mut self, feature: impl Into<String>) -> Self {
916        self.disabled_features.push(feature.into());
917        self
918    }
919
920    /// Enforce a Codex-native rollout-unit budget for this resumed execution.
921    ///
922    /// The meter and response-boundary overshoot are identical to
923    /// [`ExecCommand::rollout_budget`]. Emitting the same config on resume is
924    /// required: a budget applied only to the opening process does not carry
925    /// into a later `codex exec resume` process.
926    #[must_use]
927    pub fn rollout_budget(mut self, budget: RolloutBudgetConfig) -> Self {
928        self.rollout_budget = Some(budget);
929        self
930    }
931
932    /// Error on unrecognized config keys (`--strict-config`).
933    #[must_use]
934    pub fn strict_config(mut self) -> Self {
935        self.strict_config = true;
936        self
937    }
938
939    /// Bypass the hook trust prompt (`--dangerously-bypass-hook-trust`).
940    ///
941    /// Allows configured hooks to run without confirmation. Use with caution.
942    #[must_use]
943    pub(crate) fn set_bypass_hook_trust(mut self) -> Self {
944        self.dangerously_bypass_hook_trust = true;
945        self
946    }
947
948    /// Ignore the user-level config file (`--ignore-user-config`).
949    #[must_use]
950    pub fn ignore_user_config(mut self) -> Self {
951        self.ignore_user_config = true;
952        self
953    }
954
955    /// Ignore project rules files (`--ignore-rules`).
956    #[must_use]
957    pub fn ignore_rules(mut self) -> Self {
958        self.ignore_rules = true;
959        self
960    }
961
962    /// Require output to conform to a JSON schema (`--output-schema <path>`).
963    #[must_use]
964    pub fn output_schema(mut self, path: impl Into<String>) -> Self {
965        self.output_schema = Some(path.into());
966        self
967    }
968
969    /// Run in full-auto mode, emitted as `-c sandbox_mode="workspace-write"`.
970    ///
971    /// `--full-auto` is deprecated upstream; `codex-cli` 0.145.0 hides it and
972    /// warns to use `--sandbox workspace-write` instead. `codex exec resume`
973    /// has no `--sandbox` flag, so this sets the equivalent config key.
974    #[must_use]
975    pub fn full_auto(mut self) -> Self {
976        self.full_auto = true;
977        self
978    }
979
980    /// Bypass all approval prompts and sandbox restrictions.
981    ///
982    /// Passes `--dangerously-bypass-approvals-and-sandbox`. Use with caution.
983    #[must_use]
984    pub(crate) fn set_bypass_approvals_and_sandbox(mut self) -> Self {
985        self.dangerously_bypass_approvals_and_sandbox = true;
986        self
987    }
988
989    /// Skip the git repository check (`--skip-git-repo-check`).
990    #[must_use]
991    pub fn skip_git_repo_check(mut self) -> Self {
992        self.skip_git_repo_check = true;
993        self
994    }
995
996    /// Run in ephemeral mode — no session is persisted (`--ephemeral`).
997    #[must_use]
998    pub fn ephemeral(mut self) -> Self {
999        self.ephemeral = true;
1000        self
1001    }
1002
1003    /// Override the retry policy for this command.
1004    ///
1005    /// Takes precedence over the client-level policy set on [`Codex`].
1006    #[must_use]
1007    pub fn retry(mut self, policy: crate::retry::RetryPolicy) -> Self {
1008        self.retry_policy = Some(policy);
1009        self
1010    }
1011
1012    /// Execute this resumed turn with an explicit cancellation signal.
1013    ///
1014    /// When `cancel` resolves, the wrapper terminates the owned process group,
1015    /// awaits the direct child, and then returns [`Error::Cancelled`]. The
1016    /// client timeout uses the same settled cleanup path. Retry does not
1017    /// apply to cancellable execution.
1018    pub async fn execute_cancellable<C>(&self, codex: &Codex, cancel: C) -> Result<CommandOutput>
1019    where
1020        C: std::future::Future<Output = ()> + Send,
1021    {
1022        if self.prompt_via_stdin {
1023            let prompt = self.prompt.as_deref().unwrap_or_default();
1024            return exec::run_codex_with_stdin_prompt_cancellable(
1025                codex,
1026                self.args(),
1027                prompt,
1028                cancel,
1029            )
1030            .await;
1031        }
1032        exec::run_codex_cancellable(codex, self.args(), cancel).await
1033    }
1034
1035    /// Execute the command and parse the output as JSON Lines events.
1036    ///
1037    /// Automatically appends `--json` if not already set. Requires the `json`
1038    /// feature.
1039    #[cfg(feature = "json")]
1040    pub async fn execute_json_lines(&self, codex: &Codex) -> Result<Vec<JsonLineEvent>> {
1041        let mut args = self.args();
1042        if !self.json {
1043            args.push("--json".into());
1044        }
1045
1046        let output = if self.prompt_via_stdin {
1047            let prompt = self.prompt.as_deref().unwrap_or_default();
1048            exec::run_codex_with_stdin_prompt(codex, args, prompt).await?
1049        } else {
1050            exec::run_codex_with_retry(codex, args, self.retry_policy.as_ref()).await?
1051        };
1052        parse_json_lines(&output.stdout)
1053    }
1054
1055    /// Execute this resumed turn cancellably and parse JSON Lines events.
1056    ///
1057    /// Automatically appends `--json` if not already set. Process cleanup is
1058    /// complete before a cancellation or timeout error is returned.
1059    #[cfg(feature = "json")]
1060    pub async fn execute_json_lines_cancellable<C>(
1061        &self,
1062        codex: &Codex,
1063        cancel: C,
1064    ) -> Result<Vec<JsonLineEvent>>
1065    where
1066        C: std::future::Future<Output = ()> + Send,
1067    {
1068        let mut args = self.args();
1069        if !self.json {
1070            args.push("--json".into());
1071        }
1072
1073        let output = if self.prompt_via_stdin {
1074            let prompt = self.prompt.as_deref().unwrap_or_default();
1075            exec::run_codex_with_stdin_prompt_cancellable(codex, args, prompt, cancel).await?
1076        } else {
1077            exec::run_codex_cancellable(codex, args, cancel).await?
1078        };
1079        parse_json_lines(&output.stdout)
1080    }
1081
1082    /// Execute the resume command and return a typed [`QueryResult`].
1083    ///
1084    /// Assembles the final result text, ids, and token usage from the JSONL
1085    /// event stream. Requires the `json` feature.
1086    #[cfg(feature = "json")]
1087    pub async fn execute_json(&self, codex: &Codex) -> Result<QueryResult> {
1088        let events = self.execute_json_lines(codex).await?;
1089        Ok(QueryResult::from_events(events))
1090    }
1091
1092    /// Execute this resumed turn cancellably and return a typed [`QueryResult`].
1093    ///
1094    /// The wrapper does not return a terminal cancellation or timeout result
1095    /// until process cleanup has completed.
1096    #[cfg(feature = "json")]
1097    pub async fn execute_json_cancellable<C>(&self, codex: &Codex, cancel: C) -> Result<QueryResult>
1098    where
1099        C: std::future::Future<Output = ()> + Send,
1100    {
1101        let events = self.execute_json_lines_cancellable(codex, cancel).await?;
1102        Ok(QueryResult::from_events(events))
1103    }
1104
1105    /// Stream JSONL events from the resume command, invoking `handler` for
1106    /// each parsed [`JsonLineEvent`] as it arrives.
1107    ///
1108    /// Automatically appends `--json` if not already set. Requires the `json`
1109    /// feature.
1110    #[cfg(feature = "json")]
1111    pub async fn stream<F>(&self, codex: &Codex, handler: F) -> Result<()>
1112    where
1113        F: FnMut(JsonLineEvent),
1114    {
1115        crate::streaming::stream_exec_resume(codex, self, handler).await
1116    }
1117}
1118
1119impl Default for ExecResumeCommand {
1120    fn default() -> Self {
1121        Self::new()
1122    }
1123}
1124
1125impl CodexCommand for ExecResumeCommand {
1126    type Output = CommandOutput;
1127
1128    fn args(&self) -> Vec<String> {
1129        let mut args = vec!["exec".into(), "resume".into()];
1130        push_typed_config(&mut args, self.approval_policy, self.web_search);
1131        // `exec resume` has no `--sandbox` flag, so the `--full-auto`
1132        // replacement has to go through the config key.
1133        if self.full_auto {
1134            args.push("-c".into());
1135            args.push(format!(
1136                "sandbox_mode=\"{}\"",
1137                SandboxMode::WorkspaceWrite.as_arg()
1138            ));
1139        }
1140        push_repeat(&mut args, "-c", &self.config_overrides);
1141        push_feature_toggles(
1142            &mut args,
1143            &self.enabled_features,
1144            &self.disabled_features,
1145            self.rollout_budget.is_some(),
1146        );
1147        if let Some(budget) = &self.rollout_budget {
1148            args.push("-c".into());
1149            args.push(budget.config_override());
1150        }
1151        if self.last {
1152            args.push("--last".into());
1153        }
1154        if self.all {
1155            args.push("--all".into());
1156        }
1157        push_repeat(&mut args, "--image", &self.images);
1158        if let Some(model) = &self.model {
1159            args.push("--model".into());
1160            args.push(model.clone());
1161        }
1162        if self.strict_config {
1163            args.push("--strict-config".into());
1164        }
1165        if self.dangerously_bypass_approvals_and_sandbox {
1166            args.push("--dangerously-bypass-approvals-and-sandbox".into());
1167        }
1168        if self.dangerously_bypass_hook_trust {
1169            args.push("--dangerously-bypass-hook-trust".into());
1170        }
1171        if self.skip_git_repo_check {
1172            args.push("--skip-git-repo-check".into());
1173        }
1174        if self.ephemeral {
1175            args.push("--ephemeral".into());
1176        }
1177        if self.ignore_user_config {
1178            args.push("--ignore-user-config".into());
1179        }
1180        if self.ignore_rules {
1181            args.push("--ignore-rules".into());
1182        }
1183        if let Some(output_schema) = &self.output_schema {
1184            args.push("--output-schema".into());
1185            args.push(output_schema.clone());
1186        }
1187        if self.json {
1188            args.push("--json".into());
1189        }
1190        if let Some(path) = &self.output_last_message {
1191            args.push("--output-last-message".into());
1192            args.push(path.clone());
1193        }
1194        if let Some(session_id) = &self.session_id {
1195            args.push(session_id.clone());
1196        }
1197        if self.prompt_via_stdin {
1198            args.push("-".into());
1199        } else if let Some(prompt) = &self.prompt {
1200            args.push(prompt.clone());
1201        }
1202        args
1203    }
1204
1205    async fn execute(&self, codex: &Codex) -> Result<CommandOutput> {
1206        if self.prompt_via_stdin {
1207            let prompt = self.prompt.as_deref().unwrap_or_default();
1208            return exec::run_codex_with_stdin_prompt(codex, self.args(), prompt).await;
1209        }
1210        exec::run_codex_with_retry(codex, self.args(), self.retry_policy.as_ref()).await
1211    }
1212}
1213
1214fn push_repeat(args: &mut Vec<String>, flag: &str, values: &[String]) {
1215    for value in values {
1216        args.push(flag.into());
1217        args.push(value.clone());
1218    }
1219}
1220
1221fn push_feature_toggles(
1222    args: &mut Vec<String>,
1223    enabled: &[String],
1224    disabled: &[String],
1225    protects_rollout_budget: bool,
1226) {
1227    let keep = |feature: &&String| !protects_rollout_budget || feature.as_str() != "rollout_budget";
1228    for feature in enabled.iter().filter(keep) {
1229        args.push("--enable".into());
1230        args.push(feature.clone());
1231    }
1232    for feature in disabled.iter().filter(keep) {
1233        args.push("--disable".into());
1234        args.push(feature.clone());
1235    }
1236}
1237
1238#[cfg(feature = "json")]
1239fn parse_json_lines(stdout: &str) -> Result<Vec<JsonLineEvent>> {
1240    stdout
1241        .lines()
1242        .filter(|line| line.trim_start().starts_with('{'))
1243        .map(|line| {
1244            serde_json::from_str(line).map_err(|source| Error::Json {
1245                message: format!("failed to parse JSONL event: {line}"),
1246                source,
1247            })
1248        })
1249        .collect()
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255    use crate::types::ApprovalPolicy;
1256
1257    #[test]
1258    fn exec_args() {
1259        let args = ExecCommand::new("fix the test")
1260            .model("gpt-5")
1261            .sandbox(SandboxMode::WorkspaceWrite)
1262            .strict_config()
1263            .skip_git_repo_check()
1264            .ephemeral()
1265            .ignore_user_config()
1266            .ignore_rules()
1267            .json()
1268            .args();
1269
1270        assert_eq!(
1271            args,
1272            vec![
1273                "exec",
1274                "--model",
1275                "gpt-5",
1276                "--sandbox",
1277                "workspace-write",
1278                "--strict-config",
1279                "--skip-git-repo-check",
1280                "--ephemeral",
1281                "--ignore-user-config",
1282                "--ignore-rules",
1283                "--json",
1284                "fix the test",
1285            ]
1286        );
1287    }
1288
1289    #[test]
1290    fn exec_args_hook_trust() {
1291        let args = ExecCommand::new("go")
1292            .set_bypass_approvals_and_sandbox()
1293            .set_bypass_hook_trust()
1294            .args();
1295
1296        assert_eq!(
1297            args,
1298            vec![
1299                "exec",
1300                "--dangerously-bypass-approvals-and-sandbox",
1301                "--dangerously-bypass-hook-trust",
1302                "go",
1303            ]
1304        );
1305    }
1306
1307    #[test]
1308    #[should_panic(expected = "model name must not be empty")]
1309    fn exec_model_empty_panics() {
1310        let _ = ExecCommand::new("prompt").model("");
1311    }
1312
1313    #[test]
1314    #[should_panic(expected = "model name must not be empty")]
1315    fn exec_resume_model_empty_panics() {
1316        let _ = ExecResumeCommand::new().model("");
1317    }
1318
1319    #[test]
1320    fn exec_resume_args() {
1321        let args = ExecResumeCommand::new()
1322            .last()
1323            .model("gpt-5")
1324            .json()
1325            .prompt("continue")
1326            .args();
1327
1328        assert_eq!(
1329            args,
1330            vec![
1331                "exec", "resume", "--last", "--model", "gpt-5", "--json", "continue",
1332            ]
1333        );
1334    }
1335
1336    #[test]
1337    fn exec_resume_new_flags() {
1338        let args = ExecResumeCommand::new()
1339            .last()
1340            .strict_config()
1341            .set_bypass_hook_trust()
1342            .args();
1343
1344        assert_eq!(
1345            args,
1346            vec![
1347                "exec",
1348                "resume",
1349                "--last",
1350                "--strict-config",
1351                "--dangerously-bypass-hook-trust",
1352            ]
1353        );
1354    }
1355
1356    /// #53: `--ask-for-approval` and `--search` were removed from `codex exec`
1357    /// in codex-cli 0.145.0; the settings live on as config keys.
1358    #[test]
1359    fn exec_approval_and_search_emit_config_keys() {
1360        let args = ExecCommand::new("hi")
1361            .approval_policy(ApprovalPolicy::Never)
1362            .search()
1363            .args();
1364        assert_eq!(
1365            args,
1366            vec![
1367                "exec",
1368                "-c",
1369                "approval_policy=\"never\"",
1370                "-c",
1371                "web_search=\"live\"",
1372                "hi"
1373            ]
1374        );
1375        assert!(
1376            !args
1377                .iter()
1378                .any(|a| a == "--ask-for-approval" || a == "--search")
1379        );
1380    }
1381
1382    /// `granular` and `on-failure` are accepted by the config key but not by
1383    /// the flag, which is why `ApprovalPolicyConfig` exists.
1384    #[test]
1385    fn exec_approval_accepts_config_only_values() {
1386        let args = ExecCommand::new("hi")
1387            .approval_policy(ApprovalPolicyConfig::Granular)
1388            .args();
1389        assert_eq!(
1390            args,
1391            vec!["exec", "-c", "approval_policy=\"granular\"", "hi"]
1392        );
1393    }
1394
1395    #[test]
1396    fn exec_search_mode_variants() {
1397        for (mode, expected) in [
1398            (WebSearchMode::Disabled, "disabled"),
1399            (WebSearchMode::Cached, "cached"),
1400            (WebSearchMode::Indexed, "indexed"),
1401            (WebSearchMode::Live, "live"),
1402        ] {
1403            let args = ExecCommand::new("hi").search_mode(mode).args();
1404            assert_eq!(args[2], format!("web_search=\"{expected}\""));
1405        }
1406    }
1407
1408    /// `-c` is last-wins, so a raw override has to be emitted after the typed
1409    /// setters for it to take effect.
1410    #[test]
1411    fn exec_raw_config_is_emitted_after_typed_config() {
1412        let args = ExecCommand::new("hi")
1413            .approval_policy(ApprovalPolicy::Never)
1414            .config("approval_policy=\"untrusted\"")
1415            .args();
1416        let typed = args
1417            .iter()
1418            .position(|a| a == "approval_policy=\"never\"")
1419            .unwrap();
1420        let raw = args
1421            .iter()
1422            .position(|a| a == "approval_policy=\"untrusted\"")
1423            .unwrap();
1424        assert!(typed < raw, "raw override must win: {args:?}");
1425    }
1426
1427    #[test]
1428    fn native_rollout_budget_is_identical_and_final_on_open_and_resume() {
1429        let budget = RolloutBudgetConfig::builder(10_000)
1430            .reminder_at_remaining_tokens([5_000, 1_000])
1431            .sampling_token_weight(1.0)
1432            .prefill_token_weight(0.25)
1433            .build()
1434            .expect("valid budget");
1435        let expected = budget.config_override();
1436        let opening = ExecCommand::new("hi")
1437            .rollout_budget(budget.clone())
1438            .config("features.rollout_budget=false")
1439            .enable("rollout_budget")
1440            .disable("rollout_budget")
1441            .enable("keep-enabled")
1442            .disable("keep-disabled")
1443            .args();
1444        let resumed = ExecResumeCommand::new()
1445            .session_id("thread")
1446            .rollout_budget(budget)
1447            .config("features.rollout_budget=false")
1448            .enable("rollout_budget")
1449            .disable("rollout_budget")
1450            .enable("keep-enabled")
1451            .disable("keep-disabled")
1452            .args();
1453
1454        for args in [opening, resumed] {
1455            let budget_at = args.iter().position(|arg| arg == &expected).unwrap();
1456            let raw_at = args
1457                .iter()
1458                .position(|arg| arg == "features.rollout_budget=false")
1459                .unwrap();
1460            assert!(
1461                raw_at < budget_at,
1462                "native budget must beat raw config: {args:?}"
1463            );
1464            assert!(
1465                !args.windows(2).any(|pair| {
1466                    matches!(pair[0].as_str(), "--enable" | "--disable")
1467                        && pair[1] == "rollout_budget"
1468                }),
1469                "native budget must suppress conflicting feature toggles: {args:?}"
1470            );
1471            assert!(
1472                args.windows(2)
1473                    .any(|pair| pair == ["--enable", "keep-enabled"])
1474            );
1475            assert!(
1476                args.windows(2)
1477                    .any(|pair| pair == ["--disable", "keep-disabled"])
1478            );
1479        }
1480    }
1481
1482    /// #55: `--full-auto` is hidden and deprecated on the exec family.
1483    #[test]
1484    fn exec_full_auto_emits_sandbox_workspace_write() {
1485        let args = ExecCommand::new("hi").full_auto().args();
1486        assert_eq!(args, vec!["exec", "--sandbox", "workspace-write", "hi"]);
1487        assert!(!args.iter().any(|a| a == "--full-auto"));
1488    }
1489
1490    #[test]
1491    fn exec_explicit_sandbox_wins_over_full_auto() {
1492        let args = ExecCommand::new("hi")
1493            .full_auto()
1494            .sandbox(SandboxMode::ReadOnly)
1495            .args();
1496        assert_eq!(args, vec!["exec", "--sandbox", "read-only", "hi"]);
1497    }
1498
1499    /// `codex exec resume` has no `--sandbox` flag, so the replacement goes
1500    /// through the config key instead.
1501    #[test]
1502    fn exec_resume_full_auto_emits_sandbox_config_key() {
1503        let args = ExecResumeCommand::new().last().full_auto().args();
1504        assert_eq!(
1505            args,
1506            vec![
1507                "exec",
1508                "resume",
1509                "-c",
1510                "sandbox_mode=\"workspace-write\"",
1511                "--last"
1512            ]
1513        );
1514        assert!(!args.iter().any(|a| a == "--full-auto"));
1515    }
1516
1517    #[test]
1518    fn exec_resume_approval_and_search_emit_config_keys() {
1519        let args = ExecResumeCommand::new()
1520            .last()
1521            .approval_policy(ApprovalPolicyConfig::OnFailure)
1522            .search_mode(WebSearchMode::Cached)
1523            .args();
1524        assert_eq!(
1525            args,
1526            vec![
1527                "exec",
1528                "resume",
1529                "-c",
1530                "approval_policy=\"on-failure\"",
1531                "-c",
1532                "web_search=\"cached\"",
1533                "--last"
1534            ]
1535        );
1536    }
1537
1538    /// #65: these three were listed in #41 P1 but never landed on
1539    /// `ExecResumeCommand`.
1540    #[test]
1541    fn exec_resume_ignore_and_output_schema_args() {
1542        let args = ExecResumeCommand::new()
1543            .last()
1544            .ignore_user_config()
1545            .ignore_rules()
1546            .output_schema("/tmp/schema.json")
1547            .args();
1548        assert_eq!(
1549            args,
1550            vec![
1551                "exec",
1552                "resume",
1553                "--last",
1554                "--ignore-user-config",
1555                "--ignore-rules",
1556                "--output-schema",
1557                "/tmp/schema.json"
1558            ]
1559        );
1560    }
1561
1562    #[cfg(all(unix, feature = "json"))]
1563    #[tokio::test]
1564    async fn fresh_json_cancellation_returns_after_reaping() {
1565        use crate::test_support::{PidFile, blocking_codex, is_running_for_test};
1566
1567        let pid_file = PidFile::new("fresh-json-cancellable");
1568        let codex = blocking_codex(&pid_file)
1569            .termination_grace(std::time::Duration::from_millis(10))
1570            .build()
1571            .expect("bash must exist");
1572
1573        let result = ExecCommand::new("probe")
1574            .execute_json_cancellable(&codex, async {
1575                tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1576            })
1577            .await;
1578
1579        assert!(matches!(result, Err(Error::Cancelled { .. })));
1580        let pid = pid_file.read_pid().await;
1581        assert!(
1582            !is_running_for_test(pid),
1583            "codex ({pid}) survived cancellation"
1584        );
1585    }
1586
1587    #[cfg(all(unix, feature = "json"))]
1588    #[tokio::test]
1589    async fn resumed_stdin_json_cancellation_returns_after_reaping() {
1590        use crate::test_support::{PidFile, blocking_codex, is_running_for_test};
1591
1592        let pid_file = PidFile::new("resume-stdin-json-cancellable");
1593        let codex = blocking_codex(&pid_file)
1594            .termination_grace(std::time::Duration::from_millis(10))
1595            .build()
1596            .expect("bash must exist");
1597
1598        let result = ExecResumeCommand::from_stdin("continue")
1599            .session_id("thread-1")
1600            .execute_json_cancellable(&codex, async {
1601                tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1602            })
1603            .await;
1604
1605        assert!(matches!(result, Err(Error::Cancelled { .. })));
1606        let pid = pid_file.read_pid().await;
1607        assert!(
1608            !is_running_for_test(pid),
1609            "codex ({pid}) survived cancellation"
1610        );
1611    }
1612
1613    /// #81: the builder emitted `codex exec -` while every spawn path closed
1614    /// the child's stdin, so the prompt was never delivered. This drives a
1615    /// fake codex that echoes back what it read, which is the only way to see
1616    /// the difference: the argv is identical either way.
1617    #[cfg(all(unix, feature = "json"))]
1618    #[tokio::test]
1619    async fn stdin_prompt_reaches_the_child() {
1620        let codex = echoing_stdin_codex();
1621        let prompt = "a prompt too awkward for argv\nwith a second line";
1622
1623        let result = ExecCommand::from_stdin(prompt)
1624            .execute_json(&codex)
1625            .await
1626            .unwrap();
1627
1628        assert_eq!(result.result, prompt);
1629    }
1630
1631    #[cfg(unix)]
1632    #[tokio::test]
1633    async fn resume_stdin_prompt_reaches_the_child_for_raw_execution() {
1634        let codex = echoing_stdin_codex();
1635        let prompt = "raw resumed stdin prompt";
1636
1637        let output = ExecResumeCommand::from_stdin(prompt)
1638            .session_id("thread-1")
1639            .execute(&codex)
1640            .await
1641            .unwrap();
1642
1643        assert!(output.stdout.contains(prompt));
1644    }
1645
1646    #[cfg(all(unix, feature = "json"))]
1647    #[tokio::test]
1648    async fn resume_stdin_prompt_reaches_the_child_for_json_execution() {
1649        let codex = echoing_stdin_codex();
1650        let prompt = "json resumed stdin prompt";
1651
1652        let result = ExecResumeCommand::from_stdin(prompt)
1653            .session_id("thread-1")
1654            .execute_json(&codex)
1655            .await
1656            .unwrap();
1657
1658        assert_eq!(result.result, prompt);
1659    }
1660
1661    /// The same delivery, on the streaming path, which pipes stdin separately.
1662    #[cfg(all(unix, feature = "json"))]
1663    #[tokio::test]
1664    async fn stdin_prompt_reaches_the_child_when_streaming() {
1665        let codex = echoing_stdin_codex();
1666        let prompt = "streamed stdin prompt";
1667        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1668        let sink = std::sync::Arc::clone(&seen);
1669
1670        ExecCommand::from_stdin(prompt)
1671            .stream(&codex, move |event| {
1672                if let Some(text) = event.agent_message_text() {
1673                    sink.lock().unwrap().push(text);
1674                }
1675            })
1676            .await
1677            .unwrap();
1678
1679        assert_eq!(seen.lock().unwrap().as_slice(), &[prompt.to_string()]);
1680    }
1681
1682    #[cfg(all(unix, feature = "json"))]
1683    #[tokio::test]
1684    async fn resume_stdin_prompt_reaches_the_child_when_streaming() {
1685        let codex = echoing_stdin_codex();
1686        let prompt = "streamed resumed stdin prompt";
1687        let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1688        let sink = std::sync::Arc::clone(&seen);
1689
1690        ExecResumeCommand::from_stdin(prompt)
1691            .session_id("thread-1")
1692            .stream(&codex, move |event| {
1693                if let Some(text) = event.agent_message_text() {
1694                    sink.lock().unwrap().push(text);
1695                }
1696            })
1697            .await
1698            .unwrap();
1699
1700        assert_eq!(seen.lock().unwrap().as_slice(), &[prompt.to_string()]);
1701    }
1702
1703    /// A prompt larger than a pipe buffer must not deadlock: the write and the
1704    /// output drain have to run concurrently.
1705    #[cfg(all(unix, feature = "json"))]
1706    #[tokio::test]
1707    async fn a_prompt_larger_than_the_pipe_buffer_still_completes() {
1708        let codex = echoing_stdin_codex();
1709        // Well past the usual 64 KiB pipe capacity.
1710        let prompt = "x".repeat(512 * 1024);
1711
1712        let result = ExecCommand::from_stdin(&prompt)
1713            .execute_json(&codex)
1714            .await
1715            .unwrap();
1716
1717        assert_eq!(result.result.len(), prompt.len());
1718    }
1719
1720    #[cfg(unix)]
1721    fn echoing_stdin_codex() -> Codex {
1722        let script = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1723            .join("tests")
1724            .join("fake-codex-echo-stdin.sh");
1725        Codex::builder()
1726            .binary("/bin/bash")
1727            .arg(script.to_str().unwrap())
1728            .build()
1729            .expect("bash must exist")
1730    }
1731
1732    #[test]
1733    fn from_stdin_emits_the_dash_positional_not_the_prompt() {
1734        let args = ExecCommand::from_stdin("secret prompt").ephemeral().args();
1735        assert_eq!(args, vec!["exec", "--ephemeral", "-"]);
1736        // The prompt must not leak into argv, which is the whole point of
1737        // sending it on stdin.
1738        assert!(!args.iter().any(|a| a.contains("secret")));
1739    }
1740
1741    #[test]
1742    fn prompt_via_stdin_converts_an_existing_prompt() {
1743        let args = ExecCommand::new("hello").prompt_via_stdin().args();
1744        assert_eq!(args, vec!["exec", "-"]);
1745    }
1746
1747    #[test]
1748    fn resume_from_stdin_emits_the_dash_positional_not_the_prompt() {
1749        let args = ExecResumeCommand::from_stdin("secret prompt")
1750            .session_id("thread-1")
1751            .ephemeral()
1752            .args();
1753        assert_eq!(args, vec!["exec", "resume", "--ephemeral", "thread-1", "-"]);
1754        assert!(!args.iter().any(|arg| arg.contains("secret")));
1755    }
1756
1757    #[test]
1758    fn resume_prompt_via_stdin_converts_an_existing_prompt() {
1759        let args = ExecResumeCommand::new()
1760            .session_id("thread-1")
1761            .prompt("hello")
1762            .prompt_via_stdin()
1763            .args();
1764        assert_eq!(args, vec!["exec", "resume", "thread-1", "-"]);
1765    }
1766}