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
185impl From<TransportError> for ClientError {
186    fn from(error: TransportError) -> Self {
187        match error {
188            TransportError::Protocol(error) => ClientError::Transport(error),
189            TransportError::Sidecar(message) => ClientError::Sidecar(message),
190        }
191    }
192}
193
194impl ClientError {
195    pub(crate) fn from_rejection(
196        rejection: agentos_sidecar_client::wire::RejectedResponse,
197    ) -> Self {
198        if rejection.code == "ERR_AGENTOS_RESOURCE_LIMIT"
199            || rejection.code == "ERR_AGENTOS_OVERLOADED"
200            || rejection.code == "timeout"
201            || matches!(
202                rejection.operation.as_deref(),
203                Some("package.acquire" | "package.cache_stats")
204            )
205        {
206            let details = Box::new(ResourceLimitDetails {
207                limit_name: rejection.limit_name,
208                configured_limit: rejection.configured_limit,
209                current_usage: rejection.current_usage,
210                requested: rejection.requested,
211                unit: rejection.unit,
212                scope: rejection.scope,
213                vm_id: rejection.vm_id,
214                session_generation: rejection.session_generation,
215                capability_id: rejection.capability_id,
216                operation: rejection.operation,
217                configuration_path: rejection.configuration_path,
218                retryable: rejection.retryable,
219                errno: rejection.errno,
220            });
221            if rejection.code == "timeout" {
222                return Self::OperationTimedOut {
223                    message: rejection.message,
224                    details,
225                };
226            }
227            if rejection.code == "ERR_AGENTOS_RESOURCE_LIMIT"
228                || rejection.code == "ERR_AGENTOS_OVERLOADED"
229            {
230                return Self::ResourceLimit {
231                    code: rejection.code,
232                    message: rejection.message,
233                    details,
234                };
235            }
236            return Self::PackageAcquisition {
237                code: rejection.code,
238                message: rejection.message,
239                details,
240            };
241        }
242        Self::Kernel {
243            code: rejection.code,
244            message: rejection.message,
245        }
246    }
247
248    /// Render this error the way the TypeScript `AgentOs` surfaces `err.message` into batch results
249    /// (`BatchWriteResult.error` / `BatchReadResult.error`).
250    ///
251    /// The general [`Display`](std::fmt::Display) impl carries a human/log-oriented prefix
252    /// (`kernel error [<code>]: ...`), but the batch surface is observable data that must match TS
253    /// byte-for-byte. For kernel failures TS reports `KernelError.message`, which is
254    /// `<code>: <message>` (and avoids doubling the code when the message already starts with it).
255    /// Path-guard variants already carry the exact TS strings via their `Display` impl.
256    pub fn batch_message(&self) -> String {
257        match self {
258            ClientError::Kernel { code, message } => {
259                if message.starts_with(&format!("{code}:")) {
260                    message.clone()
261                } else {
262                    format!("{code}: {message}")
263                }
264            }
265            ClientError::ResourceLimit { code, message, .. } => {
266                if message.starts_with(&format!("{code}:")) {
267                    message.clone()
268                } else {
269                    format!("{code}: {message}")
270                }
271            }
272            ClientError::PathNotAbsolute(_)
273            | ClientError::PathNotNormalized(_)
274            | ClientError::PathReadOnly(_)
275            | ClientError::InvalidConfig(_)
276            | ClientError::ProcessNotFound(_)
277            | ClientError::ExecutionTimedOut { .. }
278            | ClientError::OperationTimedOut { .. }
279            | ClientError::TerminationFailed { .. }
280            | ClientError::ShellNotFound(_)
281            | ClientError::InvalidSchedule(_)
282            | ClientError::PastSchedule(_)
283            | ClientError::InvalidPackageSource(_)
284            | ClientError::PackageDownload(_)
285            | ClientError::PackageTooLarge { .. }
286            | ClientError::PackageDigestMismatch { .. }
287            | ClientError::InvalidPackageFormat(_)
288            | ClientError::PackageIo(_)
289            | ClientError::PackageAcquisition { .. }
290            | ClientError::PackageCacheCapacity { .. }
291            | ClientError::PackageCacheEntryCapacity { .. }
292            | ClientError::PackageCachePendingLimit { .. }
293            | ClientError::PackageCacheConfiguration(_)
294            | ClientError::SoftwareNotFound(_)
295            | ClientError::Transport(_)
296            | ClientError::Sidecar(_) => self.to_string(),
297        }
298    }
299}
300
301/// Convenience alias for results carrying a typed [`ClientError`].
302pub type ClientResult<T> = std::result::Result<T, ClientError>;
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn structured_resource_limit_metadata_survives_rejection_conversion() {
310        let error = ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
311            code: String::from("ERR_AGENTOS_RESOURCE_LIMIT"),
312            message: String::from("handle command bytes exceeded"),
313            limit_name: Some(String::from("handleCommandBytes")),
314            configured_limit: Some(4096),
315            current_usage: Some(3072),
316            requested: Some(2048),
317            unit: Some(String::from("bytes")),
318            scope: Some(String::from("vm")),
319            vm_id: Some(String::from("vm-1")),
320            session_generation: Some(3),
321            capability_id: Some(11),
322            operation: Some(String::from("socket.write")),
323            configuration_path: Some(String::from("limits.reactor.maxHandleCommandBytes")),
324            retryable: Some(true),
325            errno: Some(String::from("EAGAIN")),
326        });
327
328        match error {
329            ClientError::ResourceLimit { details, .. } => {
330                assert_eq!(details.configured_limit, Some(4096));
331                assert_eq!(details.current_usage, Some(3072));
332                assert_eq!(details.requested, Some(2048));
333                assert_eq!(
334                    details.configuration_path.as_deref(),
335                    Some("limits.reactor.maxHandleCommandBytes")
336                );
337                assert_eq!(details.retryable, Some(true));
338                assert_eq!(details.errno.as_deref(), Some("EAGAIN"));
339            }
340            other => panic!("expected resource limit, got {other:?}"),
341        }
342    }
343
344    #[test]
345    fn sidecar_package_rejection_is_not_misclassified_as_a_kernel_error() {
346        for operation in ["package.acquire", "package.cache_stats"] {
347            for code in [
348                "invalid_package_source",
349                "invalid_package_format",
350                "package_digest_mismatch",
351                "package_download_failed",
352                "package_io_failed",
353                "package_cache_configuration",
354                "package_acquisition_failed",
355            ] {
356                let error =
357                    ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
358                        code: code.into(),
359                        message: "specific package failure".into(),
360                        operation: Some(operation.into()),
361                        limit_name: None,
362                        configured_limit: None,
363                        current_usage: None,
364                        requested: None,
365                        unit: None,
366                        scope: Some("session".into()),
367                        vm_id: None,
368                        session_generation: None,
369                        capability_id: None,
370                        configuration_path: None,
371                        retryable: Some(false),
372                        errno: Some("EIO".into()),
373                    });
374                let ClientError::PackageAcquisition {
375                    code: actual,
376                    message,
377                    details,
378                } = error
379                else {
380                    panic!("expected a typed package acquisition error");
381                };
382                assert_eq!(actual, code);
383                assert_eq!(message, "specific package failure");
384                assert_eq!(details.operation.as_deref(), Some(operation));
385                assert_eq!(details.scope.as_deref(), Some("session"));
386                assert_eq!(details.retryable, Some(false));
387                assert_eq!(details.errno.as_deref(), Some("EIO"));
388            }
389        }
390    }
391
392    #[test]
393    fn package_rejections_keep_limit_timeout_precedence_and_non_package_errors_unchanged() {
394        for code in [
395            "ERR_AGENTOS_RESOURCE_LIMIT",
396            "ERR_AGENTOS_OVERLOADED",
397            "timeout",
398            "invalid_package_source",
399        ] {
400            for operation in ["package.acquire", "filesystem.read"] {
401                let error =
402                    ClientError::from_rejection(agentos_sidecar_client::wire::RejectedResponse {
403                        code: code.into(),
404                        message: "wire failure".into(),
405                        limit_name: Some("exampleLimit".into()),
406                        configured_limit: Some(10),
407                        current_usage: Some(8),
408                        requested: Some(4),
409                        unit: Some("bytes".into()),
410                        scope: Some("session".into()),
411                        vm_id: None,
412                        session_generation: None,
413                        capability_id: None,
414                        operation: Some(operation.into()),
415                        configuration_path: Some("example.limit".into()),
416                        retryable: Some(true),
417                        errno: Some("EAGAIN".into()),
418                    });
419                let details = match (code, operation, error) {
420                    ("timeout", _, ClientError::OperationTimedOut { details, .. }) => details,
421                    (
422                        "ERR_AGENTOS_RESOURCE_LIMIT" | "ERR_AGENTOS_OVERLOADED",
423                        _,
424                        ClientError::ResourceLimit { details, .. },
425                    ) => details,
426                    (
427                        "invalid_package_source",
428                        "package.acquire",
429                        ClientError::PackageAcquisition { details, .. },
430                    ) => details,
431                    (
432                        "invalid_package_source",
433                        "filesystem.read",
434                        ClientError::Kernel { code, message },
435                    ) => {
436                        assert_eq!(code, "invalid_package_source");
437                        assert_eq!(message, "wire failure");
438                        continue;
439                    }
440                    (_, _, other) => panic!("unexpected rejection classification: {other:?}"),
441                };
442                assert_eq!(details.limit_name.as_deref(), Some("exampleLimit"));
443                assert_eq!(details.configured_limit, Some(10));
444                assert_eq!(details.current_usage, Some(8));
445                assert_eq!(details.requested, Some(4));
446                assert_eq!(details.configuration_path.as_deref(), Some("example.limit"));
447                assert_eq!(details.retryable, Some(true));
448                assert_eq!(details.errno.as_deref(), Some("EAGAIN"));
449            }
450        }
451    }
452}