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