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