capsule_runtime/errors.rs
1use std::path::PathBuf;
2
3use thiserror::Error;
4
5/// One `capabilities.shell.allow` entry that resolved to a script whose interpreter's package
6/// tree nothing declared could reach inside a `sealed` composed root.
7///
8/// Defined here rather than in [`crate::reachability`] — which is a `pub(crate)` module — because
9/// it is a field of a public [`RuntimeError`] variant and so has to be nameable by anything that
10/// matches on one (`murmur-cli`'s `CliError` conversion, above all).
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct UnreachableEntrypoint {
13 /// The `capabilities.shell.allow` entry, verbatim as the manifest wrote it.
14 pub binary: String,
15 /// Where that entry resolves on this host — the first `PATH` match, i.e. the one `execvp`
16 /// would run.
17 pub resolved_path: PathBuf,
18 /// The bare interpreter name read out of the file's `#!` line, with one level of
19 /// `env NAME` indirection resolved. This is the name a covering
20 /// `interpreter_runtime`/`staged_runtime` grant would have to declare.
21 pub interpreter: String,
22}
23
24impl std::fmt::Display for UnreachableEntrypoint {
25 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26 write!(
27 f,
28 "'{}' ({}, a script run by '{}')",
29 self.binary,
30 self.resolved_path.display(),
31 self.interpreter
32 )
33 }
34}
35
36// The four codes below live beside the variants that raise them because two callers name them:
37// murmur-cli renders them on the terminal, and the A2A terminal status a process attempt writes
38// carries the same code. Defining them twice would let a client and an operator be shown
39// different codes for one failure.
40
41/// The code [`RuntimeError::HarnessTurnFailed`] renders under.
42pub const E_RUN_033: &str = "E-RUN-033";
43/// The code [`RuntimeError::ProcessDriverCallFailed`] renders under.
44pub const E_RUN_034: &str = "E-RUN-034";
45/// The code [`RuntimeError::ProcessHarnessInactive`] renders under.
46pub const E_RUN_035: &str = "E-RUN-035";
47/// The code [`RuntimeError::HarnessSessionGone`] renders under.
48pub const E_RUN_036: &str = "E-RUN-036";
49
50#[derive(Debug, Error)]
51pub enum RuntimeError {
52 #[error("artifact {name}@{version} not found in registry")]
53 ArtifactNotFound { name: String, version: String },
54
55 #[error("artifact integrity check failed for {name}@{version}")]
56 ArtifactIntegrityFailed { name: String, version: String },
57
58 #[error("lockfile missing artifact entry for '{name}'")]
59 LockMissingEntry { name: String },
60
61 #[error(
62 "lockfile version mismatch for '{name}': manifest requested {requested}, lock pinned {pinned}"
63 )]
64 LockVersionMismatch {
65 name: String,
66 requested: String,
67 pinned: String,
68 },
69
70 #[error("failed to read artifact archive for {name}@{version}: {message}")]
71 ArtifactArchive {
72 name: String,
73 version: String,
74 message: String,
75 },
76
77 #[error("failed to compile component for {name}@{version}: {message}")]
78 ToolComponentCompile {
79 name: String,
80 version: String,
81 message: String,
82 },
83
84 #[error("failed to compile capsule component: {0}")]
85 CapsuleCompile(String),
86
87 #[error("failed to create workdir at {path}: {source}")]
88 CreateWorkdir {
89 path: String,
90 #[source]
91 source: std::io::Error,
92 },
93
94 #[error("failed to write tool manifest at {path}: {source}")]
95 WriteToolManifest {
96 path: String,
97 #[source]
98 source: std::io::Error,
99 },
100
101 #[error("failed to read inference.system_prompt_file at {path}: {source}")]
102 SystemPromptFileRead {
103 path: String,
104 #[source]
105 source: std::io::Error,
106 },
107
108 #[error("failed to read inference.compaction.system_prompt_file at {path}: {source}")]
109 CompactionSystemPromptFileRead {
110 path: String,
111 #[source]
112 source: std::io::Error,
113 },
114
115 #[error(
116 "inference.system_prompt_artifact '{name}': skill.md not found or unreadable; \
117 ensure the skill is declared in artifacts: and the capsule was staged"
118 )]
119 SystemPromptArtifactRead {
120 name: String,
121 #[source]
122 source: std::io::Error,
123 },
124
125 #[error("skill source path not found: {path}")]
126 SkillSourceNotFound { path: String },
127
128 #[error("skill source directory '{path}' contains no skill.md")]
129 SkillSourceMissingSkillMd { path: String },
130
131 #[error("failed to read skill source at {path}: {source}")]
132 SkillSourceRead {
133 path: String,
134 #[source]
135 source: std::io::Error,
136 },
137
138 #[error("invalid network capability allow entry '{entry}': {message}")]
139 InvalidNetworkAllowEntry { entry: String, message: String },
140
141 #[error("invalid filesystem capability scope '{scope}': {message}")]
142 InvalidFilesystemScope { scope: String, message: String },
143
144 /// A `capabilities.filesystem.read_only` entry is not a usable workdir subpath: it is
145 /// absolute, or it escapes the workdir via `..`. The exact parallel of
146 /// [`Self::InvalidFilesystemScope`], raised from
147 /// [`crate::protected_paths::ProtectedPaths::from_declared`] at staging — before any registry
148 /// pull, workdir creation or component instantiation — so no call is ever checked against a
149 /// rule the runtime could not build.
150 #[error("invalid read-only path '{path}': {message}")]
151 InvalidReadOnlyPath { path: String, message: String },
152
153 /// An artifact entry's `config:` block cannot be delivered as `MURMUR_ARTIFACT_CONFIG`: it is
154 /// not a string-keyed mapping, does not serialize to JSON, or exceeds
155 /// [`crate::artifact_config::MAX_ARTIFACT_CONFIG_BYTES`]. Raised at staging (and from `mur run
156 /// --explain-scope`), before any registry pull, workdir creation or component instantiation,
157 /// so a malformed channel is refused once at launch rather than one tool call at a time.
158 #[error("invalid config for artifact '{artifact}': {message}")]
159 InvalidArtifactConfig { artifact: String, message: String },
160
161 /// A `capabilities.state.store` name is not a single usable directory segment under
162 /// `~/.murmur/state/`. Raised at staging (and from `mur run --explain-scope`), before any
163 /// registry pull, workdir creation or component instantiation, so a bad name never gets as far
164 /// as creating something on disk.
165 #[error("invalid state store name '{store}': {message}")]
166 InvalidStateStore { store: String, message: String },
167
168 /// A `context.record_store` value, or a `mur run --context` value, is not a single usable
169 /// directory segment of a conversation record path. Raised at staging, before any registry
170 /// pull, workdir creation or component instantiation, so a bad name never gets as far as
171 /// creating something on disk.
172 ///
173 /// Distinct from [`Self::InvalidStateStore`] because the remedies point at different keys, and
174 /// because a record is on by default while a state store is declared: an operator seeing this
175 /// may not have written a `context:` block at all, only a `--context` flag.
176 #[error("invalid {field} '{value}': {message}")]
177 InvalidConversationRecord {
178 field: String,
179 value: String,
180 message: String,
181 },
182
183 /// `mur run --resume` named a session whose context kept no conversation record on disk, so
184 /// there is nothing to continue. Raised at staging, before this launch's workdir exists: a
185 /// resume that silently started fresh would be indistinguishable from one that worked.
186 #[error(
187 "cannot resume session {session}: context '{context_id}' has no conversation record ({reason})"
188 )]
189 ResumeRecordMissing {
190 session: String,
191 context_id: String,
192 reason: String,
193 },
194
195 /// `mur run --resume-mode compact` on a capsule with no hook bound to `on-compaction`. There
196 /// is nothing to produce the summary, and falling back to `full` would silently give the
197 /// operator the mode they did not ask for.
198 #[error(
199 "--resume-mode compact needs a hook bound to on-compaction; this capsule declares none"
200 )]
201 ResumeCompactionHookMissing,
202
203 /// `mur run --resume-mode compact` on a `transport: process` capsule. The harness holds the
204 /// conversation and the runtime has no message list to hand a compaction hook, so there is
205 /// nothing `compact` could summarize.
206 #[error(
207 "--resume-mode compact is not available under inference.transport: process; the harness \
208 holds this conversation and murmur has no history to compact"
209 )]
210 ResumeCompactUnsupportedTransport,
211
212 /// The state store directory a validated name resolves to could not be resolved or created.
213 /// Distinct from [`Self::InvalidStateStore`]: the declaration is well-formed and the failure is
214 /// the host's — an unset `HOME`, or a path that cannot be made a `0700` directory.
215 #[error("state store '{store}' is unavailable at {path}: {message}")]
216 StateStoreUnavailable {
217 store: String,
218 path: String,
219 message: String,
220 },
221
222 /// The declared containment floor is stronger than what this host's kernel can back.
223 /// Raised in the stage/launch seam, before any registry pull, component compile or workdir
224 /// creation — `achieved` always comes from a live host probe, never from the manifest.
225 #[error(
226 "declared containment class '{declared}' is not achievable on this host (achieved: '{achieved}'): {reason}"
227 )]
228 ContainmentFloorUnmet {
229 declared: murmur_artifact::ContainmentClass,
230 achieved: murmur_artifact::ContainmentClass,
231 reason: String,
232 },
233 /// A capsule declares an `exports.files.root` that, once resolved against the session's
234 /// accessible workdir, lands somewhere else — the root already exists as a symlink pointing
235 /// out of it.
236 ///
237 /// Refused at launch rather than per request: an export root outside the workdir is a
238 /// misconfiguration the operator can fix, and discovering it one served file at a time would
239 /// mean the first file had already left.
240 #[error(
241 "{field} '{declared}' resolves to '{resolved}', which is outside the capsule \
242 workdir '{workdir}'"
243 )]
244 ExportRootOutsideWorkdir {
245 /// The manifest key that declared it — `exports.files.root` or
246 /// `exports.peer_files.root`. Carried rather than hard-coded because the two roots are
247 /// separate authorisers and an operator fixing one must be told which.
248 field: String,
249 declared: String,
250 resolved: String,
251 workdir: String,
252 },
253
254 /// A capsule declares an artifact whose name collides with a tool the runtime provides
255 /// itself.
256 ///
257 /// The four names in [`crate::runtime::RESERVED_TOOL_NAMES`] are answered inside
258 /// `dispatch_agent_tool_unfenced` before any allowlist check is reached, and their synthetic
259 /// manifests are written after the staging loop. An artifact installed under one of them would
260 /// have its manifest overwritten and every call to it answered by the runtime, so the collision
261 /// is refused rather than resolved into a tool the capsule can never reach.
262 #[error(
263 "artifact '{name}' collides with a tool the runtime provides itself; the reserved names \
264 are {}",
265 crate::runtime::RESERVED_TOOL_NAMES.join(", ")
266 )]
267 ReservedToolName {
268 /// The declared artifact name that collides.
269 name: String,
270 },
271
272 /// A capsule's `capabilities.env.allow` names variables the credential backstop strips from
273 /// every guest environment.
274 ///
275 /// The strip in `build_declared_env` is not negotiable from a manifest, so the entries
276 /// can only ever deliver nothing; the declaration is refused rather than left silently inert.
277 #[error(
278 "capabilities.env.allow names {}; these entries are removed from every guest environment \
279 before any guest is built, so the grant would deliver nothing",
280 crate::runtime::describe_stripped_env_allow_entries(entries)
281 )]
282 EnvAllowStrippedByBackstop {
283 /// Every distinct stripped entry, in first-declaration order.
284 entries: Vec<crate::runtime::StrippedEnvAllowEntry>,
285 },
286
287 /// A synthetic tool manifest was written under a name absent from
288 /// [`crate::runtime::RESERVED_TOOL_NAMES`].
289 ///
290 /// The inverse of [`Self::ReservedToolName`], and the reason a further runtime-provided tool
291 /// cannot be added without reserving its name: an unreserved name is claimable by an artifact,
292 /// which would then be installed and silently shadowed at dispatch.
293 #[error(
294 "'{name}' is written as a runtime-provided tool but is not one of {}; add it to \
295 RESERVED_TOOL_NAMES so no artifact can claim the name",
296 crate::runtime::RESERVED_TOOL_NAMES.join(", ")
297 )]
298 RuntimeProvidedToolNotReserved {
299 /// The name the synthetic write was attempted under.
300 name: String,
301 },
302
303 /// A capsule declares `exports.peer_files` together with `lifecycle.after_task: sleep`, and
304 /// either declared no `max_ttl` or declared one above the persistent ceiling.
305 ///
306 /// An ephemeral capsule needs no ceiling: teardown destroys the minting key, so every
307 /// outstanding handle stops verifying at once and the declared lifetime can never outlive the
308 /// process. `sleep` withdraws that bound deliberately, which makes the declared lifetime the
309 /// only one there is — so it has to be declared, and it has to be short.
310 #[error(
311 "exports.peer_files with lifecycle.after_task: sleep requires exports.peer_files.max_ttl \
312 to be declared and at most {ceiling_secs}s{}; a handle's lifetime is not a durability \
313 mechanism",
314 match declared_secs {
315 Some(secs) => format!(" (declared {secs}s)"),
316 None => String::new(),
317 }
318 )]
319 PersistentCapsuleNeedsHandleTtl {
320 /// The declared `max_ttl` in seconds, or `None` when the manifest declared none.
321 declared_secs: Option<u64>,
322 ceiling_secs: u64,
323 },
324
325 /// A capsule declares `capabilities.shell.staged_runtime` without an effective `sealed`
326 /// containment floor.
327 ///
328 /// Deliberately distinct from [`Self::ContainmentFloorUnmet`]: that one compares the declared
329 /// floor against what the *host* can back, and its remedy is to lower the floor or move hosts.
330 /// This one is decided against the declared floor alone and never looks at the host at all, so
331 /// it fires identically on a machine that could deliver `sealed` — a staged runtime has no
332 /// composed root to be staged into unless the capsule asked for one, and quietly launching it
333 /// without the mount would leave the interpreter simply absent. Its remedy is to raise the
334 /// declared floor (or drop the grant), which is the opposite advice.
335 ///
336 /// `binaries` names every offending `staged_runtime` binary so the operator does not have to
337 /// re-run to find the second one.
338 #[error(
339 "capabilities.shell.staged_runtime is declared for {} but the effective containment floor \
340 is '{declared}' — staging a runtime tree requires the 'sealed' floor, because there is no \
341 composed root to bind-mount it into below that",
342 .binaries.join(", ")
343 )]
344 StagedRuntimeRequiresSealed {
345 binaries: Vec<String>,
346 declared: murmur_artifact::ContainmentClass,
347 },
348
349 /// A `sealed` capsule allowlists a script whose interpreter's package tree nothing declared
350 /// could make reachable inside the composed root.
351 ///
352 /// Deliberately distinct from [`Self::StagedRuntimeRequiresSealed`], which it sits next to in
353 /// `stage_session`: that one fires when a grant *was* declared at too low a floor, this one
354 /// fires when no grant was declared at all and the floor is already `sealed`. Their remedies
355 /// point in opposite directions — raise the floor there, add a grant here.
356 ///
357 /// `entries` names every offending allowlist entry so an operator fixing this does not have to
358 /// re-run to discover the second script, following the same precedent.
359 #[error(
360 "capabilities.shell.allow grants {} under the 'sealed' containment floor, but nothing \
361 declared makes the interpreted entrypoint's own package tree reachable inside the \
362 composed root — the script's ELF/DT_NEEDED closure is empty, so staging it stages \
363 nothing its interpreter imports, and the capsule would fail with a module-not-found \
364 error partway into a run rather than here. Declare \
365 capabilities.shell.interpreter_runtime or capabilities.shell.staged_runtime naming the \
366 interpreter (measure the real directories with \
367 `strace -f -e trace=openat,getdents64 <the command>`), or use a copy of the interpreter \
368 and its packages that already lives under a fixed sealed runtime path",
369 .entries
370 .iter()
371 .map(UnreachableEntrypoint::to_string)
372 .collect::<Vec<_>>()
373 .join("; ")
374 )]
375 ShellBinaryPackageUnreachable { entries: Vec<UnreachableEntrypoint> },
376
377 /// A `sealed` session's composed root could not be built in the forked child, *after* the
378 /// pre-launch probe reported the mechanism available.
379 ///
380 /// Deliberately distinct from [`Self::ContainmentFloorUnmet`]: that one is a refusal decided
381 /// before anything is staged, on a host that never claimed to offer `sealed`. This one is a
382 /// host that claimed it and then failed to deliver — a race, a mount table that changed under
383 /// the runtime, or an edge case the cheap probe cannot catch. Conflating the two would tell an
384 /// operator to lower their declared floor when the real answer is that something moved.
385 ///
386 /// `detail` is the message the child wrote to the `pre_exec` diagnostic pipe, which names the
387 /// exact step (`unshare`, a specific bind mount, `pivot_root`) and its errno.
388 #[error(
389 "the sealed containment class was achievable at launch but its composed root could not be \
390 built for this subprocess: {detail}"
391 )]
392 SealedRootConstructionFailed { detail: String },
393
394 /// This host cannot give a capsule's native subprocess tree its own network namespace, so the
395 /// egress boundary that mediates `capabilities.network.allow` cannot be built.
396 ///
397 /// Raised before any registry pull, component compile or workdir creation, and raised for
398 /// **every** Linux capsule that can spawn a subprocess — including one whose
399 /// `capabilities.network.allow` is empty. That breadth is the point: with the namespace
400 /// missing, an empty allowlist would mean *unrestricted* egress rather than none, because the
401 /// seccomp `connect`/`sendto` interception that used to back it was deleted as unsound rather
402 /// than kept as a fallback. There is deliberately no path that continues at reduced
403 /// enforcement.
404 ///
405 /// Deliberately distinct from [`Self::ContainmentFloorUnmet`], which this would otherwise
406 /// look like: that one compares a *declared* containment class against what the host can
407 /// back, and its remedy is to lower the declared floor. A network namespace is not part of
408 /// the containment ladder — every class needs it equally — so lowering the floor fixes
409 /// nothing here, and offering that advice would be actively misleading.
410 #[error(
411 "this host cannot give the capsule's subprocess tree its own network namespace, so \
412 capabilities.network.allow cannot be enforced for it: {reason}"
413 )]
414 EgressNamespaceUnavailable {
415 blocker: crate::network_namespace::EgressNamespaceBlocker,
416 reason: String,
417 },
418
419 #[error(
420 "capsule component missing export murmur:capsule/run@0.1.0#run; rebuild the artifact against the versioned WIT (run `mur install` for a default artifact, or rebuild from source otherwise)"
421 )]
422 CapsuleExportMissing,
423
424 #[error(
425 "tool component '{name}' missing export murmur:tool/run@0.1.0#run; rebuild the artifact against the versioned WIT (run `mur install` for a default artifact, or rebuild from source otherwise)"
426 )]
427 ToolExportMissing { name: String },
428
429 #[error("capsule execution trapped: {0}")]
430 CapsuleTrap(String),
431
432 #[error(
433 "capsule execution exceeded its {seconds}s deadline and was interrupted; \
434 raise capabilities.limits.deadline_seconds in murmur.yaml if the capsule \
435 legitimately needs longer"
436 )]
437 CapsuleDeadlineExceeded { seconds: u64 },
438
439 #[error("capsule execution exceeded its configured resource limits: {message}")]
440 CapsuleResourceLimit { message: String },
441
442 #[error(
443 "shell subprocess '{binary}' was killed for exceeding \
444 capabilities.resources.{limit}: {detail}"
445 )]
446 ShellResourceLimitExceeded {
447 binary: String,
448 limit: String,
449 detail: String,
450 },
451
452 #[error(
453 "tool '{tool}' returned data_path '{path}', which does not resolve to a readable file \
454 beneath the session workdir ({code}): {detail}"
455 )]
456 ToolDataPathRefused {
457 tool: String,
458 path: String,
459 code: String,
460 detail: String,
461 },
462
463 #[error(
464 "this capsule can spawn native subprocesses but no cgroup v2 scope could be created to \
465 bound them ({reason}); on Linux the runtime refuses to launch rather than run a \
466 subprocess tree with no aggregate memory/pids/cpu ceiling — see the systemd user \
467 delegation requirement in docs/content/reference/resource-limits-manual-verification.md"
468 )]
469 CgroupDelegationUnavailable { reason: String },
470
471 #[error(
472 "session workdir grew to {observed_bytes} bytes, past the {max_bytes} byte ceiling \
473 (capabilities.resources.workdir_max_bytes)"
474 )]
475 WorkdirSizeExceeded { max_bytes: u64, observed_bytes: u64 },
476
477 #[error("failed to initialize WASI for {path}: {message}")]
478 WasiInit { path: String, message: String },
479
480 #[error("inference driver is not configured; add inference.driver.artifact to murmur.yaml")]
481 DriverNotConfigured,
482
483 #[error("inference driver '{0}' is not installed in the local tool registry")]
484 DriverNotInstalled(String),
485
486 /// An artifact whose operator entry declares `gateway:` has a bundled manifest with no usable
487 /// `upstream_auth:` block, so the runtime cannot tell how its upstream expects the key.
488 /// Refused at staging, before any component runs, whether or not `gateway.api_key` is set.
489 /// `reason` is `None` when the block is absent and says what is wrong when it is malformed.
490 #[error(
491 "artifact '{name}@{version}' declares no usable upstream_auth: block{}",
492 .reason.as_ref().map(|reason| format!(" ({reason})")).unwrap_or_default()
493 )]
494 GatewayArtifactDeclaresNoUpstreamAuth {
495 name: String,
496 version: String,
497 reason: Option<String>,
498 },
499
500 /// A `gateway:` block is declared on a `runtime: tool` entry whose artifact ships a native
501 /// implementation. A native tool's requests leave through the egress proxy and never pass the
502 /// credential gateway, so the key could never be attached. Refused at staging.
503 #[error(
504 "artifact '{name}' declares 'gateway:' but ships a native implementation, whose requests \
505 never pass the credential gateway"
506 )]
507 GatewayOnNativeArtifact { name: String },
508
509 /// An [`crate::types::ArtifactRequest`]'s gateway carries neither a credential nor the
510 /// operator's `keyless: true`. The manifest parser refuses that shape first; staging refuses
511 /// it again so no caller can build a gateway that sends keyless by omission.
512 #[error("artifact '{name}' declares gateway.endpoint '{endpoint}' but binds no credential")]
513 GatewayWithoutCredential { name: String, endpoint: String },
514
515 /// `gateway.api_key: ${variable}` on artifact `artifact` names a credential that neither the
516 /// global config's `credentials:` map nor the launching environment holds. Refused at staging,
517 /// before the session directory exists.
518 #[error(
519 "murmur.yaml: gateway.api_key on artifact '{artifact}' references ${{{variable}}}, but \
520 neither credentials.{variable} in {} nor the environment variable {variable} is set",
521 .credentials_file
522 .as_ref()
523 .map(|path| path.display().to_string())
524 .unwrap_or_else(|| "the global config".to_string())
525 )]
526 GatewayCredentialNotFound {
527 artifact: String,
528 variable: String,
529 credentials_file: Option<std::path::PathBuf>,
530 },
531
532 /// `spend.machine_tokens_per_day` is in effect and the shared ledger it is kept in cannot be
533 /// used — an unset `HOME`, a `~/.murmur/spend` that cannot be made a `0700` directory, or a
534 /// daily file that cannot be opened. Raised at staging, before the session directory exists
535 /// and before any provider request.
536 #[error("spend ledger at {path} is unavailable: {message}")]
537 SpendLedgerUnavailable { path: String, message: String },
538
539 #[error("agent loop failed: {0}")]
540 AgentLoopFailed(String),
541
542 #[error("internal_port {port} is already bound; choose another port or omit internal_port to use an OS-assigned port")]
543 PortInUse { port: u16 },
544
545 /// A session whose manifest declares `capabilities.spawn.allow` could not register with
546 /// `mur-roost`, so the daemon does not hold the grants it would referee that session's
547 /// delegations against.
548 ///
549 /// Fatal rather than a warning: a capsule that can delegate but is unknown to the daemon is a
550 /// capsule running outside the only thing that bounds what it may spawn. A capsule that
551 /// declares no spawn capability never registers and never reaches this.
552 ///
553 /// `reason` is the transport or daemon-side failure verbatim. Neither this nor anything
554 /// derived from it carries a credential or an approval: the registration request's tokens
555 /// travel in headers, and the HTTP client never puts a request header into an error.
556 #[error(
557 "failed to register this session with mur-roost at {roost_url}: {reason}; a capsule \
558 declaring capabilities.spawn.allow must be known to the daemon that referees its \
559 spawns, so the launch is refused rather than run unrefereed"
560 )]
561 SpawnRegistrationFailed { roost_url: String, reason: String },
562
563 /// `MURMUR_SPAWNER` was set to something that is not a spawner handle.
564 ///
565 /// `reason` names what about the value could not be read, and nothing derived from it carries
566 /// the value itself: a handle names a parent's address and session, which is not this
567 /// process's to print back out.
568 #[error(
569 "MURMUR_SPAWNER does not carry a readable spawner handle: {reason}; a delegated child \
570 must be able to tell its spawner that it finished, so the launch is refused rather than \
571 run unreportable"
572 )]
573 SpawnerHandleUnreadable { reason: String },
574
575 /// A native tool's `bin/<name>` payload was built for another operating system or CPU
576 /// architecture than this host.
577 ///
578 /// Raised before any byte of any native binary in the same staging batch reaches the session
579 /// workdir: an image this host cannot exec would otherwise be written, made executable, and
580 /// then die on a raw `Exec format error` mid-run, at a point where nothing left says which
581 /// artifact was wrong.
582 ///
583 /// Both platforms are `current_platform()`-shaped strings, or the bare `darwin` a fat Mach-O
584 /// identifies as.
585 #[error(
586 "native tool '{name}' cannot run on this host: its binary is built for \
587 {binary_platform}, this host is {host_platform}"
588 )]
589 NativeBinaryPlatformMismatch {
590 name: String,
591 binary_platform: String,
592 host_platform: String,
593 },
594
595 /// The artifact a `transport: process` manifest names under `inference.driver` does not
596 /// export [`crate::process_driver::PROCESS_DRIVER_IFACE`]. `found` holds every
597 /// `murmur:driver/process@*` instance it does export, so a driver built against another
598 /// version is told which one it has; it is empty when the artifact exports none.
599 #[error(
600 "artifact '{name}@{version}' is the transport: process driver but does not export \
601 {expected}{}",
602 process_interface_found_tail(.found)
603 )]
604 ProcessDriverInterfaceMissing {
605 name: String,
606 version: String,
607 expected: String,
608 found: Vec<String>,
609 },
610
611 /// The artifact a `transport: http` manifest names under `inference.driver` exports a
612 /// `murmur:driver/process@*` instance, so it drives a CLI rather than an HTTP API.
613 #[error(
614 "artifact '{name}@{version}' is the transport: http inference driver but exports {}, the \
615 process driver interface; name it under transport: process, or name an http driver",
616 .exported.join(", ")
617 )]
618 HttpDriverExportsProcessInterface {
619 name: String,
620 version: String,
621 exported: Vec<String>,
622 },
623
624 /// A process driver's `describe().required-env` names variables the manifest's
625 /// `capabilities.env.allow` does not declare. `missing` is in the driver's order, each name
626 /// once.
627 #[error(
628 "process driver '{name}@{version}' requires {} in its environment, which \
629 capabilities.env.allow does not declare",
630 .missing.join(", ")
631 )]
632 ProcessDriverRequiredEnvNotAllowed {
633 name: String,
634 version: String,
635 missing: Vec<String>,
636 },
637
638 /// A spend ceiling is in effect against a process driver whose `describe().reports-usage` is
639 /// `false`, so no turn it drives could ever be counted against that ceiling.
640 ///
641 /// `ceilings` names each ceiling in effect, in the order the operator would read them: the
642 /// manifest's `inference.max_session_tokens` first, then the effective config's
643 /// `spend.machine_tokens_per_day`.
644 #[error(
645 "process driver '{name}@{version}' reports no usage — its describe() sets \
646 reports-usage: false — so {} could never be enforced",
647 .ceilings.join(" and ")
648 )]
649 ProcessDriverReportsNoUsage {
650 name: String,
651 version: String,
652 ceilings: Vec<String>,
653 },
654
655 /// A process driver could not be instantiated with no grants, trapped in `describe`, or
656 /// described itself with an unusable `required-env` name. `message` is wasmtime's error, which
657 /// names an unsatisfied import, or names the unusable variable.
658 #[error("process driver '{name}@{version}' could not be loaded with no grants: {message}")]
659 ProcessDriverLoad {
660 name: String,
661 version: String,
662 message: String,
663 },
664
665 /// The executable a process capsule's harness runs as could not be resolved at staging.
666 /// `binary_source` names where the name came from, so the operator knows which field to
667 /// change: `inference.command`, or the process driver's own `describe()`.
668 #[error("harness binary '{binary}', named by {binary_source}, was not found on this host")]
669 HarnessBinaryNotFound {
670 binary: String,
671 binary_source: String,
672 },
673
674 /// The harness ended a turn in failure. Renders as [`E_RUN_033`]. `kind` is the driver's
675 /// `failure-kind`, spelled as it is in the WIT, or `max-turns` when this runtime stopped the
676 /// harness at the attempt's turn budget; `origin` distinguishes those two (`harness` /
677 /// `runtime`) and is what the trace's `harness_failed.source` records.
678 #[error("the harness turn failed ({kind}): {message}")]
679 HarnessTurnFailed {
680 kind: String,
681 message: String,
682 origin: String,
683 },
684
685 /// A turn launched `mode: resume` failed without the harness ever reporting the session it
686 /// was handed: the conversation this context names is gone from the harness's own store.
687 /// Renders as [`E_RUN_036`].
688 ///
689 /// `path` is the map file that still holds the id, or `None` on a launch that kept none. The
690 /// entry is deliberately left as it is — the next task in this context fails the same way
691 /// rather than quietly starting a new conversation, until somebody asks for it to be dropped
692 /// with `mur run --forget-session` or the door's forget header.
693 #[error(
694 "the harness '{harness}' could not continue session {session_id}, which context \
695 '{context_id}' names: {detail}{}",
696 harness_session_file_tail(.path)
697 )]
698 HarnessSessionGone {
699 context_id: String,
700 session_id: String,
701 harness: String,
702 path: Option<PathBuf>,
703 detail: String,
704 },
705
706 /// A forget was asked of a capsule that keeps no harness session: only
707 /// `inference.transport: process` has one, because only there does a harness own the
708 /// conversation. Raised at staging, so the launch leaves nothing behind.
709 ///
710 /// `transport` is the transport the capsule declared, or `none` for a capsule with no
711 /// `inference:` block at all.
712 #[error(
713 "--forget-session forgets the harness session a context names, and only \
714 inference.transport: process has one; this capsule's transport is '{transport}'"
715 )]
716 ForgetSessionUnsupportedTransport { transport: String },
717
718 /// A call into the process driver trapped, ran out of time, or refused. Renders as
719 /// [`E_RUN_034`]. `call` is the WIT function name; `message` is the driver's own refusal or
720 /// the failure's description.
721 #[error("process driver '{name}@{version}' failed in {call}: {message}")]
722 ProcessDriverCallFailed {
723 name: String,
724 version: String,
725 call: String,
726 message: String,
727 },
728
729 /// The harness went quiet: no stdout line and no bridge request for the whole inactivity
730 /// window, so the runtime killed it rather than waiting on a process that had stopped working.
731 /// Renders as [`E_RUN_035`].
732 #[error(
733 "the harness produced neither a line of output nor a tool call for {seconds}s and was \
734 killed"
735 )]
736 ProcessHarnessInactive { seconds: u64 },
737
738 #[error("runtime failure: {0}")]
739 Runtime(String),
740}
741
742/// The tail of [`RuntimeError::ProcessDriverInterfaceMissing`]'s message: which process
743/// interface versions the driver does export, or that it exports none.
744fn process_interface_found_tail(found: &[String]) -> String {
745 if found.is_empty() {
746 "; a process driver must be built against the process-driver world".to_string()
747 } else {
748 format!(
749 "; it exports {}, built against another version — rebuild it",
750 found.join(", ")
751 )
752 }
753}
754
755/// The tail of [`RuntimeError::HarnessSessionGone`]'s message: where the session id that could
756/// not be continued is recorded, so the operator can read it, or that this launch recorded it
757/// nowhere.
758fn harness_session_file_tail(path: &Option<PathBuf>) -> String {
759 match path {
760 Some(path) => format!(" (that id is recorded in {})", path.display()),
761 None => " (this launch recorded that id in memory only)".to_string(),
762 }
763}
764
765impl RuntimeError {
766 #[must_use]
767 pub fn artifact_not_found(name: impl Into<String>, version: impl Into<String>) -> Self {
768 Self::ArtifactNotFound {
769 name: name.into(),
770 version: version.into(),
771 }
772 }
773
774 #[must_use]
775 pub fn artifact_integrity_failed(name: impl Into<String>, version: impl Into<String>) -> Self {
776 Self::ArtifactIntegrityFailed {
777 name: name.into(),
778 version: version.into(),
779 }
780 }
781
782 #[must_use]
783 pub fn wasi(path: PathBuf, message: impl Into<String>) -> Self {
784 Self::WasiInit {
785 path: path.display().to_string(),
786 message: message.into(),
787 }
788 }
789}