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}