harn_hostlib/error.rs
1//! Error type for hostlib host calls.
2//!
3//! Builtins translate this into VM-level errors via [`Into<harn_vm::VmError>`]
4//! so that Harn scripts see structured exceptions rather than panics.
5
6use harn_vm::VmDictExt;
7
8use harn_vm::{VmError, VmValue};
9
10/// All errors a hostlib builtin can surface.
11///
12/// Variants intentionally describe the *kind* of failure rather than the
13/// specific module — every module routes its missing-implementation errors
14/// through [`HostlibError::Unimplemented`] so embedders and tests can
15/// distinguish intentionally scaffolded contracts from runtime failures.
16#[derive(Debug, thiserror::Error)]
17pub enum HostlibError {
18 /// The method exists in the registration table but has no implementation
19 /// yet. This is the canonical scaffold-stage error: it tells callers
20 /// "the contract is stable, but this module has not been implemented."
21 #[error(
22 "hostlib: {builtin} is not implemented yet (scaffolded contract without an implementation)"
23 )]
24 Unimplemented {
25 /// Fully-qualified builtin name, e.g. `"hostlib_ast_parse_file"`.
26 builtin: &'static str,
27 },
28
29 /// A required parameter was missing from the call payload.
30 #[error("hostlib: {builtin}: missing required parameter '{param}'")]
31 MissingParameter {
32 /// Fully-qualified builtin name.
33 builtin: &'static str,
34 /// Name of the missing parameter.
35 param: &'static str,
36 },
37
38 /// A parameter was present but had the wrong shape (wrong type, malformed).
39 #[error("hostlib: {builtin}: invalid parameter '{param}': {message}")]
40 InvalidParameter {
41 /// Fully-qualified builtin name.
42 builtin: &'static str,
43 /// Name of the invalid parameter.
44 param: &'static str,
45 /// Human-readable description of the violation.
46 message: String,
47 },
48
49 /// Catch-all wrapper for I/O, parsing, or other backend failures.
50 #[error("hostlib: {builtin}: {message}")]
51 Backend {
52 /// Fully-qualified builtin name.
53 builtin: &'static str,
54 /// Human-readable failure description.
55 message: String,
56 },
57
58 /// The OS could not start a requested process. `kind` is the canonical
59 /// `io::ErrorKind` spelling retained for script-side classification.
60 #[error("hostlib: {builtin}: process spawn failed: {message}")]
61 ProcessSpawn {
62 /// Fully-qualified builtin name.
63 builtin: &'static str,
64 /// Stable kind such as `not_found` or `permission_denied`.
65 kind: &'static str,
66 /// Human-readable OS error.
67 message: String,
68 },
69
70 /// A path the builtin resolved fell outside the session's workspace
71 /// roots under a restricted sandbox profile. The mirror of the
72 /// `harness.fs.*` `tool_rejected` rejection — both surfaces reject an
73 /// out-of-root path with the same message.
74 #[error("{message}")]
75 SandboxViolation {
76 /// Fully-qualified builtin name.
77 builtin: &'static str,
78 /// The normalized path that was rejected, for telemetry.
79 path: String,
80 /// The canonical rejection message (see
81 /// [`harn_vm::process_sandbox::SandboxViolation::message`]).
82 message: String,
83 },
84
85 /// A host capability cannot preserve the active sandbox contract.
86 #[error("{message}")]
87 SandboxUnsupported {
88 /// Fully-qualified builtin name.
89 builtin: &'static str,
90 /// Active sandbox profile.
91 profile: String,
92 /// Stable rejection message.
93 message: String,
94 },
95
96 /// A never-approvable UNIVERSAL catastrophic command (machine/disk/data
97 /// destruction) was rejected by the floor BEFORE spawning, at the shared
98 /// [`crate::process::spawn_process`] chokepoint. Enforced unconditionally
99 /// (no `command_policy` required), so it is universal across every hostlib
100 /// process tool, embedders, and standalone Harn. Mirrors the
101 /// `catastrophic_floor` disposition the `process.exec` command-policy
102 /// preflight surfaces. See
103 /// [`harn_vm::orchestration::universal_catastrophic_reason`].
104 #[error("{message}")]
105 CatastrophicFloor {
106 /// Fully-qualified builtin name.
107 builtin: &'static str,
108 /// The verbatim floor rationale (never-approvable reason).
109 message: String,
110 },
111}
112
113impl HostlibError {
114 /// The fully-qualified builtin name this error came from. Useful for
115 /// embedder logging and for the routing tests in `tests/`.
116 pub fn builtin(&self) -> &'static str {
117 match self {
118 HostlibError::Unimplemented { builtin }
119 | HostlibError::MissingParameter { builtin, .. }
120 | HostlibError::InvalidParameter { builtin, .. }
121 | HostlibError::Backend { builtin, .. }
122 | HostlibError::ProcessSpawn { builtin, .. }
123 | HostlibError::SandboxViolation { builtin, .. }
124 | HostlibError::SandboxUnsupported { builtin, .. }
125 | HostlibError::CatastrophicFloor { builtin, .. } => builtin,
126 }
127 }
128}
129
130impl From<HostlibError> for VmError {
131 fn from(err: HostlibError) -> VmError {
132 // Surface as a `Thrown` dict so Harn `try`/`catch` can pattern-match
133 // on `kind`, `builtin`, and `message`. This matches how the existing
134 // `host_call` error path shapes its exceptions.
135 let kind = match &err {
136 HostlibError::Unimplemented { .. } => "unimplemented",
137 HostlibError::MissingParameter { .. } => "missing_parameter",
138 HostlibError::InvalidParameter { .. } => "invalid_parameter",
139 HostlibError::Backend { .. } => "backend_error",
140 HostlibError::ProcessSpawn { kind, .. } => *kind,
141 HostlibError::SandboxViolation { .. } => "tool_rejected",
142 HostlibError::SandboxUnsupported { .. } => "sandbox_unsupported",
143 HostlibError::CatastrophicFloor { .. } => "catastrophic_floor",
144 };
145 // Carry the offending path on sandbox violations so `catch` blocks
146 // and telemetry can branch on it without re-parsing the message.
147 let path = match &err {
148 HostlibError::SandboxViolation { path, .. } => Some(path.clone()),
149 _ => None,
150 };
151 let profile = match &err {
152 HostlibError::SandboxUnsupported { profile, .. } => Some(profile.clone()),
153 _ => None,
154 };
155 let builtin = err.builtin();
156 let is_process_spawn = matches!(&err, HostlibError::ProcessSpawn { .. });
157 let message = err.to_string();
158
159 let mut dict: harn_vm::value::DictMap = harn_vm::value::DictMap::new();
160 dict.put_str("kind", kind);
161 dict.put_str("builtin", builtin);
162 dict.put_str("message", message);
163 if is_process_spawn {
164 dict.put_str("error", "io_error");
165 dict.put_str("operation", "process_spawn");
166 dict.put_str("category", "environment");
167 }
168 if let Some(path) = path {
169 dict.put_str("path", path);
170 }
171 if let Some(profile) = profile {
172 dict.put_str("profile", profile);
173 }
174 VmError::Thrown(VmValue::dict(dict))
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn process_spawn_error_lowers_to_typed_io_value() {
184 let error = HostlibError::ProcessSpawn {
185 builtin: "hostlib_tools_run_command",
186 kind: "not_found",
187 message: "No such file or directory".to_string(),
188 };
189 let VmError::Thrown(VmValue::Dict(fields)) = VmError::from(error) else {
190 panic!("expected a structured thrown value");
191 };
192 let field = |name| fields.get(name).map(VmValue::display);
193 assert_eq!(field("error").as_deref(), Some("io_error"));
194 assert_eq!(field("kind").as_deref(), Some("not_found"));
195 assert_eq!(field("operation").as_deref(), Some("process_spawn"));
196 assert_eq!(field("category").as_deref(), Some("environment"));
197 assert_eq!(
198 field("builtin").as_deref(),
199 Some("hostlib_tools_run_command")
200 );
201 }
202}