Skip to main content

agentos_client/
error.rs

1//! Error taxonomy for the Agent OS client SDK.
2//!
3//! Mirrors `spec.md` §4 / ADR-001 §4. Preserves the TypeScript SDK distinction so callers can still
4//! discriminate path-guard violations from kernel errno failures. Public methods return
5//! [`anyhow::Result`]; the typed [`ClientError`] is carried as the `source` so callers can downcast.
6//!
7use agentos_sidecar_client::{ProtocolCodecError, TransportError};
8
9/// Structured sidecar admission metadata kept behind one allocation so the
10/// public [`ClientError`] remains cheap to return through every SDK method.
11#[derive(Debug, Clone, Default, serde::Serialize)]
12#[serde(rename_all = "camelCase")]
13pub struct ResourceLimitDetails {
14    pub limit_name: Option<String>,
15    pub configured_limit: Option<u64>,
16    pub current_usage: Option<u64>,
17    pub requested: Option<u64>,
18    pub unit: Option<String>,
19    pub scope: Option<String>,
20    pub vm_id: Option<String>,
21    pub session_generation: Option<u64>,
22    pub capability_id: Option<u64>,
23    pub operation: Option<String>,
24    pub configuration_path: Option<String>,
25    pub retryable: Option<bool>,
26    pub errno: Option<String>,
27}
28
29/// Typed error taxonomy for the client SDK.
30#[derive(thiserror::Error, Debug, Clone)]
31pub enum ClientError {
32    /// A filesystem path was not absolute (did not start with `/`).
33    ///
34    /// The message text matches the TypeScript `AgentOs` exactly (capital "P"). These strings are
35    /// observable data (they surface in `BatchWriteResult.error` / `BatchReadResult.error`), not
36    /// logs, so the casing follows TS rather than the lowercase log convention.
37    #[error("Path must be absolute: {0}")]
38    PathNotAbsolute(String),
39
40    /// A filesystem path was not in posix-normalized form.
41    ///
42    /// The message text matches the TypeScript `AgentOs` exactly (capital "P").
43    #[error("Path must be normalized: {0}")]
44    PathNotNormalized(String),
45
46    /// A write was attempted against a read-only path (for example `/proc`).
47    ///
48    /// The message text matches the TypeScript `AgentOs` exactly (capital "P").
49    #[error("Path is read-only: {0}")]
50    PathReadOnly(String),
51
52    /// An SDK-spawned process with the given pid was not found.
53    ///
54    /// The message text matches the TypeScript `AgentOs` exactly (capital "P"). These strings are
55    /// observable data (surfaced to callers), not logs, so the casing follows TS rather than the
56    /// lowercase log convention.
57    #[error("Process not found: {0}")]
58    ProcessNotFound(u32),
59
60    /// The execution deadline expired after the guest's exit was confirmed.
61    #[error("timeout: process {process_id} exceeded its execution deadline and was stopped")]
62    ExecutionTimedOut { process_id: String },
63
64    /// A sidecar operation exceeded its deadline. Unlike ExecutionTimedOut,
65    /// this does not assert successful guest termination or resource cleanup.
66    #[error("timeout: {message}")]
67    OperationTimedOut {
68        message: String,
69        details: Box<ResourceLimitDetails>,
70    },
71
72    /// Observation or cleanup failed without proof of the guest's exit.
73    #[error("termination_failed: process {process_id} could not be confirmed stopped: {reason}")]
74    TerminationFailed { process_id: String, reason: String },
75
76    /// A shell with the given synthetic `shell-N` id was not found.
77    #[error("shell not found: {0}")]
78    ShellNotFound(String),
79
80    /// A kernel/sidecar operation failed. The errno `code` string (`ENOENT`, `EEXIST`, `ENOTDIR`,
81    /// `EACCES`, `EISDIR`, `ENOTEMPTY`, ...) is preserved verbatim for parity with the TypeScript
82    /// `KernelError`.
83    #[error("kernel error [{code}]: {message}")]
84    Kernel { code: String, message: String },
85
86    /// A sidecar policy/admission bound rejected an operation. Fields are
87    /// copied from the lockstep wire response and never parsed from text.
88    #[error("resource limit [{code}]: {message}")]
89    ResourceLimit {
90        code: String,
91        message: String,
92        details: Box<ResourceLimitDetails>,
93    },
94
95    /// A caller-supplied config value was rejected before the VM was created.
96    #[error("invalid config: {0}")]
97    InvalidConfig(String),
98
99    /// A cron schedule string could not be parsed/validated.
100    #[error("invalid schedule: {0}")]
101    InvalidSchedule(String),
102
103    /// A one-shot (ISO-8601) cron schedule resolved to a time in the past.
104    #[error("schedule is in the past: {0}")]
105    PastSchedule(String),
106
107    /// A package source failed validation before any guest-visible mutation.
108    #[error("invalid package source: {0}")]
109    InvalidPackageSource(String),
110
111    /// Remote package acquisition failed under the configured bounded policy.
112    #[error("package download failed: {0}")]
113    PackageDownload(String),
114
115    /// A package exceeded the configured acquisition byte limit.
116    #[error(
117        "package is {observed} bytes; limit is {limit}; raise package_resolver.max_package_bytes"
118    )]
119    PackageTooLarge { observed: u64, limit: u64 },
120
121    /// The acquired bytes did not match the caller-pinned content identity.
122    #[error("package digest mismatch: expected {expected}, got {actual}")]
123    PackageDigestMismatch { expected: String, actual: String },
124
125    /// A downloaded file was not a bounded valid `.aospkg` container.
126    #[error("invalid .aospkg: {0}")]
127    InvalidPackageFormat(String),
128
129    /// Trusted host I/O failed while acquiring or staging a package.
130    #[error("package I/O failed: {0}")]
131    PackageIo(String),
132
133    /// A package acquisition failed in the sidecar. The wire code is kept
134    /// separately from the message because not every local package error can
135    /// be reconstructed from a generic rejection without parsing its text.
136    /// Preserve the same rejection metadata available to TypeScript callers.
137    #[error("package acquisition [{code}]: {message}")]
138    PackageAcquisition {
139        code: String,
140        message: String,
141        details: Box<ResourceLimitDetails>,
142    },
143
144    /// The process-local immutable package cache cannot admit another object
145    /// without evicting a package still pinned by a live VM.
146    #[error(
147        "package cache capacity exceeded: requested {requested} bytes with {current} of {limit} bytes in use; raise ProcessPackageCacheOptions.max_bytes or uninstall pinned software"
148    )]
149    PackageCacheCapacity {
150        requested: u64,
151        current: u64,
152        limit: u64,
153    },
154
155    #[error(
156        "package cache entry limit exceeded: {current} of {limit} entries are in use; raise ProcessPackageCacheOptions.max_entries or uninstall pinned software"
157    )]
158    PackageCacheEntryCapacity { current: usize, limit: usize },
159
160    /// Too many distinct acquisitions are waiting in the process. The bound
161    /// prevents unique untrusted URLs from growing the single-flight table.
162    #[error(
163        "package cache pending acquisition limit {limit} reached; raise ProcessPackageCacheOptions.max_pending_acquisitions"
164    )]
165    PackageCachePendingLimit { limit: usize },
166
167    /// A process cache was already initialized with different operator-owned
168    /// limits. Process-global configuration is first-write-once by design.
169    #[error("package cache is already configured differently: {0}")]
170    PackageCacheConfiguration(String),
171
172    /// An exact content-addressed installed package was not present.
173    #[error("software package not found: {0}")]
174    SoftwareNotFound(String),
175
176    /// A framing/codec failure on the sidecar transport.
177    #[error("transport error: {0}")]
178    Transport(#[from] ProtocolCodecError),
179
180    /// A generic sidecar rejection or I/O failure with context.
181    #[error("sidecar error: {0}")]
182    Sidecar(String),
183
184    /// The sidecar process exited or was killed, taking its VMs with it. Parity with TypeScript
185    /// `SidecarProcessExited`. Create a new VM to continue.
186    #[error("sidecar exited: {0}")]
187    SidecarExited(String),
188}
189
190impl From<TransportError> for ClientError {
191    fn from(error: TransportError) -> Self {
192        match error {
193            TransportError::Protocol(error) => ClientError::Transport(error),
194            TransportError::Sidecar(message) => ClientError::Sidecar(message),
195            TransportError::Disconnected(message) => ClientError::SidecarExited(message),
196        }
197    }
198}
199
200impl ClientError {
201    pub(crate) fn from_rejection(
202        rejection: agentos_sidecar_client::wire::RejectedResponse,
203    ) -> Self {
204        if rejection.code == "ERR_AGENTOS_RESOURCE_LIMIT"
205            || rejection.code == "ERR_AGENTOS_OVERLOADED"
206            || rejection.code == "timeout"
207            || matches!(
208                rejection.operation.as_deref(),
209                Some("package.acquire" | "package.cache_stats")
210            )
211        {
212            let details = Box::new(ResourceLimitDetails {
213                limit_name: rejection.limit_name,
214                configured_limit: rejection.configured_limit,
215                current_usage: rejection.current_usage,
216                requested: rejection.requested,
217                unit: rejection.unit,
218                scope: rejection.scope,
219                vm_id: rejection.vm_id,
220                session_generation: rejection.session_generation,
221                capability_id: rejection.capability_id,
222                operation: rejection.operation,
223                configuration_path: rejection.configuration_path,
224                retryable: rejection.retryable,
225                errno: rejection.errno,
226            });
227            if rejection.code == "timeout" {
228                return Self::OperationTimedOut {
229                    message: rejection.message,
230                    details,
231                };
232            }
233            if rejection.code == "ERR_AGENTOS_RESOURCE_LIMIT"
234                || rejection.code == "ERR_AGENTOS_OVERLOADED"
235            {
236                return Self::ResourceLimit {
237                    code: rejection.code,
238                    message: rejection.message,
239                    details,
240                };
241            }
242            return Self::PackageAcquisition {
243                code: rejection.code,
244                message: rejection.message,
245                details,
246            };
247        }
248        Self::Kernel {
249            code: rejection.code,
250            message: rejection.message,
251        }
252    }
253
254    /// Render this error the way the TypeScript `AgentOs` surfaces `err.message` into batch results
255    /// (`BatchWriteResult.error` / `BatchReadResult.error`).
256    ///
257    /// The general [`Display`](std::fmt::Display) impl carries a human/log-oriented prefix
258    /// (`kernel error [<code>]: ...`), but the batch surface is observable data that must match TS
259    /// byte-for-byte. For kernel failures TS reports `KernelError.message`, which is
260    /// `<code>: <message>` (and avoids doubling the code when the message already starts with it).
261    /// Path-guard variants already carry the exact TS strings via their `Display` impl.
262    pub fn batch_message(&self) -> String {
263        match self {
264            ClientError::Kernel { code, message } => {
265                if message.starts_with(&format!("{code}:")) {
266                    message.clone()
267                } else {
268                    format!("{code}: {message}")
269                }
270            }
271            ClientError::ResourceLimit { code, message, .. } => {
272                if message.starts_with(&format!("{code}:")) {
273                    message.clone()
274                } else {
275                    format!("{code}: {message}")
276                }
277            }
278            ClientError::PathNotAbsolute(_)
279            | ClientError::PathNotNormalized(_)
280            | ClientError::PathReadOnly(_)
281            | ClientError::InvalidConfig(_)
282            | ClientError::ProcessNotFound(_)
283            | ClientError::ExecutionTimedOut { .. }
284            | ClientError::OperationTimedOut { .. }
285            | ClientError::TerminationFailed { .. }
286            | ClientError::ShellNotFound(_)
287            | ClientError::InvalidSchedule(_)
288            | ClientError::PastSchedule(_)
289            | ClientError::InvalidPackageSource(_)
290            | ClientError::PackageDownload(_)
291            | ClientError::PackageTooLarge { .. }
292            | ClientError::PackageDigestMismatch { .. }
293            | ClientError::InvalidPackageFormat(_)
294            | ClientError::PackageIo(_)
295            | ClientError::PackageAcquisition { .. }
296            | ClientError::PackageCacheCapacity { .. }
297            | ClientError::PackageCacheEntryCapacity { .. }
298            | ClientError::PackageCachePendingLimit { .. }
299            | ClientError::PackageCacheConfiguration(_)
300            | ClientError::SoftwareNotFound(_)
301            | ClientError::Transport(_)
302            | ClientError::Sidecar(_)
303            | ClientError::SidecarExited(_) => self.to_string(),
304        }
305    }
306}
307
308/// Convenience alias for results carrying a typed [`ClientError`].
309pub type ClientResult<T> = std::result::Result<T, ClientError>;
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314
315    #[test]
316    fn structured_resource_limit_metadata_survives_rejection_conversion() {
317        let error = ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
318            code: String::from("ERR_AGENTOS_RESOURCE_LIMIT"),
319            message: String::from("handle command bytes exceeded"),
320            limit_name: Some(String::from("handleCommandBytes")),
321            configured_limit: Some(4096),
322            current_usage: Some(3072),
323            requested: Some(2048),
324            unit: Some(String::from("bytes")),
325            scope: Some(String::from("vm")),
326            vm_id: Some(String::from("vm-1")),
327            session_generation: Some(3),
328            capability_id: Some(11),
329            operation: Some(String::from("socket.write")),
330            configuration_path: Some(String::from("limits.reactor.maxHandleCommandBytes")),
331            retryable: Some(true),
332            errno: Some(String::from("EAGAIN")),
333        });
334
335        match error {
336            ClientError::ResourceLimit { details, .. } => {
337                assert_eq!(details.configured_limit, Some(4096));
338                assert_eq!(details.current_usage, Some(3072));
339                assert_eq!(details.requested, Some(2048));
340                assert_eq!(
341                    details.configuration_path.as_deref(),
342                    Some("limits.reactor.maxHandleCommandBytes")
343                );
344                assert_eq!(details.retryable, Some(true));
345                assert_eq!(details.errno.as_deref(), Some("EAGAIN"));
346            }
347            other => panic!("expected resource limit, got {other:?}"),
348        }
349    }
350
351    #[test]
352    fn sidecar_package_rejection_is_not_misclassified_as_a_kernel_error() {
353        for operation in ["package.acquire", "package.cache_stats"] {
354            for code in [
355                "invalid_package_source",
356                "invalid_package_format",
357                "package_digest_mismatch",
358                "package_download_failed",
359                "package_io_failed",
360                "package_cache_configuration",
361                "package_acquisition_failed",
362            ] {
363                let error =
364                    ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
365                        code: code.into(),
366                        message: "specific package failure".into(),
367                        operation: Some(operation.into()),
368                        limit_name: None,
369                        configured_limit: None,
370                        current_usage: None,
371                        requested: None,
372                        unit: None,
373                        scope: Some("session".into()),
374                        vm_id: None,
375                        session_generation: None,
376                        capability_id: None,
377                        configuration_path: None,
378                        retryable: Some(false),
379                        errno: Some("EIO".into()),
380                    });
381                let ClientError::PackageAcquisition {
382                    code: actual,
383                    message,
384                    details,
385                } = error
386                else {
387                    panic!("expected a typed package acquisition error");
388                };
389                assert_eq!(actual, code);
390                assert_eq!(message, "specific package failure");
391                assert_eq!(details.operation.as_deref(), Some(operation));
392                assert_eq!(details.scope.as_deref(), Some("session"));
393                assert_eq!(details.retryable, Some(false));
394                assert_eq!(details.errno.as_deref(), Some("EIO"));
395            }
396        }
397    }
398
399    #[test]
400    fn package_rejections_keep_limit_timeout_precedence_and_non_package_errors_unchanged() {
401        for code in [
402            "ERR_AGENTOS_RESOURCE_LIMIT",
403            "ERR_AGENTOS_OVERLOADED",
404            "timeout",
405            "invalid_package_source",
406        ] {
407            for operation in ["package.acquire", "filesystem.read"] {
408                let error =
409                    ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
410                        code: code.into(),
411                        message: "wire failure".into(),
412                        limit_name: Some("exampleLimit".into()),
413                        configured_limit: Some(10),
414                        current_usage: Some(8),
415                        requested: Some(4),
416                        unit: Some("bytes".into()),
417                        scope: Some("session".into()),
418                        vm_id: None,
419                        session_generation: None,
420                        capability_id: None,
421                        operation: Some(operation.into()),
422                        configuration_path: Some("example.limit".into()),
423                        retryable: Some(true),
424                        errno: Some("EAGAIN".into()),
425                    });
426                let details = match (code, operation, error) {
427                    ("timeout", _, ClientError::OperationTimedOut { details, .. }) => details,
428                    (
429                        "ERR_AGENTOS_RESOURCE_LIMIT" | "ERR_AGENTOS_OVERLOADED",
430                        _,
431                        ClientError::ResourceLimit { details, .. },
432                    ) => details,
433                    (
434                        "invalid_package_source",
435                        "package.acquire",
436                        ClientError::PackageAcquisition { details, .. },
437                    ) => details,
438                    (
439                        "invalid_package_source",
440                        "filesystem.read",
441                        ClientError::Kernel { code, message },
442                    ) => {
443                        assert_eq!(code, "invalid_package_source");
444                        assert_eq!(message, "wire failure");
445                        continue;
446                    }
447                    (_, _, other) => panic!("unexpected rejection classification: {other:?}"),
448                };
449                assert_eq!(details.limit_name.as_deref(), Some("exampleLimit"));
450                assert_eq!(details.configured_limit, Some(10));
451                assert_eq!(details.current_usage, Some(8));
452                assert_eq!(details.requested, Some(4));
453                assert_eq!(details.configuration_path.as_deref(), Some("example.limit"));
454                assert_eq!(details.retryable, Some(true));
455                assert_eq!(details.errno.as_deref(), Some("EAGAIN"));
456            }
457        }
458    }
459}