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}