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