Skip to main content

codex_wrapper/
error.rs

1//! Error types for `codex-wrapper`.
2
3use std::path::PathBuf;
4
5/// Errors returned by `codex-wrapper` operations.
6///
7/// This enum is `#[non_exhaustive]`: match arms must include a `_` catch-all so
8/// new variants can be added without a breaking change. This mirrors
9/// `claude-wrapper`'s `Error` for cross-crate consistency.
10#[derive(Debug, thiserror::Error)]
11#[non_exhaustive]
12pub enum Error {
13    /// The `codex` binary was not found in PATH.
14    #[error("codex binary not found in PATH")]
15    NotFound,
16
17    /// The CLI could not authenticate.
18    ///
19    /// Classified from stderr by [`Error::from_command_failure`]; see
20    /// [`FailureKind`] for what that costs. Not retried: the CLI has already
21    /// retried internally by the time this surfaces, and the credentials will
22    /// not have changed.
23    #[error("codex authentication failed: {}", first_line(message))]
24    Auth {
25        /// The CLI's stderr, trimmed. Retained whole rather than reduced to
26        /// the matched line, so nothing is lost to classification.
27        message: String,
28        command: String,
29        exit_code: i32,
30        working_dir: Option<PathBuf>,
31    },
32
33    /// The CLI rejected the configuration before running.
34    ///
35    /// An unknown key under `--strict-config`, or a malformed override.
36    #[error("codex rejected the configuration: {}", first_line(message))]
37    Config {
38        /// The CLI's stderr, trimmed.
39        message: String,
40        command: String,
41        exit_code: i32,
42        working_dir: Option<PathBuf>,
43    },
44
45    /// The working directory is not a trusted directory or git repo, and
46    /// `--skip-git-repo-check` was not set.
47    #[error("codex refused an untrusted directory: {}", first_line(message))]
48    NotTrustedDirectory {
49        /// The CLI's stderr, trimmed.
50        message: String,
51        command: String,
52        exit_code: i32,
53        working_dir: Option<PathBuf>,
54    },
55
56    /// The session or thread being resumed does not exist.
57    #[error("codex session not found: {}", first_line(message))]
58    SessionNotFound {
59        /// The CLI's stderr, trimmed.
60        message: String,
61        command: String,
62        exit_code: i32,
63        working_dir: Option<PathBuf>,
64    },
65
66    /// A codex command failed with a non-zero exit code.
67    #[error("codex command failed: {command} (exit code {exit_code}){}{}{}", working_dir.as_ref().map(|d| format!(" (in {})", d.display())).unwrap_or_default(), if stdout.is_empty() { String::new() } else { format!("\nstdout: {stdout}") }, if stderr.is_empty() { String::new() } else { format!("\nstderr: {stderr}") })]
68    CommandFailed {
69        command: String,
70        exit_code: i32,
71        stdout: String,
72        stderr: String,
73        working_dir: Option<PathBuf>,
74    },
75
76    /// An I/O error occurred while spawning or communicating with the process.
77    #[error("io error: {message}{}", working_dir.as_ref().map(|d| format!(" (in {})", d.display())).unwrap_or_default())]
78    Io {
79        message: String,
80        #[source]
81        source: std::io::Error,
82        working_dir: Option<PathBuf>,
83    },
84
85    /// The command timed out.
86    #[error("codex command timed out after {timeout_seconds}s")]
87    Timeout { timeout_seconds: u64 },
88
89    /// A session's token budget was reached.
90    ///
91    /// Denominated in tokens rather than money because the CLI reports token
92    /// counts and no cost; see [`crate::budget`].
93    #[error("token budget exceeded: {total_tokens} of {max_tokens} tokens")]
94    TokenBudgetExceeded {
95        /// Tokens recorded when the ceiling was hit. May exceed `max_tokens`,
96        /// since a turn's usage is only known once it has been spent.
97        total_tokens: u64,
98        /// The configured ceiling.
99        max_tokens: u64,
100    },
101
102    /// A native rollout-budget configuration was invalid before launch.
103    #[error("invalid Codex rollout budget: {message}")]
104    InvalidRolloutBudget {
105        /// The failed invariant.
106        message: String,
107    },
108
109    /// A file on disk could not be parsed.
110    ///
111    /// Distinct from [`Error::Config`], which is the CLI rejecting a
112    /// configuration it was given. This one never ran a command, so it
113    /// carries no exit code and is not a [`FailureKind`].
114    #[cfg(feature = "config")]
115    #[error("failed to parse {}: {message}", path.display())]
116    ConfigParse {
117        /// The file that could not be parsed.
118        path: PathBuf,
119        /// The parser's message.
120        message: String,
121    },
122
123    /// A bypass of codex's safety controls was requested without permission.
124    ///
125    /// See [`crate::dangerous`]. Never a command failure: nothing ran.
126    #[error("bypassing codex safety controls requires {variable} to be set")]
127    DangerousNotAllowed {
128        /// The environment variable that would have permitted it.
129        variable: &'static str,
130    },
131
132    /// The run was cancelled by the caller.
133    ///
134    /// The process group was asked to stop, given `grace_seconds`, then
135    /// killed. Distinct from [`Error::Timeout`], which is the client's own
136    /// deadline rather than the caller's decision.
137    #[error("codex run cancelled (after a {grace_seconds}s grace period)")]
138    Cancelled {
139        /// How long the group was given to exit before being killed.
140        grace_seconds: u64,
141    },
142
143    /// JSON parsing failed.
144    #[cfg(feature = "json")]
145    #[error("json parse error: {message}")]
146    Json {
147        message: String,
148        #[source]
149        source: serde_json::Error,
150    },
151
152    /// The installed CLI version does not meet the minimum requirement.
153    #[error("CLI version {found} does not meet minimum requirement {minimum}")]
154    VersionMismatch {
155        found: crate::version::CliVersion,
156        minimum: crate::version::CliVersion,
157    },
158
159    /// The installed CLI is outside the wrapper's tested-against range.
160    ///
161    /// Only returned by
162    /// [`Codex::ensure_tested_cli_version`](crate::Codex::ensure_tested_cli_version).
163    /// The default path reports drift as a
164    /// [`CliVersionStatus`](crate::CliVersionStatus) rather than an error.
165    #[error("CLI version {found} is outside the tested range {tested_min}..={tested_max}")]
166    UntestedCliVersion {
167        found: crate::version::CliVersion,
168        tested_min: crate::version::CliVersion,
169        tested_max: crate::version::CliVersion,
170    },
171}
172
173impl From<std::io::Error> for Error {
174    fn from(e: std::io::Error) -> Self {
175        Self::Io {
176            message: e.to_string(),
177            source: e,
178            working_dir: None,
179        }
180    }
181}
182
183/// Result type alias for codex-wrapper operations.
184pub type Result<T> = std::result::Result<T, Error>;
185
186/// The first non-empty line of a message, for a one-line `Display`.
187fn first_line(message: &str) -> &str {
188    message
189        .lines()
190        .map(str::trim)
191        .find(|line| !line.is_empty())
192        .unwrap_or(message)
193}
194
195/// The class of a failed command, for matching without destructuring.
196///
197/// Returned by [`Error::failure_kind`].
198#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199#[non_exhaustive]
200pub enum FailureKind {
201    /// The CLI could not authenticate.
202    Auth,
203    /// The CLI rejected the configuration before running.
204    Config,
205    /// The working directory was not trusted.
206    NotTrustedDirectory,
207    /// The session or thread being resumed does not exist.
208    SessionNotFound,
209    /// A non-zero exit that matched no known signature.
210    Unclassified,
211}
212
213/// Signatures observed on `codex-cli` 0.145.0, each from a captured failing
214/// run. Matched against stderr, since every one of them exits 1: the exit code
215/// carries no information here.
216///
217/// Deliberately matched on the stable part of each message. The auth line, for
218/// instance, also carries a request id and a `cf-ray` header that differ every
219/// time.
220const SIGNATURES: &[(&str, FailureKind)] = &[
221    ("401 Unauthorized", FailureKind::Auth),
222    ("Missing bearer or basic authentication", FailureKind::Auth),
223    (
224        "Not inside a trusted directory",
225        FailureKind::NotTrustedDirectory,
226    ),
227    ("Error loading config.toml", FailureKind::Config),
228    ("unknown configuration field", FailureKind::Config),
229    (
230        "no rollout found for thread id",
231        FailureKind::SessionNotFound,
232    ),
233];
234
235impl Error {
236    /// Build an error from a failed command, classifying it by stderr.
237    ///
238    /// Every non-zero exit in this crate goes through here, so a caller can
239    /// branch on the class rather than substring-matching stderr themselves.
240    /// An unrecognized failure stays [`Error::CommandFailed`] with its output
241    /// intact, so classification never loses information.
242    ///
243    /// Classification is by message, because it has to be: every failure
244    /// observed on 0.145.0 exits 1. That makes it sensitive to the CLI
245    /// rewording a message, which is the cost of doing it here instead of in
246    /// every caller. `tests/contract.rs` is where a reworded message should be
247    /// caught.
248    #[must_use]
249    pub fn from_command_failure(
250        command: String,
251        exit_code: i32,
252        stdout: String,
253        stderr: String,
254        working_dir: Option<PathBuf>,
255    ) -> Self {
256        let message = stderr.trim().to_string();
257
258        let kind = SIGNATURES
259            .iter()
260            .find(|(needle, _)| message.contains(needle))
261            .map(|(_, kind)| *kind);
262
263        match kind {
264            Some(FailureKind::Auth) => Error::Auth {
265                message,
266                command,
267                exit_code,
268                working_dir,
269            },
270            Some(FailureKind::Config) => Error::Config {
271                message,
272                command,
273                exit_code,
274                working_dir,
275            },
276            Some(FailureKind::NotTrustedDirectory) => Error::NotTrustedDirectory {
277                message,
278                command,
279                exit_code,
280                working_dir,
281            },
282            Some(FailureKind::SessionNotFound) => Error::SessionNotFound {
283                message,
284                command,
285                exit_code,
286                working_dir,
287            },
288            _ => Error::CommandFailed {
289                command,
290                exit_code,
291                stdout,
292                stderr,
293                working_dir,
294            },
295        }
296    }
297
298    /// The class of this failure, or `None` if it is not a command failure.
299    #[must_use]
300    pub fn failure_kind(&self) -> Option<FailureKind> {
301        match self {
302            Error::Auth { .. } => Some(FailureKind::Auth),
303            Error::Config { .. } => Some(FailureKind::Config),
304            Error::NotTrustedDirectory { .. } => Some(FailureKind::NotTrustedDirectory),
305            Error::SessionNotFound { .. } => Some(FailureKind::SessionNotFound),
306            Error::CommandFailed { .. } => Some(FailureKind::Unclassified),
307            _ => None,
308        }
309    }
310
311    /// The process exit code, for any variant that came from one.
312    ///
313    /// Classification moved some failures off [`Error::CommandFailed`], so
314    /// anything reading an exit code should read it here rather than matching
315    /// that one variant.
316    #[must_use]
317    pub fn exit_code(&self) -> Option<i32> {
318        match self {
319            Error::CommandFailed { exit_code, .. }
320            | Error::Auth { exit_code, .. }
321            | Error::Config { exit_code, .. }
322            | Error::NotTrustedDirectory { exit_code, .. }
323            | Error::SessionNotFound { exit_code, .. } => Some(*exit_code),
324            _ => None,
325        }
326    }
327
328    /// Whether re-running the identical command could plausibly succeed.
329    ///
330    /// False for the classified failures: each is a deterministic rejection,
331    /// and the CLI has already retried the auth case internally before it
332    /// surfaces here.
333    #[must_use]
334    pub fn is_deterministic_failure(&self) -> bool {
335        matches!(
336            self,
337            Error::Auth { .. }
338                | Error::Config { .. }
339                | Error::NotTrustedDirectory { .. }
340                | Error::SessionNotFound { .. }
341        )
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn display_not_found() {
351        let err = Error::NotFound;
352        assert_eq!(err.to_string(), "codex binary not found in PATH");
353    }
354
355    #[test]
356    fn display_command_failed_minimal() {
357        let err = Error::CommandFailed {
358            command: "exec".to_string(),
359            exit_code: 1,
360            stdout: String::new(),
361            stderr: String::new(),
362            working_dir: None,
363        };
364        assert_eq!(err.to_string(), "codex command failed: exec (exit code 1)");
365    }
366
367    #[test]
368    fn display_command_failed_with_all_fields() {
369        let err = Error::CommandFailed {
370            command: "exec".to_string(),
371            exit_code: 2,
372            stdout: "out".to_string(),
373            stderr: "err".to_string(),
374            working_dir: Some(PathBuf::from("/tmp")),
375        };
376        assert_eq!(
377            err.to_string(),
378            "codex command failed: exec (exit code 2) (in /tmp)\nstdout: out\nstderr: err"
379        );
380    }
381
382    #[test]
383    fn display_io_without_working_dir() {
384        let source = std::io::Error::other("disk full");
385        let err = Error::Io {
386            message: source.to_string(),
387            source,
388            working_dir: None,
389        };
390        assert_eq!(err.to_string(), "io error: disk full");
391    }
392
393    #[test]
394    fn display_io_with_working_dir() {
395        let source = std::io::Error::other("disk full");
396        let err = Error::Io {
397            message: source.to_string(),
398            source,
399            working_dir: Some(PathBuf::from("/home/user")),
400        };
401        assert_eq!(err.to_string(), "io error: disk full (in /home/user)");
402    }
403
404    #[test]
405    fn display_timeout() {
406        let err = Error::Timeout {
407            timeout_seconds: 30,
408        };
409        assert_eq!(err.to_string(), "codex command timed out after 30s");
410    }
411
412    #[cfg(feature = "json")]
413    #[test]
414    fn display_json() {
415        let source: serde_json::Error =
416            serde_json::from_str::<serde_json::Value>("invalid").unwrap_err();
417        let err = Error::Json {
418            message: source.to_string(),
419            source,
420        };
421        assert!(err.to_string().starts_with("json parse error:"));
422    }
423
424    #[test]
425    fn display_version_mismatch() {
426        let err = Error::VersionMismatch {
427            found: crate::version::CliVersion::new(0, 100, 0),
428            minimum: crate::version::CliVersion::new(0, 145, 0),
429        };
430        assert_eq!(
431            err.to_string(),
432            "CLI version 0.100.0 does not meet minimum requirement 0.145.0"
433        );
434    }
435
436    // ---------------------------------------------------------------
437    // Classification (#85)
438    // ---------------------------------------------------------------
439
440    fn classify(stderr: &str) -> Error {
441        Error::from_command_failure(
442            "codex exec hi".into(),
443            1,
444            String::new(),
445            stderr.into(),
446            None,
447        )
448    }
449
450    /// Each string is transcribed from a captured codex-cli 0.145.0 failure,
451    /// varying parts and all.
452    #[test]
453    fn classifies_every_captured_signature() {
454        let cases: &[(&str, FailureKind)] = &[
455            (
456                "ERROR: unexpected status 401 Unauthorized: Missing bearer or basic authentication in header, url: https://api.openai.com/v1/responses, cf-ray: a272310168bcba62-SJC, request id: req_ef11",
457                FailureKind::Auth,
458            ),
459            (
460                "Not inside a trusted directory and --skip-git-repo-check was not specified.",
461                FailureKind::NotTrustedDirectory,
462            ),
463            (
464                "Error loading config.toml: unknown configuration field `bogus` in -c/--config override",
465                FailureKind::Config,
466            ),
467            (
468                "Error: thread/resume: thread/resume failed: no rollout found for thread id 00000000-0000-0000-0000-000000000000 (code -32600)",
469                FailureKind::SessionNotFound,
470            ),
471        ];
472
473        for (stderr, expected) in cases {
474            let err = classify(stderr);
475            assert_eq!(
476                err.failure_kind(),
477                Some(*expected),
478                "misclassified: {stderr}"
479            );
480            assert_eq!(err.exit_code(), Some(1));
481            assert!(err.is_deterministic_failure(), "{stderr}");
482        }
483    }
484
485    /// An unrecognized failure must keep its output rather than being forced
486    /// into a class. Classification is allowed to not know.
487    #[test]
488    fn an_unknown_failure_stays_command_failed_with_its_output() {
489        let err = Error::from_command_failure(
490            "codex exec hi".into(),
491            2,
492            "partial stdout".into(),
493            "something new the CLI started saying".into(),
494            None,
495        );
496
497        assert_eq!(err.failure_kind(), Some(FailureKind::Unclassified));
498        assert!(!err.is_deterministic_failure());
499        match err {
500            Error::CommandFailed {
501                stdout,
502                stderr,
503                exit_code,
504                ..
505            } => {
506                assert_eq!(stdout, "partial stdout");
507                assert_eq!(stderr, "something new the CLI started saying");
508                assert_eq!(exit_code, 2);
509            }
510            other => panic!("expected CommandFailed, got {other:?}"),
511        }
512    }
513
514    /// The whole of stderr is kept, not just the matched line, so nothing is
515    /// lost to classification.
516    #[test]
517    fn a_classified_failure_keeps_the_full_message() {
518        let err = classify(
519            "ERROR: Reconnecting... 5/5\nERROR: unexpected status 401 Unauthorized: Missing bearer",
520        );
521        match &err {
522            Error::Auth { message, .. } => {
523                assert!(message.contains("Reconnecting... 5/5"), "{message}");
524                assert!(message.contains("401 Unauthorized"), "{message}");
525            }
526            other => panic!("expected Auth, got {other:?}"),
527        }
528        // Display stays one line, leading with the first thing stderr said.
529        assert_eq!(
530            err.to_string(),
531            "codex authentication failed: ERROR: Reconnecting... 5/5"
532        );
533    }
534
535    #[test]
536    fn non_command_errors_have_no_failure_kind() {
537        assert_eq!(Error::NotFound.failure_kind(), None);
538        assert_eq!(Error::Timeout { timeout_seconds: 5 }.failure_kind(), None);
539        assert_eq!(Error::NotFound.exit_code(), None);
540    }
541}