Skip to main content

codex_wrapper/
lib.rs

1//! A type-safe Codex CLI wrapper for Rust.
2//!
3//! `codex-wrapper` provides a builder-pattern interface for invoking the
4//! `codex` CLI programmatically. It follows the same design philosophy as
5//! [`claude-wrapper`](https://crates.io/crates/claude-wrapper) and
6//! [`docker-wrapper`](https://crates.io/crates/docker-wrapper):
7//! each CLI subcommand is a builder struct that produces typed output.
8//!
9//! # Quick Start
10//!
11//! ```no_run
12//! use codex_wrapper::{Codex, CodexCommand, ExecCommand, SandboxMode};
13//!
14//! # async fn example() -> codex_wrapper::Result<()> {
15//! let codex = Codex::builder().build()?;
16//!
17//! let output = ExecCommand::new("summarize this repository")
18//!     .sandbox(SandboxMode::WorkspaceWrite)
19//!     .ephemeral()
20//!     .execute(&codex)
21//!     .await?;
22//!
23//! println!("{}", output.stdout);
24//! # Ok(())
25//! # }
26//! ```
27//!
28//! ## Defaults
29//!
30//! | Type | Default variant |
31//! |------|-----------------|
32//! | [`SandboxMode`] | [`SandboxMode::WorkspaceWrite`] |
33//! | [`ApprovalPolicy`] | [`ApprovalPolicy::OnRequest`] |
34//!
35//! # Two-Layer Builder
36//!
37//! The [`Codex`] client holds shared config (binary path, env vars, timeout,
38//! retry policy). Command builders hold per-invocation options and call
39//! `execute(&codex)`.
40//!
41//! ```no_run
42//! use codex_wrapper::{Codex, CodexCommand, ExecCommand, RetryPolicy};
43//!
44//! # async fn example() -> codex_wrapper::Result<()> {
45//! // Configure once, reuse across commands
46//! let codex = Codex::builder()
47//!     .env("OPENAI_API_KEY", "sk-...")
48//!     .timeout_secs(300)
49//!     .retry(RetryPolicy::new().max_attempts(3).exponential())
50//!     .build()?;
51//!
52//! // Each command is a separate builder
53//! let output = ExecCommand::new("fix the failing tests")
54//!     .model("o3")
55//!     .sandbox(codex_wrapper::SandboxMode::WorkspaceWrite)
56//!     .skip_git_repo_check()
57//!     .ephemeral()
58//!     .execute(&codex)
59//!     .await?;
60//! # Ok(())
61//! # }
62//! ```
63//!
64//! # JSONL Output Parsing
65//!
66//! Use `execute_json_lines()` to get structured events from `--json` mode:
67//!
68//! ```no_run
69//! use codex_wrapper::{Codex, ExecCommand};
70//!
71//! # async fn example() -> codex_wrapper::Result<()> {
72//! let codex = Codex::builder().build()?;
73//! let events = ExecCommand::new("what is 2+2?")
74//!     .ephemeral()
75//!     .execute_json_lines(&codex)
76//!     .await?;
77//!
78//! for event in &events {
79//!     println!("{}: {:?}", event.event_type, event.extra);
80//! }
81//! # Ok(())
82//! # }
83//! ```
84//!
85//! # Child Environment Policy
86//!
87//! Child processes inherit the wrapper process's environment by default.
88//! [`CodexBuilder::clear_env`] opts into clearing that environment before
89//! applying entries from [`CodexBuilder::env`] and [`CodexBuilder::envs`].
90//! The setting controls the direct child's environment, not same-user access
91//! to files, process metadata, sockets, or other OS resources.
92//!
93//! # Available Commands
94//!
95//! | Command | CLI equivalent |
96//! |---------|---------------|
97//! | [`ExecCommand`] | `codex exec <prompt>` |
98//! | [`ExecResumeCommand`] | `codex exec resume` |
99//! | [`ReviewCommand`] | `codex exec review` |
100//! | [`ResumeCommand`] | `codex resume` |
101//! | [`ForkCommand`] | `codex fork` |
102//! | [`LoginCommand`] | `codex login` |
103//! | [`LoginStatusCommand`] | `codex login status` |
104//! | [`LogoutCommand`] | `codex logout` |
105//! | [`McpListCommand`] | `codex mcp list` |
106//! | [`McpGetCommand`] | `codex mcp get` |
107//! | [`McpAddCommand`] | `codex mcp add` |
108//! | [`McpRemoveCommand`] | `codex mcp remove` |
109//! | [`McpLoginCommand`] | `codex mcp login` |
110//! | [`McpLogoutCommand`] | `codex mcp logout` |
111//! | [`McpServerCommand`] | `codex mcp-server` |
112//! | [`CompletionCommand`] | `codex completion` |
113//! | [`SandboxCommand`] | `codex sandbox` |
114//! | [`ApplyCommand`] | `codex apply` |
115//! | [`ArchiveCommand`] | `codex archive` |
116//! | [`DeleteCommand`] | `codex delete` |
117//! | [`UnarchiveCommand`] | `codex unarchive` |
118//! | [`DoctorCommand`] | `codex doctor` |
119//! | [`UpdateCommand`] | `codex update` |
120//! | [`PluginAddCommand`] | `codex plugin add` |
121//! | [`PluginListCommand`] | `codex plugin list` |
122//! | [`PluginRemoveCommand`] | `codex plugin remove` |
123//! | [`PluginMarketplaceAddCommand`] | `codex plugin marketplace add` |
124//! | [`PluginMarketplaceListCommand`] | `codex plugin marketplace list` |
125//! | [`PluginMarketplaceUpgradeCommand`] | `codex plugin marketplace upgrade` |
126//! | [`PluginMarketplaceRemoveCommand`] | `codex plugin marketplace remove` |
127//! | [`FeaturesListCommand`] | `codex features list` |
128//! | [`FeaturesEnableCommand`] | `codex features enable` |
129//! | [`FeaturesDisableCommand`] | `codex features disable` |
130//! | [`VersionCommand`] | `codex --version` |
131//! | [`RawCommand`] | Escape hatch for arbitrary args |
132//!
133//! # Error Handling
134//!
135//! All commands return [`Result<T>`], with typed errors via [`thiserror`]:
136//!
137//! ```no_run
138//! use codex_wrapper::{Codex, CodexCommand, ExecCommand, Error};
139//!
140//! # async fn example() -> codex_wrapper::Result<()> {
141//! let codex = Codex::builder().build()?;
142//! match ExecCommand::new("test").execute(&codex).await {
143//!     Ok(output) => println!("{}", output.stdout),
144//!     Err(Error::CommandFailed { stderr, exit_code, .. }) => {
145//!         eprintln!("failed (exit {}): {}", exit_code, stderr);
146//!     }
147//!     Err(Error::Timeout { .. }) => eprintln!("timed out"),
148//!     Err(e) => eprintln!("{e}"),
149//! }
150//! # Ok(())
151//! # }
152//! ```
153//!
154//! # Cancellation
155//!
156//! [`ExecCommand::execute_cancellable`] and
157//! [`ExecResumeCommand::execute_cancellable`] accept an explicit cancellation
158//! future. They terminate the owned process group and await the direct child
159//! before returning. Buffered client timeouts use the same settled cleanup
160//! path.
161//!
162//! Dropping an ordinary command future is an abrupt fallback. It kills the
163//! direct child and, on Unix, the owned process group, but `Drop` cannot await
164//! reaping. A supervisor that needs terminal settlement should signal a
165//! cancellable method and keep polling it until it returns.
166//!
167//! Process groups are Unix-only. Elsewhere explicit cancellation kills and
168//! awaits the direct child, but cannot guarantee descendant cleanup.
169//!
170//! # Features
171//!
172//! - `json` *(enabled by default)* - JSONL output parsing via `serde_json`
173
174#[cfg(feature = "json")]
175pub mod auth;
176#[cfg(feature = "json")]
177pub mod budget;
178// Only the read-side modules need it, and each sits behind its own feature.
179#[cfg(any(feature = "json", feature = "config"))]
180mod codex_home;
181pub mod command;
182#[cfg(feature = "config")]
183pub mod config;
184pub mod dangerous;
185pub mod error;
186pub mod exec;
187#[cfg(feature = "json")]
188pub mod history;
189pub mod mcp_config;
190pub mod retry;
191pub mod rollout_budget;
192#[cfg(feature = "json")]
193pub mod session;
194#[cfg(feature = "json")]
195pub mod streaming;
196#[cfg(all(test, unix))]
197mod test_support;
198pub mod types;
199pub mod version;
200
201use std::collections::HashMap;
202use std::fmt;
203use std::path::{Path, PathBuf};
204use std::time::Duration;
205
206#[cfg(feature = "json")]
207pub use auth::{AuthStatus, AuthStrategy};
208#[cfg(feature = "json")]
209pub use budget::{TokenBudget, TokenBudgetBuilder};
210pub use command::CodexCommand;
211pub use command::apply::ApplyCommand;
212pub use command::completion::{CompletionCommand, Shell};
213pub use command::doctor::DoctorCommand;
214pub use command::exec::{ExecCommand, ExecResumeCommand};
215pub use command::features::{FeaturesDisableCommand, FeaturesEnableCommand, FeaturesListCommand};
216pub use command::fork::ForkCommand;
217pub use command::login::{LoginCommand, LoginStatusCommand, LogoutCommand};
218pub use command::mcp::{
219    McpAddCommand, McpGetCommand, McpListCommand, McpLoginCommand, McpLogoutCommand,
220    McpRemoveCommand,
221};
222pub use command::mcp_server::McpServerCommand;
223pub use command::plugin::{
224    PluginAddCommand, PluginListCommand, PluginMarketplaceAddCommand, PluginMarketplaceListCommand,
225    PluginMarketplaceRemoveCommand, PluginMarketplaceUpgradeCommand, PluginRemoveCommand,
226};
227pub use command::raw::RawCommand;
228pub use command::resume::ResumeCommand;
229pub use command::review::ReviewCommand;
230pub use command::sandbox::SandboxCommand;
231pub use command::session_mgmt::{ArchiveCommand, DeleteCommand, UnarchiveCommand};
232pub use command::update::UpdateCommand;
233pub use command::version::VersionCommand;
234#[cfg(feature = "config")]
235pub use config::CodexConfig;
236pub use error::{Error, FailureKind, Result};
237pub use exec::CommandOutput;
238#[cfg(feature = "json")]
239pub use history::{SessionFile, SessionLog, SessionMeta, SessionQuery};
240pub use mcp_config::{McpConfigBuilder, McpServerConfig};
241pub use retry::{BackoffStrategy, RetryPolicy};
242pub use rollout_budget::{RolloutBudgetConfig, RolloutBudgetConfigBuilder};
243#[cfg(feature = "json")]
244pub use session::{Session, TurnRecord};
245pub use types::*;
246pub use version::{
247    CliVersion, CliVersionStatus, TESTED_CLI_VERSION_MAX, TESTED_CLI_VERSION_MIN, VersionParseError,
248};
249
250/// Shared Codex CLI client configuration.
251///
252/// Holds the binary path, working directory, environment variables, global
253/// arguments, timeout, and retry policy. Cheap to [`Clone`]; intended to be
254/// created once and reused across many command invocations.
255///
256/// # Example
257///
258/// ```no_run
259/// # fn example() -> codex_wrapper::Result<()> {
260/// let codex = codex_wrapper::Codex::builder()
261///     .env("OPENAI_API_KEY", "sk-...")
262///     .timeout_secs(120)
263///     .build()?;
264/// # Ok(())
265/// # }
266/// ```
267#[derive(Clone)]
268pub struct Codex {
269    pub(crate) binary: PathBuf,
270    pub(crate) working_dir: Option<PathBuf>,
271    pub(crate) env: HashMap<String, String>,
272    pub(crate) clear_env: bool,
273    pub(crate) global_args: Vec<String>,
274    pub(crate) timeout: Option<Duration>,
275    pub(crate) termination_grace: Duration,
276    pub(crate) process_group: bool,
277    pub(crate) retry_policy: Option<RetryPolicy>,
278    pub(crate) tested_cli_version_range: (CliVersion, CliVersion),
279}
280
281impl Codex {
282    /// Create a new [`CodexBuilder`].
283    #[must_use]
284    pub fn builder() -> CodexBuilder {
285        CodexBuilder::default()
286    }
287
288    /// Path to the resolved `codex` binary.
289    #[must_use]
290    pub fn binary(&self) -> &Path {
291        &self.binary
292    }
293
294    /// Working directory for command execution, if set.
295    #[must_use]
296    pub fn working_dir(&self) -> Option<&Path> {
297        self.working_dir.as_deref()
298    }
299
300    /// Return a clone of this client with a different working directory.
301    #[must_use]
302    pub fn with_working_dir(&self, dir: impl Into<PathBuf>) -> Self {
303        let mut clone = self.clone();
304        clone.working_dir = Some(dir.into());
305        clone
306    }
307
308    /// Read `config.toml` for this client's `CODEX_HOME`.
309    ///
310    /// Uses the same effective environment as spawned commands. A client
311    /// built with [`clear_env`](CodexBuilder::clear_env) does not fall back to
312    /// ambient `CODEX_HOME` or `HOME` values.
313    ///
314    /// `Ok(None)` when there is no config file. Requires the `config` feature.
315    /// See [`crate::config`] for what is typed and what stays raw.
316    #[cfg(feature = "config")]
317    pub fn config(&self) -> Result<Option<crate::config::CodexConfig>> {
318        let home = crate::codex_home::resolve(&|key| self.environment_value(key));
319        crate::config::load_from_home(home)
320    }
321
322    /// Which credential this client's CLI would use, without spawning it.
323    ///
324    /// Honors a `CODEX_HOME` set on this client via
325    /// [`env`](CodexBuilder::env), falling back to the process environment
326    /// unless [`clear_env`](CodexBuilder::clear_env) was selected.
327    /// See [`crate::auth`] for what the strategies mean and how they were
328    /// determined.
329    ///
330    /// ```no_run
331    /// use codex_wrapper::Codex;
332    ///
333    /// # fn example() -> codex_wrapper::Result<()> {
334    /// let codex = Codex::builder().build()?;
335    /// if !codex.auth_status().is_configured() {
336    ///     eprintln!("no credentials; run `codex login`");
337    /// }
338    /// # Ok(())
339    /// # }
340    /// ```
341    #[cfg(feature = "json")]
342    #[must_use]
343    pub fn auth_status(&self) -> crate::auth::AuthStatus {
344        crate::auth::detect_with(|key| self.environment_value(key))
345    }
346
347    /// Resolve one variable exactly as this client's direct child will see
348    /// it. Read-side helpers use the same policy as process spawning so a
349    /// preflight cannot report credentials or config excluded from the child.
350    #[cfg(any(feature = "json", feature = "config"))]
351    fn environment_value(&self, key: &str) -> Option<String> {
352        self.env.get(key).cloned().or_else(|| {
353            if self.clear_env {
354                None
355            } else {
356                std::env::var(key).ok()
357            }
358        })
359    }
360
361    pub async fn cli_version(&self) -> Result<CliVersion> {
362        let output = VersionCommand::new().execute(self).await?;
363        CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
364            message: format!("failed to parse CLI version: {e}"),
365            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
366            working_dir: None,
367        })
368    }
369
370    /// Verify the installed CLI meets a minimum version requirement.
371    ///
372    /// Returns [`Error::VersionMismatch`] if the installed version is too old.
373    pub async fn check_version(&self, minimum: &CliVersion) -> Result<CliVersion> {
374        let version = self.cli_version().await?;
375        if version.satisfies_minimum(minimum) {
376            Ok(version)
377        } else {
378            Err(Error::VersionMismatch {
379                found: version,
380                minimum: *minimum,
381            })
382        }
383    }
384
385    /// The tested-against CLI version range this client reports on.
386    ///
387    /// Defaults to [`TESTED_CLI_VERSION_MIN`] and [`TESTED_CLI_VERSION_MAX`];
388    /// override with [`CodexBuilder::tested_cli_version_range`].
389    #[must_use]
390    pub fn tested_cli_version_range(&self) -> (CliVersion, CliVersion) {
391        self.tested_cli_version_range
392    }
393
394    /// Classify the installed CLI against the tested-against range.
395    ///
396    /// Emits a `tracing::warn!` when outside the range, and returns the typed
397    /// status either way. This reports; it does not fail. Most CLI releases
398    /// break nothing, so refusing to run against an unrecognized version is
399    /// worse than saying so. Use
400    /// [`ensure_tested_cli_version`](Self::ensure_tested_cli_version) when you
401    /// do want a hard gate.
402    ///
403    /// Intended for one-shot use at startup rather than before every command:
404    /// it spawns `codex --version`.
405    pub async fn cli_version_status(&self) -> Result<CliVersionStatus> {
406        let (min, max) = self.tested_cli_version_range;
407        let status = self.cli_version().await?.status_within(&min, &max);
408        warn_on_drift(&status);
409        Ok(status)
410    }
411
412    /// Like [`cli_version_status`](Self::cli_version_status), but returns
413    /// [`Error::UntestedCliVersion`] when the installed CLI is outside the
414    /// tested range.
415    ///
416    /// This is the opt-in hard gate. It is a method rather than a
417    /// [`CodexBuilder`] option because [`CodexBuilder::build`] is synchronous
418    /// and never spawns the binary; enforcing a version there would mean
419    /// running a subprocess inside a constructor.
420    pub async fn ensure_tested_cli_version(&self) -> Result<CliVersion> {
421        let (min, max) = self.tested_cli_version_range;
422        let found = self.cli_version().await?;
423        match found.status_within(&min, &max) {
424            CliVersionStatus::Tested => Ok(found),
425            status => {
426                warn_on_drift(&status);
427                Err(Error::UntestedCliVersion {
428                    found,
429                    tested_min: min,
430                    tested_max: max,
431                })
432            }
433        }
434    }
435}
436
437impl fmt::Debug for Codex {
438    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
439        let mut env_keys: Vec<&str> = self.env.keys().map(String::as_str).collect();
440        env_keys.sort_unstable();
441
442        f.debug_struct("Codex")
443            .field("binary", &self.binary)
444            .field("working_dir", &self.working_dir)
445            .field("env_keys", &env_keys)
446            .field("clear_env", &self.clear_env)
447            .field("global_args", &self.global_args)
448            .field("timeout", &self.timeout)
449            .field("termination_grace", &self.termination_grace)
450            .field("process_group", &self.process_group)
451            .field("retry_policy", &self.retry_policy)
452            .field("tested_cli_version_range", &self.tested_cli_version_range)
453            .finish()
454    }
455}
456
457fn warn_on_drift(status: &CliVersionStatus) {
458    match status {
459        CliVersionStatus::Tested => {}
460        CliVersionStatus::NewerUntested { found, tested_max } => {
461            tracing::warn!(
462                found = %found,
463                tested_max = %tested_max,
464                "codex CLI is newer than this wrapper's tested-against range; \
465                 semantics may have drifted"
466            );
467        }
468        CliVersionStatus::OlderThanMinimum { found, minimum } => {
469            tracing::warn!(
470                found = %found,
471                minimum = %minimum,
472                "codex CLI is older than this wrapper's tested-against range; \
473                 some emitted arguments are likely to be rejected"
474            );
475        }
476    }
477}
478
479/// Builder for creating a [`Codex`] client.
480///
481/// All options are optional. By default the builder discovers the `codex`
482/// binary via `PATH`.
483#[derive(Default)]
484pub struct CodexBuilder {
485    binary: Option<PathBuf>,
486    working_dir: Option<PathBuf>,
487    env: HashMap<String, String>,
488    clear_env: bool,
489    global_args: Vec<String>,
490    timeout: Option<Duration>,
491    termination_grace: Option<Duration>,
492    process_group: Option<bool>,
493    retry_policy: Option<RetryPolicy>,
494    tested_cli_version_range: Option<(CliVersion, CliVersion)>,
495}
496
497impl CodexBuilder {
498    /// Override the tested-against CLI version range.
499    ///
500    /// Defaults to the range this crate declares and verifies in CI. Set this
501    /// only when you have validated a different range yourself; widening it
502    /// does not make the wrapper work against versions it was not tested on.
503    #[must_use]
504    pub fn tested_cli_version_range(mut self, min: CliVersion, max: CliVersion) -> Self {
505        self.tested_cli_version_range = Some((min, max));
506        self
507    }
508
509    /// Set an explicit path to the `codex` binary (skips `PATH` lookup).
510    #[must_use]
511    pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
512        self.binary = Some(path.into());
513        self
514    }
515
516    /// Set the working directory for all commands.
517    #[must_use]
518    pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
519        self.working_dir = Some(path.into());
520        self
521    }
522
523    /// Set a single environment variable for child processes.
524    #[must_use]
525    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
526        self.env.insert(key.into(), value.into());
527        self
528    }
529
530    /// Set multiple environment variables for child processes.
531    #[must_use]
532    pub fn envs(
533        mut self,
534        vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
535    ) -> Self {
536        for (key, value) in vars {
537            self.env.insert(key.into(), value.into());
538        }
539        self
540    }
541
542    /// Clear the inherited environment before applying variables supplied by
543    /// [`env`](Self::env) and [`envs`](Self::envs).
544    ///
545    /// By default, child processes inherit the wrapper process's environment.
546    /// This opt-in is call-order independent: explicit variables survive
547    /// whether they are added before or after `clear_env`.
548    ///
549    /// This controls the direct Codex child's environment. It is not OS or
550    /// same-user isolation: the child can still access files, process metadata,
551    /// sockets, and other resources allowed to its user and sandbox.
552    #[must_use]
553    pub fn clear_env(mut self) -> Self {
554        self.clear_env = true;
555        self
556    }
557
558    /// Set the command timeout in seconds.
559    #[must_use]
560    pub fn timeout_secs(mut self, seconds: u64) -> Self {
561        self.timeout = Some(Duration::from_secs(seconds));
562        self
563    }
564
565    /// Set the command timeout as a [`Duration`].
566    #[must_use]
567    pub fn timeout(mut self, duration: Duration) -> Self {
568        self.timeout = Some(duration);
569        self
570    }
571
572    /// How long a cancelled run's process group gets to exit before it is
573    /// killed. Defaults to five seconds.
574    ///
575    /// Applies to cancellable exec methods and buffered client timeouts. They
576    /// send SIGTERM, wait this long, send SIGKILL, and await the direct child.
577    /// A dropped future does not use it: `Drop` cannot wait, so it kills
578    /// immediately.
579    #[must_use]
580    pub fn termination_grace(mut self, duration: Duration) -> Self {
581        self.termination_grace = Some(duration);
582        self
583    }
584
585    /// Whether each run gets its own process group. On by default.
586    ///
587    /// With a group of its own, cancelling a run reaches the subprocesses
588    /// codex spawned for tool use, not just codex itself (#78). That is the
589    /// right contract for a supervisor that cancels programmatically.
590    ///
591    /// Opting out puts the child in the parent's group, so a terminal Ctrl-C
592    /// reaches the whole run directly. Then a wrapper-side kill reaches only
593    /// the direct child, and its subprocesses survive. That is the right
594    /// contract for a terminal-attached host that shells out synchronously and
595    /// treats the terminal as the supervisor.
596    ///
597    /// Matches `claude-wrapper`'s option of the same name. No effect on
598    /// non-unix targets, which have no process groups.
599    #[must_use]
600    pub fn process_group(mut self, enabled: bool) -> Self {
601        self.process_group = Some(enabled);
602        self
603    }
604
605    /// Append a raw global argument passed before any subcommand.
606    ///
607    /// When an exec command has a typed rollout budget, conflicting global
608    /// `--enable/--disable rollout_budget` arguments are suppressed at final
609    /// assembly. Codex applies feature toggles after config regardless of argv
610    /// order, so retaining one could silently defeat the typed protection.
611    #[must_use]
612    pub fn arg(mut self, arg: impl Into<String>) -> Self {
613        self.global_args.push(arg.into());
614        self
615    }
616
617    /// Add a global config override (`-c key=value`).
618    #[must_use]
619    pub fn config(mut self, key_value: impl Into<String>) -> Self {
620        self.global_args.push("-c".into());
621        self.global_args.push(key_value.into());
622        self
623    }
624
625    /// Enable a feature flag globally (`--enable <name>`).
626    ///
627    /// A `rollout_budget` toggle is suppressed for an exec command that has a
628    /// typed [`RolloutBudgetConfig`].
629    #[must_use]
630    pub fn enable(mut self, feature: impl Into<String>) -> Self {
631        self.global_args.push("--enable".into());
632        self.global_args.push(feature.into());
633        self
634    }
635
636    /// Disable a feature flag globally (`--disable <name>`).
637    ///
638    /// A `rollout_budget` toggle is suppressed for an exec command that has a
639    /// typed [`RolloutBudgetConfig`].
640    #[must_use]
641    pub fn disable(mut self, feature: impl Into<String>) -> Self {
642        self.global_args.push("--disable".into());
643        self.global_args.push(feature.into());
644        self
645    }
646
647    /// Set a default [`RetryPolicy`] for all commands.
648    #[must_use]
649    pub fn retry(mut self, policy: RetryPolicy) -> Self {
650        self.retry_policy = Some(policy);
651        self
652    }
653
654    /// Build the [`Codex`] client.
655    ///
656    /// Returns [`Error::NotFound`] if no binary path was set and `codex` is
657    /// not found in `PATH`.
658    pub fn build(self) -> Result<Codex> {
659        let binary = match self.binary {
660            Some(path) => path,
661            None => which::which("codex").map_err(|_| Error::NotFound)?,
662        };
663
664        Ok(Codex {
665            binary,
666            working_dir: self.working_dir,
667            env: self.env,
668            clear_env: self.clear_env,
669            global_args: self.global_args,
670            termination_grace: self
671                .termination_grace
672                .unwrap_or_else(|| Duration::from_secs(5)),
673            process_group: self.process_group.unwrap_or(true),
674            timeout: self.timeout,
675            retry_policy: self.retry_policy,
676            tested_cli_version_range: self.tested_cli_version_range.unwrap_or((
677                version::TESTED_CLI_VERSION_MIN,
678                version::TESTED_CLI_VERSION_MAX,
679            )),
680        })
681    }
682}
683
684impl fmt::Debug for CodexBuilder {
685    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
686        let mut env_keys: Vec<&str> = self.env.keys().map(String::as_str).collect();
687        env_keys.sort_unstable();
688
689        f.debug_struct("CodexBuilder")
690            .field("binary", &self.binary)
691            .field("working_dir", &self.working_dir)
692            .field("env_keys", &env_keys)
693            .field("clear_env", &self.clear_env)
694            .field("global_args", &self.global_args)
695            .field("timeout", &self.timeout)
696            .field("termination_grace", &self.termination_grace)
697            .field("process_group", &self.process_group)
698            .field("retry_policy", &self.retry_policy)
699            .field("tested_cli_version_range", &self.tested_cli_version_range)
700            .finish()
701    }
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707
708    #[test]
709    fn builder_with_binary() {
710        let codex = Codex::builder()
711            .binary("/usr/local/bin/codex")
712            .env("FOO", "bar")
713            .timeout_secs(60)
714            .build()
715            .unwrap();
716
717        assert_eq!(codex.binary, PathBuf::from("/usr/local/bin/codex"));
718        assert_eq!(codex.env.get("FOO").unwrap(), "bar");
719        assert!(!codex.clear_env);
720        assert_eq!(codex.timeout, Some(Duration::from_secs(60)));
721    }
722
723    #[test]
724    fn clear_env_is_opt_in_and_keeps_explicit_entries() {
725        let codex = Codex::builder()
726            .binary("/bin/echo")
727            .env("BEFORE_CLEAR", "one")
728            .clear_env()
729            .env("AFTER_CLEAR", "two")
730            .build()
731            .unwrap();
732
733        assert!(codex.clear_env);
734        assert_eq!(
735            codex.env.get("BEFORE_CLEAR").map(String::as_str),
736            Some("one")
737        );
738        assert_eq!(
739            codex.env.get("AFTER_CLEAR").map(String::as_str),
740            Some("two")
741        );
742    }
743
744    #[test]
745    fn client_and_builder_debug_hide_environment_values() {
746        let builder = Codex::builder()
747            .binary("/bin/echo")
748            .env("CODEX_WRAPPER_SECRET", "debug-must-not-leak-this");
749
750        let builder_debug = format!("{builder:?}");
751        assert!(builder_debug.contains("CODEX_WRAPPER_SECRET"));
752        assert!(!builder_debug.contains("debug-must-not-leak-this"));
753
754        let codex = builder.build().unwrap();
755        let client_debug = format!("{codex:?}");
756        assert!(client_debug.contains("CODEX_WRAPPER_SECRET"));
757        assert!(!client_debug.contains("debug-must-not-leak-this"));
758    }
759
760    /// Read-side helpers must answer from the environment the child receives,
761    /// not from ambient values that `clear_env` removes at spawn time.
762    #[cfg(any(feature = "json", feature = "config"))]
763    #[test]
764    fn effective_environment_lookup_obeys_clear_env() {
765        let ambient_path = std::env::var("PATH").expect("test process must have PATH");
766        let inherited = Codex::builder().binary("/bin/echo").build().unwrap();
767        assert_eq!(inherited.environment_value("PATH"), Some(ambient_path));
768
769        let cleared = Codex::builder()
770            .binary("/bin/echo")
771            .clear_env()
772            .build()
773            .unwrap();
774        assert_eq!(cleared.environment_value("PATH"), None);
775
776        let explicit = Codex::builder()
777            .binary("/bin/echo")
778            .clear_env()
779            .env("PATH", "/intentional/bin")
780            .build()
781            .unwrap();
782        assert_eq!(
783            explicit.environment_value("PATH").as_deref(),
784            Some("/intentional/bin")
785        );
786    }
787
788    #[test]
789    fn builder_global_args() {
790        let codex = Codex::builder()
791            .binary("/usr/local/bin/codex")
792            .config("model=\"gpt-5\"")
793            .enable("foo")
794            .disable("bar")
795            .build()
796            .unwrap();
797
798        assert_eq!(
799            codex.global_args,
800            vec![
801                "-c",
802                "model=\"gpt-5\"",
803                "--enable",
804                "foo",
805                "--disable",
806                "bar"
807            ]
808        );
809    }
810
811    #[test]
812    fn client_defaults_to_the_crate_tested_range() {
813        let codex = Codex::builder().binary("/bin/echo").build().unwrap();
814        assert_eq!(
815            codex.tested_cli_version_range(),
816            (
817                version::TESTED_CLI_VERSION_MIN,
818                version::TESTED_CLI_VERSION_MAX
819            )
820        );
821    }
822
823    #[test]
824    fn builder_can_override_the_tested_range() {
825        let min = CliVersion::new(1, 0, 0);
826        let max = CliVersion::new(2, 0, 0);
827        let codex = Codex::builder()
828            .binary("/bin/echo")
829            .tested_cli_version_range(min, max)
830            .build()
831            .unwrap();
832        assert_eq!(codex.tested_cli_version_range(), (min, max));
833    }
834
835    #[test]
836    fn untested_version_error_names_both_bounds() {
837        let err = Error::UntestedCliVersion {
838            found: CliVersion::new(0, 200, 0),
839            tested_min: CliVersion::new(0, 145, 0),
840            tested_max: CliVersion::new(0, 146, 0),
841        };
842        assert_eq!(
843            err.to_string(),
844            "CLI version 0.200.0 is outside the tested range 0.145.0..=0.146.0"
845        );
846    }
847
848    /// A CODEX_HOME set on the client must win over the process environment,
849    /// or a client pointed at a different home reports the wrong credentials.
850    #[cfg(feature = "json")]
851    #[test]
852    fn auth_status_honors_a_client_codex_home() {
853        let dir =
854            std::env::temp_dir().join(format!("codex-wrapper-client-auth-{}", std::process::id()));
855        let _ = std::fs::remove_dir_all(&dir);
856        std::fs::create_dir_all(&dir).unwrap();
857        std::fs::write(
858            dir.join("auth.json"),
859            r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-secret"}"#,
860        )
861        .unwrap();
862
863        let codex = Codex::builder()
864            .binary("/bin/echo")
865            .env("CODEX_HOME", dir.to_str().unwrap())
866            .build()
867            .unwrap();
868
869        let status = codex.auth_status();
870        assert_eq!(status.codex_home, dir);
871        assert!(status.is_configured());
872        assert!(
873            !format!("{status:?}").contains("sk-secret"),
874            "the credential leaked into the status"
875        );
876
877        let _ = std::fs::remove_dir_all(&dir);
878    }
879
880    #[cfg(feature = "json")]
881    #[test]
882    fn auth_status_does_not_fall_back_to_ambient_home_when_cleared() {
883        let codex = Codex::builder()
884            .binary("/bin/echo")
885            .clear_env()
886            .build()
887            .unwrap();
888
889        assert_eq!(codex.auth_status().codex_home, PathBuf::from(".codex"));
890    }
891}