agentmux_common/
errors.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Cross-process error catalog.
5//!
6//! `AgentMuxError` is the single typed error returned by RPC handlers
7//! and surfaced to the frontend. Each variant carries a stable
8//! `AmxCode` (e.g. `AMX-IO-001`) so the renderer's translation table
9//! can look up a user-friendly message + recovery hint without
10//! caring about the wire-format details.
11//!
12//! See `docs/specs/SPEC_ERROR_CATALOG_2026_05_17.md` for the full
13//! design and migration plan.
14
15use serde::Serialize;
16use thiserror::Error;
17
18/// Stable string codes shipped to the frontend. The variant name in
19/// `AgentMuxError` is for Rust callers; the `&'static str` returned
20/// by `AmxCode::as_str()` is the contract with the catalog at
21/// `frontend/app/errors/catalog.ts`.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum AmxCode {
24    // Filesystem / I/O
25    OutOfSpace,
26    PermissionDenied,
27    PathNotFound,
28    PathTraversal,
29    // Persistence
30    MigrationFailed,
31    VersionMismatch,
32    // Provider CLI
33    CliNotInstalled,
34    NpmInstallFailed,
35    CliShimMissing,
36    /// Non-npm provider whose CLI couldn't be found on the system
37    /// PATH. The user must install it manually — there's no
38    /// in-app install path for these (Kimi via pip, etc.).
39    CliMissingOnPath,
40    // Auth
41    AuthRequiresTty,
42    AuthTimeout,
43    // Network
44    HttpError,
45    // Lifecycle
46    SidecarBindFailed,
47    AlreadyRunning,
48    // Fallback for un-migrated handlers — every legacy `Err(String)`
49    // gets wrapped in this so the frontend still has a code to grep.
50    Legacy,
51}
52
53impl AmxCode {
54    pub const fn as_str(self) -> &'static str {
55        match self {
56            AmxCode::OutOfSpace => "AMX-IO-001",
57            AmxCode::PermissionDenied => "AMX-IO-002",
58            AmxCode::PathNotFound => "AMX-IO-003",
59            AmxCode::PathTraversal => "AMX-IO-004",
60            AmxCode::MigrationFailed => "AMX-STORE-001",
61            AmxCode::VersionMismatch => "AMX-STORE-002",
62            AmxCode::CliNotInstalled => "AMX-CLI-001",
63            AmxCode::NpmInstallFailed => "AMX-CLI-002",
64            AmxCode::CliShimMissing => "AMX-CLI-003",
65            AmxCode::CliMissingOnPath => "AMX-CLI-004",
66            AmxCode::AuthRequiresTty => "AMX-AUTH-001",
67            AmxCode::AuthTimeout => "AMX-AUTH-002",
68            AmxCode::HttpError => "AMX-NET-001",
69            AmxCode::SidecarBindFailed => "AMX-LIFECYCLE-001",
70            AmxCode::AlreadyRunning => "AMX-LIFECYCLE-002",
71            AmxCode::Legacy => "AMX-LEGACY",
72        }
73    }
74}
75
76impl std::fmt::Display for AmxCode {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        f.write_str(self.as_str())
79    }
80}
81
82impl serde::Serialize for AmxCode {
83    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
84        ser.serialize_str(self.as_str())
85    }
86}
87
88/// The typed error returned by RPC handlers. Serializes to:
89/// `{ "code": "AMX-IO-001", "message": "device out of space ...", "details": { ... } }`
90#[derive(Debug, Error)]
91pub enum AgentMuxError {
92    // ── Filesystem / I/O ────────────────────────────────────
93    #[error("device out of space writing {path}")]
94    OutOfSpace { path: String, source_msg: String },
95
96    #[error("permission denied accessing {path}")]
97    PermissionDenied { path: String, source_msg: String },
98
99    #[error("path not found: {path}")]
100    PathNotFound { path: String },
101
102    #[error("path traversal blocked: {path}")]
103    PathTraversal { path: String },
104
105    // ── Persistence ─────────────────────────────────────────
106    #[error("schema migration {from}→{to} failed: {message}")]
107    MigrationFailed { from: u32, to: u32, message: String },
108
109    #[error("optimistic-lock version mismatch on {oid} (expected {expected}, actual {actual})")]
110    VersionMismatch { oid: String, expected: u64, actual: u64 },
111
112    // ── Provider CLI ────────────────────────────────────────
113    #[error("CLI {cli} not installed for provider {provider}")]
114    CliNotInstalled { provider: String, cli: String },
115
116    #[error("npm install failed for {package}: {message}")]
117    NpmInstallFailed { package: String, message: String },
118
119    #[error("installed CLI shim missing: {expected_path}")]
120    CliShimMissing { provider: String, expected_path: String },
121
122    #[error("{cli} not found on PATH for {provider}")]
123    CliMissingOnPath {
124        provider: String,
125        cli: String,
126        install_hint: String,
127    },
128
129    // ── Auth ────────────────────────────────────────────────
130    #[error("OAuth subprocess requires an interactive TTY: {provider}")]
131    AuthRequiresTty { provider: String },
132
133    #[error("OAuth login timed out after {seconds}s: {provider}")]
134    AuthTimeout { provider: String, seconds: u64 },
135
136    // ── Network ─────────────────────────────────────────────
137    #[error("HTTP request failed ({status:?}) for {url}: {message}")]
138    HttpError {
139        url: String,
140        status: Option<u16>,
141        message: String,
142    },
143
144    // ── Lifecycle ───────────────────────────────────────────
145    #[error("sidecar bind failed on port {port}: {message}")]
146    SidecarBindFailed { port: u16, message: String },
147
148    #[error("single-instance lock held by pid {pid}")]
149    AlreadyRunning { pid: u32 },
150
151    // ── Fallback ────────────────────────────────────────────
152    #[error("{0}")]
153    Legacy(String),
154}
155
156impl AgentMuxError {
157    /// Stable code for this variant. Mirrors the JSON `code` field
158    /// the frontend's catalog looks up.
159    pub fn code(&self) -> AmxCode {
160        match self {
161            AgentMuxError::OutOfSpace { .. } => AmxCode::OutOfSpace,
162            AgentMuxError::PermissionDenied { .. } => AmxCode::PermissionDenied,
163            AgentMuxError::PathNotFound { .. } => AmxCode::PathNotFound,
164            AgentMuxError::PathTraversal { .. } => AmxCode::PathTraversal,
165            AgentMuxError::MigrationFailed { .. } => AmxCode::MigrationFailed,
166            AgentMuxError::VersionMismatch { .. } => AmxCode::VersionMismatch,
167            AgentMuxError::CliNotInstalled { .. } => AmxCode::CliNotInstalled,
168            AgentMuxError::NpmInstallFailed { .. } => AmxCode::NpmInstallFailed,
169            AgentMuxError::CliShimMissing { .. } => AmxCode::CliShimMissing,
170            AgentMuxError::CliMissingOnPath { .. } => AmxCode::CliMissingOnPath,
171            AgentMuxError::AuthRequiresTty { .. } => AmxCode::AuthRequiresTty,
172            AgentMuxError::AuthTimeout { .. } => AmxCode::AuthTimeout,
173            AgentMuxError::HttpError { .. } => AmxCode::HttpError,
174            AgentMuxError::SidecarBindFailed { .. } => AmxCode::SidecarBindFailed,
175            AgentMuxError::AlreadyRunning { .. } => AmxCode::AlreadyRunning,
176            AgentMuxError::Legacy(_) => AmxCode::Legacy,
177        }
178    }
179
180    /// Helper for the common case: an `std::io::Error` raised while
181    /// operating on a known path. Use this at call sites that have
182    /// the path on hand — the `From<std::io::Error>` impl below
183    /// can't recover the path from the bare IO error.
184    pub fn from_io_with_path(path: impl Into<String>, err: std::io::Error) -> Self {
185        let path = path.into();
186        let source_msg = err.to_string();
187        match Self::classify_io(&err) {
188            AmxCode::OutOfSpace => AgentMuxError::OutOfSpace { path, source_msg },
189            AmxCode::PermissionDenied => AgentMuxError::PermissionDenied { path, source_msg },
190            AmxCode::PathNotFound => AgentMuxError::PathNotFound { path },
191            _ => AgentMuxError::Legacy(format!("{path}: {source_msg}")),
192        }
193    }
194
195    fn classify_io(err: &std::io::Error) -> AmxCode {
196        // `ErrorKind::StorageFull` was stabilized in 1.83 but we
197        // can't rely on it across all toolchains the CI uses. Match
198        // raw OS codes instead. ENOSPC=28 is portable across Unix +
199        // also unused on Windows. The Windows-specific codes 39 and
200        // 112 collide with Unix errnos (ENOTEMPTY / EHOSTDOWN) so
201        // they must be gated to `cfg(windows)` — otherwise a
202        // disconnected CIFS mount on Linux would mis-classify as
203        // "Device out of space."
204        if err.raw_os_error() == Some(28) {
205            return AmxCode::OutOfSpace;
206        }
207        #[cfg(windows)]
208        if matches!(err.raw_os_error(), Some(39) | Some(112)) {
209            // 39  = ERROR_HANDLE_DISK_FULL (file-handle-bound APIs)
210            // 112 = ERROR_DISK_FULL        (volume-level APIs)
211            return AmxCode::OutOfSpace;
212        }
213        match err.kind() {
214            std::io::ErrorKind::PermissionDenied => AmxCode::PermissionDenied,
215            std::io::ErrorKind::NotFound => AmxCode::PathNotFound,
216            _ => AmxCode::Legacy,
217        }
218    }
219
220    /// Serializes the error into the wire format the RPC engine
221    /// emits. Frontend pattern-matches on `code`.
222    pub fn to_wire(&self) -> serde_json::Value {
223        let mut details = serde_json::Map::new();
224        match self {
225            AgentMuxError::OutOfSpace { path, source_msg } => {
226                details.insert("path".into(), path.clone().into());
227                details.insert("source_msg".into(), source_msg.clone().into());
228            }
229            AgentMuxError::PermissionDenied { path, source_msg } => {
230                details.insert("path".into(), path.clone().into());
231                details.insert("source_msg".into(), source_msg.clone().into());
232            }
233            AgentMuxError::PathNotFound { path } | AgentMuxError::PathTraversal { path } => {
234                details.insert("path".into(), path.clone().into());
235            }
236            AgentMuxError::MigrationFailed { from, to, message } => {
237                details.insert("from".into(), (*from).into());
238                details.insert("to".into(), (*to).into());
239                details.insert("message".into(), message.clone().into());
240            }
241            AgentMuxError::VersionMismatch { oid, expected, actual } => {
242                details.insert("oid".into(), oid.clone().into());
243                details.insert("expected".into(), (*expected).into());
244                details.insert("actual".into(), (*actual).into());
245            }
246            AgentMuxError::CliNotInstalled { provider, cli } => {
247                details.insert("provider".into(), provider.clone().into());
248                details.insert("cli".into(), cli.clone().into());
249            }
250            AgentMuxError::NpmInstallFailed { package, message } => {
251                details.insert("package".into(), package.clone().into());
252                details.insert("message".into(), message.clone().into());
253            }
254            AgentMuxError::CliShimMissing { provider, expected_path } => {
255                details.insert("provider".into(), provider.clone().into());
256                details.insert("expected_path".into(), expected_path.clone().into());
257            }
258            AgentMuxError::CliMissingOnPath { provider, cli, install_hint } => {
259                details.insert("provider".into(), provider.clone().into());
260                details.insert("cli".into(), cli.clone().into());
261                details.insert("install_hint".into(), install_hint.clone().into());
262            }
263            AgentMuxError::AuthRequiresTty { provider } => {
264                details.insert("provider".into(), provider.clone().into());
265            }
266            AgentMuxError::AuthTimeout { provider, seconds } => {
267                details.insert("provider".into(), provider.clone().into());
268                details.insert("seconds".into(), (*seconds).into());
269            }
270            AgentMuxError::HttpError { url, status, message } => {
271                details.insert("url".into(), url.clone().into());
272                if let Some(s) = status {
273                    details.insert("status".into(), (*s).into());
274                }
275                details.insert("message".into(), message.clone().into());
276            }
277            AgentMuxError::SidecarBindFailed { port, message } => {
278                details.insert("port".into(), (*port).into());
279                details.insert("message".into(), message.clone().into());
280            }
281            AgentMuxError::AlreadyRunning { pid } => {
282                details.insert("pid".into(), (*pid).into());
283            }
284            AgentMuxError::Legacy(_) => {}
285        }
286        serde_json::json!({
287            "code": self.code().as_str(),
288            "message": self.to_string(),
289            "details": serde_json::Value::Object(details),
290        })
291    }
292}
293
294impl Serialize for AgentMuxError {
295    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
296        self.to_wire().serialize(ser)
297    }
298}
299
300impl From<std::io::Error> for AgentMuxError {
301    /// Implicit conversion routes by `ErrorKind` + raw OS code, but
302    /// loses the path context. Prefer `from_io_with_path` at sites
303    /// that know which file/dir was being operated on — the empty
304    /// path here renders as the literal `(unknown path)` sentinel
305    /// in `Display`, which leaks into wire `message` and the
306    /// Details disclosure.
307    fn from(err: std::io::Error) -> Self {
308        let source_msg = err.to_string();
309        let unknown = || UNKNOWN_PATH.to_string();
310        match Self::classify_io(&err) {
311            AmxCode::OutOfSpace => AgentMuxError::OutOfSpace {
312                path: unknown(),
313                source_msg,
314            },
315            AmxCode::PermissionDenied => AgentMuxError::PermissionDenied {
316                path: unknown(),
317                source_msg,
318            },
319            AmxCode::PathNotFound => AgentMuxError::PathNotFound { path: unknown() },
320            _ => AgentMuxError::Legacy(source_msg),
321        }
322    }
323}
324
325/// Sentinel rendered when an `std::io::Error` is converted without
326/// path context (via the `From` impl above). `from_io_with_path`
327/// supplies the real path so the user sees the offending location.
328const UNKNOWN_PATH: &str = "(unknown path)";
329
330/// Wrap a free-text string in `AgentMuxError::Legacy`. Used by the
331/// RPC engine to bridge un-migrated handlers that still return
332/// `Result<_, String>`.
333impl From<String> for AgentMuxError {
334    fn from(s: String) -> Self {
335        AgentMuxError::Legacy(s)
336    }
337}
338
339impl From<&str> for AgentMuxError {
340    fn from(s: &str) -> Self {
341        AgentMuxError::Legacy(s.to_string())
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn code_strs_unique_and_stable() {
351        let all = [
352            AmxCode::OutOfSpace,
353            AmxCode::PermissionDenied,
354            AmxCode::PathNotFound,
355            AmxCode::PathTraversal,
356            AmxCode::MigrationFailed,
357            AmxCode::VersionMismatch,
358            AmxCode::CliNotInstalled,
359            AmxCode::NpmInstallFailed,
360            AmxCode::CliShimMissing,
361            AmxCode::CliMissingOnPath,
362            AmxCode::AuthRequiresTty,
363            AmxCode::AuthTimeout,
364            AmxCode::HttpError,
365            AmxCode::SidecarBindFailed,
366            AmxCode::AlreadyRunning,
367            AmxCode::Legacy,
368        ];
369        let mut seen: Vec<&str> = Vec::new();
370        for c in all {
371            let s = c.as_str();
372            assert!(s.starts_with("AMX-"), "{s} missing AMX- prefix");
373            assert!(!seen.contains(&s), "duplicate code {s}");
374            seen.push(s);
375        }
376    }
377
378    #[test]
379    fn io_error_routes_enospc_to_out_of_space() {
380        // Synthesize an error with ENOSPC OS code.
381        let err = std::io::Error::from_raw_os_error(28);
382        let mux: AgentMuxError = err.into();
383        assert_eq!(mux.code(), AmxCode::OutOfSpace);
384    }
385
386    #[cfg(windows)]
387    #[test]
388    fn io_error_routes_windows_disk_full_to_out_of_space() {
389        let err = std::io::Error::from_raw_os_error(112);
390        let mux: AgentMuxError = err.into();
391        assert_eq!(mux.code(), AmxCode::OutOfSpace);
392    }
393
394    #[cfg(windows)]
395    #[test]
396    fn io_error_routes_windows_handle_disk_full_to_out_of_space() {
397        // ERROR_HANDLE_DISK_FULL — what `WriteFile` returns when the
398        // disk fills up via a file-handle-bound write.
399        let err = std::io::Error::from_raw_os_error(39);
400        let mux: AgentMuxError = err.into();
401        assert_eq!(mux.code(), AmxCode::OutOfSpace);
402    }
403
404    #[cfg(not(windows))]
405    #[test]
406    fn io_error_unix_ehostdown_does_not_route_to_out_of_space() {
407        // On Linux raw OS error 112 = EHOSTDOWN, not disk-full.
408        // Must NOT be classified as OutOfSpace — the Windows code
409        // 112 (ERROR_DISK_FULL) must be cfg-gated.
410        let err = std::io::Error::from_raw_os_error(112);
411        let mux: AgentMuxError = err.into();
412        assert_ne!(mux.code(), AmxCode::OutOfSpace);
413    }
414
415    #[test]
416    fn io_error_permission_denied_routes() {
417        let err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
418        let mux: AgentMuxError = err.into();
419        assert_eq!(mux.code(), AmxCode::PermissionDenied);
420    }
421
422    #[test]
423    fn io_error_not_found_routes() {
424        let err = std::io::Error::new(std::io::ErrorKind::NotFound, "nope");
425        let mux: AgentMuxError = err.into();
426        assert_eq!(mux.code(), AmxCode::PathNotFound);
427    }
428
429    #[test]
430    fn io_error_unclassified_routes_to_legacy() {
431        let err = std::io::Error::new(std::io::ErrorKind::InvalidData, "bad bytes");
432        let mux: AgentMuxError = err.into();
433        assert_eq!(mux.code(), AmxCode::Legacy);
434    }
435
436    #[test]
437    fn from_io_with_path_preserves_path() {
438        let err = std::io::Error::from_raw_os_error(28);
439        let mux = AgentMuxError::from_io_with_path("/tmp/foo.db", err);
440        assert_eq!(mux.code(), AmxCode::OutOfSpace);
441        match mux {
442            AgentMuxError::OutOfSpace { path, .. } => assert_eq!(path, "/tmp/foo.db"),
443            _ => panic!("expected OutOfSpace"),
444        }
445    }
446
447    #[test]
448    fn wire_format_has_code_message_details() {
449        let mux = AgentMuxError::OutOfSpace {
450            path: "/tmp/x".into(),
451            source_msg: "ENOSPC".into(),
452        };
453        let wire = mux.to_wire();
454        assert_eq!(wire["code"], "AMX-IO-001");
455        assert!(wire["message"]
456            .as_str()
457            .unwrap()
458            .contains("/tmp/x"));
459        assert_eq!(wire["details"]["path"], "/tmp/x");
460        assert_eq!(wire["details"]["source_msg"], "ENOSPC");
461    }
462
463    #[test]
464    fn wire_format_round_trip_via_serde() {
465        let mux = AgentMuxError::CliNotInstalled {
466            provider: "claude".into(),
467            cli: "claude".into(),
468        };
469        let json = serde_json::to_value(&mux).unwrap();
470        assert_eq!(json["code"], "AMX-CLI-001");
471        assert_eq!(json["details"]["provider"], "claude");
472    }
473
474    #[test]
475    fn legacy_string_wraps_unchanged() {
476        let mux: AgentMuxError = "legacy raw message".into();
477        let wire = mux.to_wire();
478        assert_eq!(wire["code"], "AMX-LEGACY");
479        assert_eq!(wire["message"], "legacy raw message");
480    }
481}