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//! # Available Commands
86//!
87//! | Command | CLI equivalent |
88//! |---------|---------------|
89//! | [`ExecCommand`] | `codex exec <prompt>` |
90//! | [`ExecResumeCommand`] | `codex exec resume` |
91//! | [`ReviewCommand`] | `codex exec review` |
92//! | [`ResumeCommand`] | `codex resume` |
93//! | [`ForkCommand`] | `codex fork` |
94//! | [`LoginCommand`] | `codex login` |
95//! | [`LoginStatusCommand`] | `codex login status` |
96//! | [`LogoutCommand`] | `codex logout` |
97//! | [`McpListCommand`] | `codex mcp list` |
98//! | [`McpGetCommand`] | `codex mcp get` |
99//! | [`McpAddCommand`] | `codex mcp add` |
100//! | [`McpRemoveCommand`] | `codex mcp remove` |
101//! | [`McpLoginCommand`] | `codex mcp login` |
102//! | [`McpLogoutCommand`] | `codex mcp logout` |
103//! | [`McpServerCommand`] | `codex mcp-server` |
104//! | [`CompletionCommand`] | `codex completion` |
105//! | [`SandboxCommand`] | `codex sandbox` |
106//! | [`ApplyCommand`] | `codex apply` |
107//! | [`ArchiveCommand`] | `codex archive` |
108//! | [`DeleteCommand`] | `codex delete` |
109//! | [`UnarchiveCommand`] | `codex unarchive` |
110//! | [`DoctorCommand`] | `codex doctor` |
111//! | [`UpdateCommand`] | `codex update` |
112//! | [`PluginAddCommand`] | `codex plugin add` |
113//! | [`PluginListCommand`] | `codex plugin list` |
114//! | [`PluginRemoveCommand`] | `codex plugin remove` |
115//! | [`PluginMarketplaceAddCommand`] | `codex plugin marketplace add` |
116//! | [`PluginMarketplaceListCommand`] | `codex plugin marketplace list` |
117//! | [`PluginMarketplaceUpgradeCommand`] | `codex plugin marketplace upgrade` |
118//! | [`PluginMarketplaceRemoveCommand`] | `codex plugin marketplace remove` |
119//! | [`FeaturesListCommand`] | `codex features list` |
120//! | [`FeaturesEnableCommand`] | `codex features enable` |
121//! | [`FeaturesDisableCommand`] | `codex features disable` |
122//! | [`VersionCommand`] | `codex --version` |
123//! | [`RawCommand`] | Escape hatch for arbitrary args |
124//!
125//! # Error Handling
126//!
127//! All commands return [`Result<T>`], with typed errors via [`thiserror`]:
128//!
129//! ```no_run
130//! use codex_wrapper::{Codex, CodexCommand, ExecCommand, Error};
131//!
132//! # async fn example() -> codex_wrapper::Result<()> {
133//! let codex = Codex::builder().build()?;
134//! match ExecCommand::new("test").execute(&codex).await {
135//!     Ok(output) => println!("{}", output.stdout),
136//!     Err(Error::CommandFailed { stderr, exit_code, .. }) => {
137//!         eprintln!("failed (exit {}): {}", exit_code, stderr);
138//!     }
139//!     Err(Error::Timeout { .. }) => eprintln!("timed out"),
140//!     Err(e) => eprintln!("{e}"),
141//! }
142//! # Ok(())
143//! # }
144//! ```
145//!
146//! # Cancellation
147//!
148//! Dropping the future returned by a command kills the spawned `codex`
149//! process. That covers a timeout, an aborted task, and a caller that stops
150//! awaiting during a graceful shutdown: cancelling the future cancels the
151//! work, rather than leaving codex running and billing with no handle left to
152//! stop it.
153//!
154//! Two limits are worth knowing:
155//!
156//! - The kill reaps the `codex` process itself. Subprocesses codex spawned for
157//!   tool use are not signalled and can outlive it.
158//! - Reaping needs the tokio runtime to still be running. A future dropped as
159//!   part of runtime shutdown may not get far enough to kill the child.
160//!
161//! # Features
162//!
163//! - `json` *(enabled by default)* - JSONL output parsing via `serde_json`
164
165#[cfg(feature = "json")]
166pub mod auth;
167#[cfg(feature = "json")]
168pub mod budget;
169// Only the read-side modules need it, and each sits behind its own feature.
170#[cfg(any(feature = "json", feature = "config"))]
171mod codex_home;
172pub mod command;
173#[cfg(feature = "config")]
174pub mod config;
175pub mod dangerous;
176pub mod error;
177pub mod exec;
178#[cfg(feature = "json")]
179pub mod history;
180pub mod mcp_config;
181pub mod retry;
182#[cfg(feature = "json")]
183pub mod session;
184#[cfg(feature = "json")]
185pub mod streaming;
186#[cfg(all(test, unix))]
187mod test_support;
188pub mod types;
189pub mod version;
190
191use std::collections::HashMap;
192use std::path::{Path, PathBuf};
193use std::time::Duration;
194
195#[cfg(feature = "json")]
196pub use auth::{AuthStatus, AuthStrategy};
197#[cfg(feature = "json")]
198pub use budget::{TokenBudget, TokenBudgetBuilder};
199pub use command::CodexCommand;
200pub use command::apply::ApplyCommand;
201pub use command::completion::{CompletionCommand, Shell};
202pub use command::doctor::DoctorCommand;
203pub use command::exec::{ExecCommand, ExecResumeCommand};
204pub use command::features::{FeaturesDisableCommand, FeaturesEnableCommand, FeaturesListCommand};
205pub use command::fork::ForkCommand;
206pub use command::login::{LoginCommand, LoginStatusCommand, LogoutCommand};
207pub use command::mcp::{
208    McpAddCommand, McpGetCommand, McpListCommand, McpLoginCommand, McpLogoutCommand,
209    McpRemoveCommand,
210};
211pub use command::mcp_server::McpServerCommand;
212pub use command::plugin::{
213    PluginAddCommand, PluginListCommand, PluginMarketplaceAddCommand, PluginMarketplaceListCommand,
214    PluginMarketplaceRemoveCommand, PluginMarketplaceUpgradeCommand, PluginRemoveCommand,
215};
216pub use command::raw::RawCommand;
217pub use command::resume::ResumeCommand;
218pub use command::review::ReviewCommand;
219pub use command::sandbox::SandboxCommand;
220pub use command::session_mgmt::{ArchiveCommand, DeleteCommand, UnarchiveCommand};
221pub use command::update::UpdateCommand;
222pub use command::version::VersionCommand;
223#[cfg(feature = "config")]
224pub use config::CodexConfig;
225pub use error::{Error, FailureKind, Result};
226pub use exec::CommandOutput;
227#[cfg(feature = "json")]
228pub use history::{SessionFile, SessionLog, SessionMeta, SessionQuery};
229pub use mcp_config::{McpConfigBuilder, McpServerConfig};
230pub use retry::{BackoffStrategy, RetryPolicy};
231#[cfg(feature = "json")]
232pub use session::{Session, TurnRecord};
233pub use types::*;
234pub use version::{
235    CliVersion, CliVersionStatus, TESTED_CLI_VERSION_MAX, TESTED_CLI_VERSION_MIN, VersionParseError,
236};
237
238/// Shared Codex CLI client configuration.
239///
240/// Holds the binary path, working directory, environment variables, global
241/// arguments, timeout, and retry policy. Cheap to [`Clone`]; intended to be
242/// created once and reused across many command invocations.
243///
244/// # Example
245///
246/// ```no_run
247/// # fn example() -> codex_wrapper::Result<()> {
248/// let codex = codex_wrapper::Codex::builder()
249///     .env("OPENAI_API_KEY", "sk-...")
250///     .timeout_secs(120)
251///     .build()?;
252/// # Ok(())
253/// # }
254/// ```
255#[derive(Debug, Clone)]
256pub struct Codex {
257    pub(crate) binary: PathBuf,
258    pub(crate) working_dir: Option<PathBuf>,
259    pub(crate) env: HashMap<String, String>,
260    pub(crate) global_args: Vec<String>,
261    pub(crate) timeout: Option<Duration>,
262    pub(crate) termination_grace: Duration,
263    pub(crate) process_group: bool,
264    pub(crate) retry_policy: Option<RetryPolicy>,
265    pub(crate) tested_cli_version_range: (CliVersion, CliVersion),
266}
267
268impl Codex {
269    /// Create a new [`CodexBuilder`].
270    #[must_use]
271    pub fn builder() -> CodexBuilder {
272        CodexBuilder::default()
273    }
274
275    /// Path to the resolved `codex` binary.
276    #[must_use]
277    pub fn binary(&self) -> &Path {
278        &self.binary
279    }
280
281    /// Working directory for command execution, if set.
282    #[must_use]
283    pub fn working_dir(&self) -> Option<&Path> {
284        self.working_dir.as_deref()
285    }
286
287    /// Return a clone of this client with a different working directory.
288    #[must_use]
289    pub fn with_working_dir(&self, dir: impl Into<PathBuf>) -> Self {
290        let mut clone = self.clone();
291        clone.working_dir = Some(dir.into());
292        clone
293    }
294
295    /// Query the installed Codex CLI version.
296    /// Read `config.toml` for this client's `CODEX_HOME`.
297    ///
298    /// `Ok(None)` when there is no config file. Requires the `config` feature.
299    /// See [`crate::config`] for what is typed and what stays raw.
300    #[cfg(feature = "config")]
301    pub fn config(&self) -> Result<Option<crate::config::CodexConfig>> {
302        let home = crate::codex_home::resolve(&|key| {
303            self.env
304                .get(key)
305                .cloned()
306                .or_else(|| std::env::var(key).ok())
307        });
308        crate::config::load_from_home(home)
309    }
310
311    /// Which credential this client's CLI would use, without spawning it.
312    ///
313    /// Honors a `CODEX_HOME` set on this client via
314    /// [`env`](CodexBuilder::env), falling back to the process environment.
315    /// See [`crate::auth`] for what the strategies mean and how they were
316    /// determined.
317    ///
318    /// ```no_run
319    /// use codex_wrapper::Codex;
320    ///
321    /// # fn example() -> codex_wrapper::Result<()> {
322    /// let codex = Codex::builder().build()?;
323    /// if !codex.auth_status().is_configured() {
324    ///     eprintln!("no credentials; run `codex login`");
325    /// }
326    /// # Ok(())
327    /// # }
328    /// ```
329    #[cfg(feature = "json")]
330    #[must_use]
331    pub fn auth_status(&self) -> crate::auth::AuthStatus {
332        crate::auth::detect_with(|key| {
333            self.env
334                .get(key)
335                .cloned()
336                .or_else(|| std::env::var(key).ok())
337        })
338    }
339
340    pub async fn cli_version(&self) -> Result<CliVersion> {
341        let output = VersionCommand::new().execute(self).await?;
342        CliVersion::parse_version_output(&output.stdout).map_err(|e| Error::Io {
343            message: format!("failed to parse CLI version: {e}"),
344            source: std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()),
345            working_dir: None,
346        })
347    }
348
349    /// Verify the installed CLI meets a minimum version requirement.
350    ///
351    /// Returns [`Error::VersionMismatch`] if the installed version is too old.
352    pub async fn check_version(&self, minimum: &CliVersion) -> Result<CliVersion> {
353        let version = self.cli_version().await?;
354        if version.satisfies_minimum(minimum) {
355            Ok(version)
356        } else {
357            Err(Error::VersionMismatch {
358                found: version,
359                minimum: *minimum,
360            })
361        }
362    }
363
364    /// The tested-against CLI version range this client reports on.
365    ///
366    /// Defaults to [`TESTED_CLI_VERSION_MIN`] and [`TESTED_CLI_VERSION_MAX`];
367    /// override with [`CodexBuilder::tested_cli_version_range`].
368    #[must_use]
369    pub fn tested_cli_version_range(&self) -> (CliVersion, CliVersion) {
370        self.tested_cli_version_range
371    }
372
373    /// Classify the installed CLI against the tested-against range.
374    ///
375    /// Emits a `tracing::warn!` when outside the range, and returns the typed
376    /// status either way. This reports; it does not fail. Most CLI releases
377    /// break nothing, so refusing to run against an unrecognized version is
378    /// worse than saying so. Use
379    /// [`ensure_tested_cli_version`](Self::ensure_tested_cli_version) when you
380    /// do want a hard gate.
381    ///
382    /// Intended for one-shot use at startup rather than before every command:
383    /// it spawns `codex --version`.
384    pub async fn cli_version_status(&self) -> Result<CliVersionStatus> {
385        let (min, max) = self.tested_cli_version_range;
386        let status = self.cli_version().await?.status_within(&min, &max);
387        warn_on_drift(&status);
388        Ok(status)
389    }
390
391    /// Like [`cli_version_status`](Self::cli_version_status), but returns
392    /// [`Error::UntestedCliVersion`] when the installed CLI is outside the
393    /// tested range.
394    ///
395    /// This is the opt-in hard gate. It is a method rather than a
396    /// [`CodexBuilder`] option because [`CodexBuilder::build`] is synchronous
397    /// and never spawns the binary; enforcing a version there would mean
398    /// running a subprocess inside a constructor.
399    pub async fn ensure_tested_cli_version(&self) -> Result<CliVersion> {
400        let (min, max) = self.tested_cli_version_range;
401        let found = self.cli_version().await?;
402        match found.status_within(&min, &max) {
403            CliVersionStatus::Tested => Ok(found),
404            status => {
405                warn_on_drift(&status);
406                Err(Error::UntestedCliVersion {
407                    found,
408                    tested_min: min,
409                    tested_max: max,
410                })
411            }
412        }
413    }
414}
415
416fn warn_on_drift(status: &CliVersionStatus) {
417    match status {
418        CliVersionStatus::Tested => {}
419        CliVersionStatus::NewerUntested { found, tested_max } => {
420            tracing::warn!(
421                found = %found,
422                tested_max = %tested_max,
423                "codex CLI is newer than this wrapper's tested-against range; \
424                 semantics may have drifted"
425            );
426        }
427        CliVersionStatus::OlderThanMinimum { found, minimum } => {
428            tracing::warn!(
429                found = %found,
430                minimum = %minimum,
431                "codex CLI is older than this wrapper's tested-against range; \
432                 some emitted arguments are likely to be rejected"
433            );
434        }
435    }
436}
437
438/// Builder for creating a [`Codex`] client.
439///
440/// All options are optional. By default the builder discovers the `codex`
441/// binary via `PATH`.
442#[derive(Debug, Default)]
443pub struct CodexBuilder {
444    binary: Option<PathBuf>,
445    working_dir: Option<PathBuf>,
446    env: HashMap<String, String>,
447    global_args: Vec<String>,
448    timeout: Option<Duration>,
449    termination_grace: Option<Duration>,
450    process_group: Option<bool>,
451    retry_policy: Option<RetryPolicy>,
452    tested_cli_version_range: Option<(CliVersion, CliVersion)>,
453}
454
455impl CodexBuilder {
456    /// Override the tested-against CLI version range.
457    ///
458    /// Defaults to the range this crate declares and verifies in CI. Set this
459    /// only when you have validated a different range yourself; widening it
460    /// does not make the wrapper work against versions it was not tested on.
461    #[must_use]
462    pub fn tested_cli_version_range(mut self, min: CliVersion, max: CliVersion) -> Self {
463        self.tested_cli_version_range = Some((min, max));
464        self
465    }
466
467    /// Set an explicit path to the `codex` binary (skips `PATH` lookup).
468    #[must_use]
469    pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
470        self.binary = Some(path.into());
471        self
472    }
473
474    /// Set the working directory for all commands.
475    #[must_use]
476    pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
477        self.working_dir = Some(path.into());
478        self
479    }
480
481    /// Set a single environment variable for child processes.
482    #[must_use]
483    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
484        self.env.insert(key.into(), value.into());
485        self
486    }
487
488    /// Set multiple environment variables for child processes.
489    #[must_use]
490    pub fn envs(
491        mut self,
492        vars: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
493    ) -> Self {
494        for (key, value) in vars {
495            self.env.insert(key.into(), value.into());
496        }
497        self
498    }
499
500    /// Set the command timeout in seconds.
501    #[must_use]
502    pub fn timeout_secs(mut self, seconds: u64) -> Self {
503        self.timeout = Some(Duration::from_secs(seconds));
504        self
505    }
506
507    /// Set the command timeout as a [`Duration`].
508    #[must_use]
509    pub fn timeout(mut self, duration: Duration) -> Self {
510        self.timeout = Some(duration);
511        self
512    }
513
514    /// How long a cancelled run's process group gets to exit before it is
515    /// killed. Defaults to five seconds.
516    ///
517    /// Applies to
518    /// [`run_codex_cancellable`](crate::exec::run_codex_cancellable), which
519    /// sends SIGTERM, waits this long, then sends SIGKILL. A dropped future
520    /// does not use it: `Drop` cannot wait, so it kills immediately.
521    #[must_use]
522    pub fn termination_grace(mut self, duration: Duration) -> Self {
523        self.termination_grace = Some(duration);
524        self
525    }
526
527    /// Whether each run gets its own process group. On by default.
528    ///
529    /// With a group of its own, cancelling a run reaches the subprocesses
530    /// codex spawned for tool use, not just codex itself (#78). That is the
531    /// right contract for a supervisor that cancels programmatically.
532    ///
533    /// Opting out puts the child in the parent's group, so a terminal Ctrl-C
534    /// reaches the whole run directly. Then a wrapper-side kill reaches only
535    /// the direct child, and its subprocesses survive. That is the right
536    /// contract for a terminal-attached host that shells out synchronously and
537    /// treats the terminal as the supervisor.
538    ///
539    /// Matches `claude-wrapper`'s option of the same name. No effect on
540    /// non-unix targets, which have no process groups.
541    #[must_use]
542    pub fn process_group(mut self, enabled: bool) -> Self {
543        self.process_group = Some(enabled);
544        self
545    }
546
547    /// Append a raw global argument passed before any subcommand.
548    #[must_use]
549    pub fn arg(mut self, arg: impl Into<String>) -> Self {
550        self.global_args.push(arg.into());
551        self
552    }
553
554    /// Add a global config override (`-c key=value`).
555    #[must_use]
556    pub fn config(mut self, key_value: impl Into<String>) -> Self {
557        self.global_args.push("-c".into());
558        self.global_args.push(key_value.into());
559        self
560    }
561
562    /// Enable a feature flag globally (`--enable <name>`).
563    #[must_use]
564    pub fn enable(mut self, feature: impl Into<String>) -> Self {
565        self.global_args.push("--enable".into());
566        self.global_args.push(feature.into());
567        self
568    }
569
570    /// Disable a feature flag globally (`--disable <name>`).
571    #[must_use]
572    pub fn disable(mut self, feature: impl Into<String>) -> Self {
573        self.global_args.push("--disable".into());
574        self.global_args.push(feature.into());
575        self
576    }
577
578    /// Set a default [`RetryPolicy`] for all commands.
579    #[must_use]
580    pub fn retry(mut self, policy: RetryPolicy) -> Self {
581        self.retry_policy = Some(policy);
582        self
583    }
584
585    /// Build the [`Codex`] client.
586    ///
587    /// Returns [`Error::NotFound`] if no binary path was set and `codex` is
588    /// not found in `PATH`.
589    pub fn build(self) -> Result<Codex> {
590        let binary = match self.binary {
591            Some(path) => path,
592            None => which::which("codex").map_err(|_| Error::NotFound)?,
593        };
594
595        Ok(Codex {
596            binary,
597            working_dir: self.working_dir,
598            env: self.env,
599            global_args: self.global_args,
600            termination_grace: self
601                .termination_grace
602                .unwrap_or_else(|| Duration::from_secs(5)),
603            process_group: self.process_group.unwrap_or(true),
604            timeout: self.timeout,
605            retry_policy: self.retry_policy,
606            tested_cli_version_range: self.tested_cli_version_range.unwrap_or((
607                version::TESTED_CLI_VERSION_MIN,
608                version::TESTED_CLI_VERSION_MAX,
609            )),
610        })
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    #[test]
619    fn builder_with_binary() {
620        let codex = Codex::builder()
621            .binary("/usr/local/bin/codex")
622            .env("FOO", "bar")
623            .timeout_secs(60)
624            .build()
625            .unwrap();
626
627        assert_eq!(codex.binary, PathBuf::from("/usr/local/bin/codex"));
628        assert_eq!(codex.env.get("FOO").unwrap(), "bar");
629        assert_eq!(codex.timeout, Some(Duration::from_secs(60)));
630    }
631
632    #[test]
633    fn builder_global_args() {
634        let codex = Codex::builder()
635            .binary("/usr/local/bin/codex")
636            .config("model=\"gpt-5\"")
637            .enable("foo")
638            .disable("bar")
639            .build()
640            .unwrap();
641
642        assert_eq!(
643            codex.global_args,
644            vec![
645                "-c",
646                "model=\"gpt-5\"",
647                "--enable",
648                "foo",
649                "--disable",
650                "bar"
651            ]
652        );
653    }
654
655    #[test]
656    fn client_defaults_to_the_crate_tested_range() {
657        let codex = Codex::builder().binary("/bin/echo").build().unwrap();
658        assert_eq!(
659            codex.tested_cli_version_range(),
660            (
661                version::TESTED_CLI_VERSION_MIN,
662                version::TESTED_CLI_VERSION_MAX
663            )
664        );
665    }
666
667    #[test]
668    fn builder_can_override_the_tested_range() {
669        let min = CliVersion::new(1, 0, 0);
670        let max = CliVersion::new(2, 0, 0);
671        let codex = Codex::builder()
672            .binary("/bin/echo")
673            .tested_cli_version_range(min, max)
674            .build()
675            .unwrap();
676        assert_eq!(codex.tested_cli_version_range(), (min, max));
677    }
678
679    #[test]
680    fn untested_version_error_names_both_bounds() {
681        let err = Error::UntestedCliVersion {
682            found: CliVersion::new(0, 200, 0),
683            tested_min: CliVersion::new(0, 145, 0),
684            tested_max: CliVersion::new(0, 146, 0),
685        };
686        assert_eq!(
687            err.to_string(),
688            "CLI version 0.200.0 is outside the tested range 0.145.0..=0.146.0"
689        );
690    }
691
692    /// A CODEX_HOME set on the client must win over the process environment,
693    /// or a client pointed at a different home reports the wrong credentials.
694    #[cfg(feature = "json")]
695    #[test]
696    fn auth_status_honors_a_client_codex_home() {
697        let dir =
698            std::env::temp_dir().join(format!("codex-wrapper-client-auth-{}", std::process::id()));
699        let _ = std::fs::remove_dir_all(&dir);
700        std::fs::create_dir_all(&dir).unwrap();
701        std::fs::write(
702            dir.join("auth.json"),
703            r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-secret"}"#,
704        )
705        .unwrap();
706
707        let codex = Codex::builder()
708            .binary("/bin/echo")
709            .env("CODEX_HOME", dir.to_str().unwrap())
710            .build()
711            .unwrap();
712
713        let status = codex.auth_status();
714        assert_eq!(status.codex_home, dir);
715        assert!(status.is_configured());
716        assert!(
717            !format!("{status:?}").contains("sk-secret"),
718            "the credential leaked into the status"
719        );
720
721        let _ = std::fs::remove_dir_all(&dir);
722    }
723}