capsule_runtime/errors.rs
1use std::path::PathBuf;
2
3use murmur_artifact::{level_has_phrase, ContractDirection, UnservedInterface};
4use thiserror::Error;
5
6/// Where the reference docs state the cgroup delegation a subprocess-capable capsule needs on
7/// Linux, cited by [`RuntimeError::CgroupDelegationUnavailable`].
8const RESOURCE_LIMITS_PLATFORM_URL: &str =
9 murmur_artifact::docs_reference_url!("resource-limits/#platform-behavior");
10
11/// One `capabilities.shell.allow` entry that resolved to a script whose interpreter's package
12/// tree nothing declared could reach inside a `sealed` composed root.
13///
14/// Defined here rather than in [`crate::reachability`] — which is a `pub(crate)` module — because
15/// it is a field of a public [`RuntimeError`] variant and so has to be nameable by anything that
16/// matches on one (`murmur-cli`'s `CliError` conversion, above all).
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct UnreachableEntrypoint {
19 /// The `capabilities.shell.allow` entry, verbatim as the manifest wrote it.
20 pub binary: String,
21 /// Where that entry resolves on this host — the first `PATH` match, i.e. the one `execvp`
22 /// would run.
23 pub resolved_path: PathBuf,
24 /// The bare interpreter name read out of the file's `#!` line, with one level of
25 /// `env NAME` indirection resolved. This is the name a covering
26 /// `interpreter_runtime`/`staged_runtime` grant would have to declare.
27 pub interpreter: String,
28}
29
30impl std::fmt::Display for UnreachableEntrypoint {
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 write!(
33 f,
34 "'{}' ({}, a script run by '{}')",
35 self.binary,
36 self.resolved_path.display(),
37 self.interpreter
38 )
39 }
40}
41
42// The four codes below live beside the variants that raise them because two callers name them:
43// murmur-cli renders them on the terminal, and the A2A terminal status a process attempt writes
44// carries the same code. Defining them twice would let a client and an operator be shown
45// different codes for one failure.
46
47/// The code [`RuntimeError::HarnessTurnFailed`] renders under.
48pub const E_RUN_033: &str = "E-RUN-033";
49/// The code [`RuntimeError::ProcessDriverCallFailed`] renders under.
50pub const E_RUN_034: &str = "E-RUN-034";
51/// The code [`RuntimeError::ProcessHarnessInactive`] renders under.
52pub const E_RUN_035: &str = "E-RUN-035";
53/// The code [`RuntimeError::HarnessSessionGone`] renders under.
54pub const E_RUN_036: &str = "E-RUN-036";
55
56#[derive(Debug, Error)]
57pub enum RuntimeError {
58 #[error("artifact {name}@{version} is not in the store")]
59 ArtifactNotFound { name: String, version: String },
60
61 #[error("artifact integrity check failed for {name}@{version}")]
62 ArtifactIntegrityFailed { name: String, version: String },
63
64 #[error("lockfile missing artifact entry for '{name}'")]
65 LockMissingEntry { name: String },
66
67 #[error(
68 "lockfile version mismatch for '{name}': manifest requested {requested}, lock pinned {pinned}"
69 )]
70 LockVersionMismatch {
71 name: String,
72 requested: String,
73 pinned: String,
74 },
75
76 /// A `murmur.lock` pin written by `manage.pull()` is declared in a role only an
77 /// operator-declared pin may carry. `declared_as` is the offending manifest text:
78 /// `runtime: hook`, `runtime: driver`, `gateway:` or `inference.system_prompt_artifact`.
79 #[error(
80 "murmur.lock pins '{name}@{version}' from a runtime pull by session {session}, and \
81 murmur.yaml declares it with {declared_as}, which only an operator-declared pin may carry"
82 )]
83 RuntimeOriginNotDeclarable {
84 name: String,
85 version: String,
86 session: String,
87 declared_as: &'static str,
88 },
89
90 #[error("failed to read artifact archive for {name}@{version}: {message}")]
91 ArtifactArchive {
92 name: String,
93 version: String,
94 message: String,
95 },
96
97 #[error("failed to compile component for {name}@{version}: {message}")]
98 ToolComponentCompile {
99 name: String,
100 version: String,
101 message: String,
102 },
103
104 #[error("failed to compile capsule component: {0}")]
105 CapsuleCompile(String),
106
107 #[error("failed to create workdir at {path}: {source}")]
108 CreateWorkdir {
109 path: String,
110 #[source]
111 source: std::io::Error,
112 },
113
114 /// A runtime write, append, read, create or removal inside the capsule's workdir met a
115 /// symbolic link, a hard-linked or non-regular file, or an invalid path component, and did
116 /// nothing. `mur` renders it as `E-RUN-050`.
117 #[error("{refusal}")]
118 WorkdirPathRefused {
119 refusal: crate::workdir_fs::WorkdirRefusal,
120 },
121
122 /// `~/.murmur/sessions/<session-id>/`, the session's runtime directory, could not be created
123 /// or locked. `mur` renders it as `E-IO-003`.
124 #[error("failed to create the runtime directory: {reason}")]
125 RuntimeDirUnavailable { reason: String },
126
127 /// `~/.murmur/records/<workdir root>/<session-id>/`, the session's records directory, could not
128 /// be created. `mur` renders it as `E-IO-003`.
129 #[error("failed to create the records directory: {reason}")]
130 RecordsDirUnavailable { reason: String },
131
132 /// `HOME` cannot hold `~/.murmur`, so there is nowhere to put a session's runtime and records
133 /// directories. Checked before anything is created; `mur` renders it as `E-IO-004`.
134 #[error(transparent)]
135 HomeUnusable(#[from] crate::murmur_home::HomeUnusable),
136
137 /// A delegated child's `--workdir` is not the directory its parent created: its
138 /// `(st_dev, st_ino)` differs from `--workdir-identity`. Nothing was written. `mur` renders it
139 /// as `E-RUN-051`.
140 #[error(
141 "the workdir {workdir} is not the directory the delegating capsule created (expected \
142 {expected}, found {found})"
143 )]
144 WorkdirIdentityMismatch {
145 workdir: String,
146 expected: String,
147 found: String,
148 },
149
150 /// A tool name that is not one normal path component, so its staged manifest would land
151 /// outside the runtime directory's `tools/`.
152 #[error(
153 "tool name '{}' is not a bare name, so its manifest would be written outside the \
154 workdir's tools/ directory",
155 name.escape_debug()
156 )]
157 ToolNameNotAComponent { name: String },
158
159 #[error("failed to write tool manifest at {path}: {source}")]
160 WriteToolManifest {
161 path: String,
162 #[source]
163 source: std::io::Error,
164 },
165
166 #[error("failed to read inference.system_prompt_file at {path}: {source}")]
167 SystemPromptFileRead {
168 path: String,
169 #[source]
170 source: std::io::Error,
171 },
172
173 #[error("failed to read inference.compaction.system_prompt_file at {path}: {source}")]
174 CompactionSystemPromptFileRead {
175 path: String,
176 #[source]
177 source: std::io::Error,
178 },
179
180 #[error(
181 "inference.system_prompt_artifact '{name}': skill.md not found or unreadable; \
182 ensure the skill is declared in artifacts: and the capsule was staged"
183 )]
184 SystemPromptArtifactRead {
185 name: String,
186 #[source]
187 source: std::io::Error,
188 },
189
190 #[error("skill source path not found: {path}")]
191 SkillSourceNotFound { path: String },
192
193 #[error("skill source directory '{path}' contains no skill.md")]
194 SkillSourceMissingSkillMd { path: String },
195
196 #[error("failed to read skill source at {path}: {source}")]
197 SkillSourceRead {
198 path: String,
199 #[source]
200 source: std::io::Error,
201 },
202
203 /// A local skill source directory holds what `mur build` refuses to pack: a symlink, two
204 /// spellings of `skill.md`, or a root `murmur-build.json`.
205 #[error("skill source refused: {message}")]
206 SkillSourceRefused { message: String },
207
208 #[error("invalid network capability allow entry '{entry}': {message}")]
209 InvalidNetworkAllowEntry { entry: String, message: String },
210
211 #[error("invalid filesystem capability scope '{scope}': {message}")]
212 InvalidFilesystemScope { scope: String, message: String },
213
214 /// A `capabilities.filesystem.read_only` entry is not a usable workdir subpath: it is
215 /// absolute, or it escapes the workdir via `..`. The exact parallel of
216 /// [`Self::InvalidFilesystemScope`], raised from
217 /// [`crate::protected_paths::ProtectedPaths::from_declared`] at staging — before any registry
218 /// pull, workdir creation or component instantiation — so no call is ever checked against a
219 /// rule the runtime could not build.
220 #[error("invalid read-only path '{path}': {message}")]
221 InvalidReadOnlyPath { path: String, message: String },
222
223 /// An artifact entry's `config:` block cannot be delivered as `MURMUR_ARTIFACT_CONFIG`: it is
224 /// not a string-keyed mapping, does not serialize to JSON, or exceeds
225 /// [`crate::artifact_config::MAX_ARTIFACT_CONFIG_BYTES`]. Raised at staging (and from `mur run
226 /// --explain-scope`), before any registry pull, workdir creation or component instantiation,
227 /// so a malformed channel is refused once at launch rather than one tool call at a time.
228 #[error("invalid config for artifact '{artifact}': {message}")]
229 InvalidArtifactConfig { artifact: String, message: String },
230
231 /// A `capabilities.state.store` name is not a single usable directory segment under
232 /// `~/.murmur/state/`. Raised at staging (and from `mur run --explain-scope`), before any
233 /// registry pull, workdir creation or component instantiation, so a bad name never gets as far
234 /// as creating something on disk.
235 #[error("invalid state store name '{store}': {message}")]
236 InvalidStateStore { store: String, message: String },
237
238 /// A `context.record_store` value, or a `mur run --context` value, is not a single usable
239 /// directory segment of a conversation record path. Raised at staging, before any registry
240 /// pull, workdir creation or component instantiation, so a bad name never gets as far as
241 /// creating something on disk.
242 ///
243 /// Distinct from [`Self::InvalidStateStore`] because the remedies point at different keys, and
244 /// because a record is on by default while a state store is declared: an operator seeing this
245 /// may not have written a `context:` block at all, only a `--context` flag.
246 #[error("invalid {field} '{value}': {message}")]
247 InvalidConversationRecord {
248 field: String,
249 value: String,
250 message: String,
251 },
252
253 /// `mur run --resume` named a session whose context kept no conversation record on disk, so
254 /// there is nothing to continue. Raised at staging, before this launch's workdir exists: a
255 /// resume that silently started fresh would be indistinguishable from one that worked.
256 #[error(
257 "cannot resume session {session}: context '{context_id}' has no conversation record ({reason})"
258 )]
259 ResumeRecordMissing {
260 session: String,
261 context_id: String,
262 reason: String,
263 },
264
265 /// `mur run --resume-mode compact` on a capsule with no hook bound to `on-compaction`. There
266 /// is nothing to produce the summary, and falling back to `full` would silently give the
267 /// operator the mode they did not ask for.
268 #[error(
269 "--resume-mode compact needs a hook bound to on-compaction; this capsule declares none"
270 )]
271 ResumeCompactionHookMissing,
272
273 /// `mur run --resume-mode compact` on a `transport: process` capsule. The harness holds the
274 /// conversation and the runtime has no message list to hand a compaction hook, so there is
275 /// nothing `compact` could summarize.
276 #[error(
277 "--resume-mode compact is not available under inference.transport: process; the harness \
278 holds this conversation and murmur has no history to compact"
279 )]
280 ResumeCompactUnsupportedTransport,
281
282 /// The state store directory a validated name resolves to could not be resolved or created.
283 /// Distinct from [`Self::InvalidStateStore`]: the declaration is well-formed and the failure is
284 /// the host's — an unset `HOME`, or a path that cannot be made a `0700` directory.
285 #[error("state store '{store}' is unavailable at {path}: {message}")]
286 StateStoreUnavailable {
287 store: String,
288 path: String,
289 message: String,
290 },
291
292 /// The declared containment floor is stronger than what this host's kernel can back.
293 /// Raised in the stage/launch seam, before any registry pull, component compile or workdir
294 /// creation — `achieved` always comes from a live host probe, never from the manifest.
295 #[error(
296 "declared containment class '{declared}' is not achievable on this host (achieved: '{achieved}'): {reason}{}",
297 crate::provider_tool::required_by_lines(.required_by)
298 )]
299 ContainmentFloorUnmet {
300 declared: murmur_artifact::ContainmentClass,
301 achieved: murmur_artifact::ContainmentClass,
302 reason: String,
303 /// Why the floor is unmet, which decides what the operator is told to change.
304 cause: crate::containment::ShortfallCause,
305 /// The declared provider tools that need the floor, each named on a `required by:` line.
306 /// Filled only for a declared `sealed` floor; empty otherwise, and then the message has
307 /// no `required by:` line.
308 required_by: Vec<crate::provider_tool::ProviderToolUse>,
309 },
310 /// A capsule declares an `exports.files.root` that, once resolved against the session's
311 /// accessible workdir, lands somewhere else — the root already exists as a symlink pointing
312 /// out of it.
313 ///
314 /// Refused at launch rather than per request: an export root outside the workdir is a
315 /// misconfiguration the operator can fix, and discovering it one served file at a time would
316 /// mean the first file had already left.
317 #[error(
318 "{field} '{declared}' resolves to '{resolved}', which is outside the capsule \
319 workdir '{workdir}'"
320 )]
321 ExportRootOutsideWorkdir {
322 /// The manifest key that declared it — `exports.files.root` or
323 /// `exports.peer_files.root`. Carried rather than hard-coded because the two roots are
324 /// separate authorisers and an operator fixing one must be told which.
325 field: String,
326 declared: String,
327 resolved: String,
328 workdir: String,
329 },
330
331 /// A capsule declares an artifact whose name collides with a tool the runtime provides
332 /// itself.
333 ///
334 /// Every name in [`crate::runtime::RESERVED_TOOL_NAMES`] is answered inside
335 /// `dispatch_agent_tool_unfenced` before any allowlist check is reached, and their synthetic
336 /// manifests are written after the staging loop. An artifact installed under one of them would
337 /// have its manifest overwritten and every call to it answered by the runtime, so the collision
338 /// is refused rather than resolved into a tool the capsule can never reach.
339 #[error(
340 "artifact '{name}' has the tool name '{}', which collides with a tool the runtime \
341 provides itself; the reserved names are {}",
342 murmur_artifact::tool_name_of(name),
343 crate::runtime::RESERVED_TOOL_NAMES.join(", ")
344 )]
345 ReservedToolName {
346 /// The declared artifact name whose tool name collides.
347 name: String,
348 },
349
350 /// Two declared artifacts share a tool name, the bare part a session offers the model and
351 /// stages under `<runtime dir>/tools/`.
352 #[error(
353 "artifacts '{first}' and '{second}' share the tool name '{tool}'; a capsule offers each \
354 artifact to the model by its tool name, so no two may share one"
355 )]
356 DuplicateToolName {
357 first: String,
358 second: String,
359 tool: String,
360 },
361
362 /// A capsule's `capabilities.env.allow` names variables the credential backstop strips from
363 /// every guest environment.
364 ///
365 /// The strip in `build_declared_env` is not negotiable from a manifest, so the entries
366 /// can only ever deliver nothing; the declaration is refused rather than left silently inert.
367 #[error(
368 "capabilities.env.allow names {}; these entries are removed from every guest environment \
369 before any guest is built, so the grant would deliver nothing",
370 crate::runtime::describe_stripped_env_allow_entries(entries)
371 )]
372 EnvAllowStrippedByBackstop {
373 /// Every distinct stripped entry, in first-declaration order.
374 entries: Vec<crate::runtime::StrippedEnvAllowEntry>,
375 },
376
377 /// A `host_paths` entry in `~/.murmur/config.yaml` cannot be honoured for this session: at
378 /// launch (missing, not a directory, a system path, read-write over murmur's own home, inside
379 /// the work folder, the same directory twice) or at staging, read-write over a folder the
380 /// session may run something from. A configured host path is never silently dropped.
381 #[error("{0}")]
382 HostPathRefused(crate::host_paths::HostPathRefusal),
383
384 /// A synthetic tool manifest was written under a name absent from
385 /// [`crate::runtime::RESERVED_TOOL_NAMES`].
386 ///
387 /// The inverse of [`Self::ReservedToolName`], and the reason a further runtime-provided tool
388 /// cannot be added without reserving its name: an unreserved name is claimable by an artifact,
389 /// which would then be installed and silently shadowed at dispatch.
390 #[error(
391 "'{name}' is written as a runtime-provided tool but is not one of {}; add it to \
392 RESERVED_TOOL_NAMES so no artifact can claim the name",
393 crate::runtime::RESERVED_TOOL_NAMES.join(", ")
394 )]
395 RuntimeProvidedToolNotReserved {
396 /// The name the synthetic write was attempted under.
397 name: String,
398 },
399
400 /// A capsule declares `exports.peer_files` together with `lifecycle.after_task: sleep`, and
401 /// either declared no `max_ttl` or declared one above the persistent ceiling.
402 ///
403 /// An ephemeral capsule needs no ceiling: teardown destroys the minting key, so every
404 /// outstanding handle stops verifying at once and the declared lifetime can never outlive the
405 /// process. `sleep` withdraws that bound deliberately, which makes the declared lifetime the
406 /// only one there is — so it has to be declared, and it has to be short.
407 #[error(
408 "exports.peer_files with lifecycle.after_task: sleep requires exports.peer_files.max_ttl \
409 to be declared and at most {ceiling_secs}s{}; a handle's lifetime is not a durability \
410 mechanism",
411 match declared_secs {
412 Some(secs) => format!(" (declared {secs}s)"),
413 None => String::new(),
414 }
415 )]
416 PersistentCapsuleNeedsHandleTtl {
417 /// The declared `max_ttl` in seconds, or `None` when the manifest declared none.
418 declared_secs: Option<u64>,
419 ceiling_secs: u64,
420 },
421
422 /// A capsule declares `capabilities.shell.staged_runtime` without an effective `sealed`
423 /// containment floor.
424 ///
425 /// Deliberately distinct from [`Self::ContainmentFloorUnmet`]: that one compares the declared
426 /// floor against what the *host* can back, and its remedy is to lower the floor or move hosts.
427 /// This one is decided against the declared floor alone and never looks at the host at all, so
428 /// it fires identically on a machine that could deliver `sealed` — a staged runtime has no
429 /// composed root to be staged into unless the capsule asked for one, and quietly launching it
430 /// without the mount would leave the interpreter simply absent. Its remedy is to raise the
431 /// declared floor (or drop the grant), which is the opposite advice.
432 ///
433 /// `binaries` names every offending `staged_runtime` binary so the operator does not have to
434 /// re-run to find the second one.
435 #[error(
436 "capabilities.shell.staged_runtime is declared for {} but the effective containment floor \
437 is '{declared}' — staging a runtime tree requires the 'sealed' floor, because there is no \
438 composed root to bind-mount it into below that",
439 .binaries.join(", ")
440 )]
441 StagedRuntimeRequiresSealed {
442 binaries: Vec<String>,
443 declared: murmur_artifact::ContainmentClass,
444 },
445
446 /// A capsule declares a tool whose artifact declares `provider_tool:`, under an effective
447 /// containment floor below `sealed`.
448 ///
449 /// Decided against the declared floor alone, like [`Self::StagedRuntimeRequiresSealed`]: the
450 /// floor is never raised for the tool, so a weaker declaration is refused rather than
451 /// upgraded. `tools` names every such tool and its kind; `host_can_back` is what this host
452 /// can back, for the hint.
453 #[error(
454 "{} declare{} a provider tool, which runs only under the 'sealed' containment floor, and \
455 the effective containment floor is '{declared}'",
456 .tools.iter().map(ToString::to_string).collect::<Vec<_>>().join(", "),
457 if .tools.len() == 1 { "s" } else { "" }
458 )]
459 ProviderToolRequiresSealed {
460 /// Each such tool, in staging order.
461 tools: Vec<crate::provider_tool::ProviderToolUse>,
462 declared: murmur_artifact::ContainmentClass,
463 host_can_back: murmur_artifact::ContainmentClass,
464 },
465
466 /// A capsule declares a computer tool and a `transport: http` driver choice runs a model
467 /// whose driver marks it `vision: false` in its `models:` table. Names the first such tool
468 /// and the first such choice, primary first.
469 #[error(
470 "tool '{tool}' is a {kind} (provider_tool: {kind}) but model '{model}' of driver choice \
471 '{choice}' has no vision: driver {driver} declares vision: false for it"
472 )]
473 ProviderToolNeedsVision {
474 tool: String,
475 kind: murmur_artifact::ProviderToolKind,
476 model: String,
477 choice: String,
478 /// The driver artifact as `name@version`.
479 driver: String,
480 },
481
482 /// An installed artifact's `provider_tool:` block does not parse, or sits on an artifact that
483 /// is not a tool. `artifact` is `name@version`; `reason` names the dotted field.
484 #[error("artifact '{artifact}' ships a malformed provider_tool: block: {reason}")]
485 ProviderToolMalformed { artifact: String, reason: String },
486
487 /// A `sealed` capsule allowlists a script whose interpreter's package tree nothing declared
488 /// could make reachable inside the composed root.
489 ///
490 /// Deliberately distinct from [`Self::StagedRuntimeRequiresSealed`], which it sits next to in
491 /// `stage_session`: that one fires when a grant *was* declared at too low a floor, this one
492 /// fires when no grant was declared at all and the floor is already `sealed`. Their remedies
493 /// point in opposite directions — raise the floor there, add a grant here.
494 ///
495 /// `entries` names every offending allowlist entry so an operator fixing this does not have to
496 /// re-run to discover the second script, following the same precedent.
497 #[error(
498 "capabilities.shell.allow grants {} under the 'sealed' containment floor, but nothing \
499 declared makes the interpreted entrypoint's own package tree reachable inside the \
500 composed root — the script's ELF/DT_NEEDED closure is empty, so staging it stages \
501 nothing its interpreter imports, and the capsule would fail with a module-not-found \
502 error partway into a run rather than here. Declare \
503 capabilities.shell.interpreter_runtime or capabilities.shell.staged_runtime naming the \
504 interpreter (measure the real directories with \
505 `strace -f -e trace=openat,getdents64 <the command>`), or use a copy of the interpreter \
506 and its packages that already lives under a fixed sealed runtime path",
507 .entries
508 .iter()
509 .map(UnreachableEntrypoint::to_string)
510 .collect::<Vec<_>>()
511 .join("; ")
512 )]
513 ShellBinaryPackageUnreachable { entries: Vec<UnreachableEntrypoint> },
514
515 /// A `sealed` session's composed root could not be built in the forked child, *after* the
516 /// pre-launch probe reported the mechanism available.
517 ///
518 /// Deliberately distinct from [`Self::ContainmentFloorUnmet`]: that one is a refusal decided
519 /// before anything is staged, on a host that never claimed to offer `sealed`. This one is a
520 /// host that claimed it and then failed to deliver — a race, a mount table that changed under
521 /// the runtime, or an edge case the cheap probe cannot catch. Conflating the two would tell an
522 /// operator to lower their declared floor when the real answer is that something moved.
523 ///
524 /// `detail` is the message the child wrote to the `pre_exec` diagnostic pipe, which names the
525 /// exact step (`unshare`, a specific bind mount, `pivot_root`) and its errno.
526 #[error(
527 "the sealed containment class was achievable at launch but its composed root could not be \
528 built for this subprocess: {detail}"
529 )]
530 SealedRootConstructionFailed { detail: String },
531
532 /// This host cannot give a capsule's subprocess tree its own network namespace, so the
533 /// egress boundary that mediates `capabilities.network.allow` cannot be built.
534 ///
535 /// Raised before any registry pull, component compile or workdir creation, and raised for
536 /// **every** Linux capsule that can spawn a subprocess — including one whose
537 /// `capabilities.network.allow` is empty. That breadth is the point: with the namespace
538 /// missing, an empty allowlist would mean *unrestricted* egress rather than none, because the
539 /// seccomp `connect`/`sendto` interception that used to back it was deleted as unsound rather
540 /// than kept as a fallback. There is deliberately no path that continues at reduced
541 /// enforcement.
542 ///
543 /// Deliberately distinct from [`Self::ContainmentFloorUnmet`], which this would otherwise
544 /// look like: that one compares a *declared* containment class against what the host can
545 /// back, and its remedy is to lower the declared floor. A network namespace is not part of
546 /// the containment ladder — every class needs it equally — so lowering the floor fixes
547 /// nothing here, and offering that advice would be actively misleading.
548 #[error(
549 "this host cannot give the capsule's subprocess tree its own network namespace, so \
550 capabilities.network.allow cannot be enforced for it: {reason}{}",
551 crate::provider_tool::required_by_lines(.required_by)
552 )]
553 EgressNamespaceUnavailable {
554 blocker: crate::network_namespace::EgressNamespaceBlocker,
555 reason: String,
556 /// The declared provider tools, each named on a `required by:` line; empty without one,
557 /// and then the message has no `required by:` line.
558 required_by: Vec<crate::provider_tool::ProviderToolUse>,
559 },
560
561 #[error(
562 "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)"
563 )]
564 CapsuleExportMissing,
565
566 #[error(
567 "tool component '{name}' missing export murmur:tool/run@0.2.0#run; rebuild the artifact against the versioned WIT (run `mur install` for a default artifact, or rebuild from source otherwise)"
568 )]
569 ToolExportMissing { name: String },
570
571 #[error("capsule execution trapped: {0}")]
572 CapsuleTrap(String),
573
574 #[error(
575 "capsule execution exceeded its {seconds}s deadline and was interrupted; \
576 raise capabilities.limits.deadline_seconds in murmur.yaml if the capsule \
577 legitimately needs longer"
578 )]
579 CapsuleDeadlineExceeded { seconds: u64 },
580
581 #[error("capsule execution exceeded its configured resource limits: {message}")]
582 CapsuleResourceLimit { message: String },
583
584 #[error(
585 "shell subprocess '{binary}' was killed for exceeding \
586 capabilities.resources.{limit}: {detail}"
587 )]
588 ShellResourceLimitExceeded {
589 binary: String,
590 limit: String,
591 detail: String,
592 },
593
594 #[error(
595 "tool '{tool}' returned data_path '{path}', which does not resolve to a readable file \
596 beneath the work folder ({code}): {detail}"
597 )]
598 ToolDataPathRefused {
599 tool: String,
600 path: String,
601 code: String,
602 detail: String,
603 },
604
605 #[error(
606 "this capsule can spawn subprocesses but no cgroup v2 scope could be created to \
607 bound them ({reason}); on Linux the runtime refuses to launch rather than run a \
608 subprocess tree with no aggregate memory/pids/cpu ceiling — see the systemd user \
609 delegation requirement at {}",
610 RESOURCE_LIMITS_PLATFORM_URL
611 )]
612 CgroupDelegationUnavailable { reason: String },
613
614 #[error(
615 "work folder, $HOME and $TMPDIR together grew to {observed_bytes} bytes, past the \
616 {max_bytes} byte ceiling (capabilities.resources.workdir_max_bytes)"
617 )]
618 WorkdirSizeExceeded { max_bytes: u64, observed_bytes: u64 },
619
620 #[error("failed to initialize WASI for {path}: {message}")]
621 WasiInit { path: String, message: String },
622
623 #[error("inference driver is not configured; add inference.driver.artifact to murmur.yaml")]
624 DriverNotConfigured,
625
626 #[error("inference driver '{0}' is not installed in the local tool registry")]
627 DriverNotInstalled(String),
628
629 /// An artifact whose operator entry declares `gateway:` has a bundled manifest with no usable
630 /// `upstream_auth:` block, so the runtime cannot tell how its upstream expects the key.
631 /// Refused at staging, before any component runs, whether or not `gateway.api_key` is set.
632 /// `reason` is `None` when the block is absent and says what is wrong when it is malformed.
633 #[error(
634 "artifact '{name}@{version}' declares no usable upstream_auth: block{}",
635 .reason.as_ref().map(|reason| format!(" ({reason})")).unwrap_or_default()
636 )]
637 GatewayArtifactDeclaresNoUpstreamAuth {
638 name: String,
639 version: String,
640 reason: Option<String>,
641 },
642
643 /// A driver choice under `transport: http` has no output cap, or its driver ships a malformed
644 /// `models:` table. Refused at staging, before any request leaves.
645 #[error(transparent)]
646 ModelLimits(#[from] crate::model_limits::ModelLimitsError),
647
648 /// A staged or pulled artifact's own packed `murmur.yaml` declares `implementation: native`
649 /// or `execution: native`. Murmur runs tools only as WASM components, so the launch stops
650 /// before any session starts and a pull stages nothing. `artifact` is the full
651 /// `<scope>/<name>`; `field` is the manifest key that declared it.
652 #[error(
653 "artifact '{artifact}' declares {field}: native; murmur runs tools only as WASM components"
654 )]
655 NativeTool { artifact: String, field: String },
656
657 /// The launch's `--lifecycle-task-acceptance queue` override turns a manifest that declares
658 /// `lifecycle.queue_depth: 0` under another acceptance into a queue with no room for a waiting
659 /// task. The manifest refuses `queue` at depth 0 at load; this is the one combination load
660 /// cannot see. Refused at staging, before any registry pull or workdir creation.
661 #[error(
662 "the launch sets lifecycle.task_acceptance to queue, and murmur.yaml declares \
663 lifecycle.queue_depth: 0, which leaves the queue no room for a waiting task"
664 )]
665 QueueWithoutRoom,
666
667 /// An [`crate::types::ArtifactRequest`]'s gateway carries neither a credential nor the
668 /// operator's `keyless: true`. The manifest parser refuses that shape first; staging refuses
669 /// it again so no caller can build a gateway that sends keyless by omission.
670 #[error("artifact '{name}' declares gateway.endpoint '{endpoint}' but binds no credential")]
671 GatewayWithoutCredential { name: String, endpoint: String },
672
673 /// `gateway.api_key: ${variable}` on artifact `artifact` names a credential that neither the
674 /// global config's `credentials:` map nor the launching environment holds. Refused at staging,
675 /// before the session directory exists.
676 #[error(
677 "murmur.yaml: gateway.api_key on artifact '{artifact}' references ${{{variable}}}, but \
678 neither credentials.{variable} in {} nor the environment variable {variable} is set",
679 .credentials_file
680 .as_ref()
681 .map(|path| path.display().to_string())
682 .unwrap_or_else(|| "the global config".to_string())
683 )]
684 GatewayCredentialNotFound {
685 artifact: String,
686 variable: String,
687 credentials_file: Option<std::path::PathBuf>,
688 },
689
690 /// `spend.machine_tokens_per_day` is in effect and the shared ledger it is kept in cannot be
691 /// used — an unset `HOME`, a `~/.murmur/spend` that cannot be made a `0700` directory, or a
692 /// daily file that cannot be opened. Raised at staging, before the session directory exists
693 /// and before any provider request.
694 #[error("spend ledger at {path} is unavailable: {message}")]
695 SpendLedgerUnavailable { path: String, message: String },
696
697 /// A capsule declaring `control:` could not mint its control token or write it beside its
698 /// running record. Raised before the door is announced: a capsule whose declared
699 /// controllability no controller could reach does not run. `reason` names the path and the
700 /// failure, never the token.
701 #[error("this capsule declares control: and its control token could not be written: {reason}")]
702 ControlTokenUnwritable { reason: String },
703
704 #[error("agent loop failed: {0}")]
705 AgentLoopFailed(String),
706
707 /// The launch ran, and the task it was run for did not complete. The session itself shut down
708 /// cleanly: everything it records — `out/result.txt`, the trace, a delegated child's
709 /// completion — was written before this was returned.
710 ///
711 /// `exit_status` is the `task_end`/`session_end` vocabulary, never `"ok"`: `"failed"`,
712 /// `"max_turns_reached"`, `"spend_ceiling_reached"` or `"canceled"`. `reason` is never empty;
713 /// for `"failed"` it is the reason the run's `task_failed` trace line carries.
714 #[error("the task ended {exit_status}: {reason}")]
715 TaskDidNotComplete {
716 exit_status: &'static str,
717 reason: String,
718 },
719
720 #[error("internal_port {port} is already bound; choose another port or omit internal_port to use an OS-assigned port")]
721 PortInUse { port: u16 },
722
723 /// A session whose manifest declares `capabilities.spawn.allow` could not register with
724 /// `mur-roost`, so the daemon does not hold the grants it would referee that session's
725 /// delegations against.
726 ///
727 /// Fatal rather than a warning: a capsule that can delegate but is unknown to the daemon is a
728 /// capsule running outside the only thing that bounds what it may spawn. A capsule that
729 /// declares no spawn capability never registers and never reaches this.
730 ///
731 /// `reason` is the transport or daemon-side failure verbatim. Neither this nor anything
732 /// derived from it carries a credential or an approval: the registration request's tokens
733 /// travel in headers, and the HTTP client never puts a request header into an error.
734 #[error(
735 "failed to register this session with mur-roost at {roost_url}: {reason}; a capsule \
736 declaring capabilities.spawn.allow must be known to the daemon that referees its \
737 spawns, so the launch is refused rather than run unrefereed"
738 )]
739 SpawnRegistrationFailed { roost_url: String, reason: String },
740
741 /// `MURMUR_SPAWNER` was set to something that is not a spawner handle.
742 ///
743 /// `reason` names what about the value could not be read, and nothing derived from it carries
744 /// the value itself: a handle names a parent's address and session, which is not this
745 /// process's to print back out.
746 #[error(
747 "MURMUR_SPAWNER does not carry a readable spawner handle: {reason}; a delegated child \
748 must be able to tell its spawner that it finished, so the launch is refused rather than \
749 run unreportable"
750 )]
751 SpawnerHandleUnreadable { reason: String },
752
753 /// `MURMUR_FORMATION_ID` was set to something that is not a formation id.
754 ///
755 /// `reason` names what about the value is wrong — its prefix, its length, or a character
756 /// outside lowercase hex — and never carries the value itself.
757 #[error(
758 "MURMUR_FORMATION_ID does not carry a formation id: {reason}; a session that cannot tell \
759 which formation it belongs to would be grouped with the wrong one, so the launch is \
760 refused"
761 )]
762 FormationIdUnreadable { reason: String },
763
764 /// `MURMUR_FORMATION_PEERS` is set in `mur run`'s own process environment, where nothing
765 /// legitimately puts it, or a list of peers is not one a guest could be handed.
766 ///
767 /// `reason` says where the variable was found, or names the pair at fault by its position and
768 /// what is wrong with it. It never carries the value.
769 #[error("MURMUR_FORMATION_PEERS cannot be used: {reason}, so the launch is refused")]
770 FormationPeersUnreadable { reason: String },
771
772 /// The formation channel `MURMUR_FORMATION_CHANNEL` names could not be read: the descriptor is
773 /// not open, its first line did not arrive in time or is not a member's bundle, or it names
774 /// another formation.
775 ///
776 /// `reason` names the problem and never quotes the line: the line carries the member's
777 /// formation tokens.
778 #[error(
779 "MURMUR_FORMATION_CHANNEL does not carry this member's formation credentials: {reason}; a \
780 member that cannot read what its launcher handed it could neither prove who it is nor \
781 call anyone, so the launch is refused"
782 )]
783 FormationChannelUnreadable { reason: String },
784
785 /// `MURMUR_FORMATION_LIFELINE` does not name a lifeline this session can hold: the value is
786 /// not a pipe's read end, it was read twice, it came without a formation id, or the session
787 /// cannot honour one (a script capsule, or a caller that does not own its process).
788 ///
789 /// `reason` names what is wrong, by descriptor number where there is one.
790 #[error(
791 "MURMUR_FORMATION_LIFELINE does not carry this member's formation lifeline: {reason}; a \
792 member that cannot hear its lifeline would keep running after its formation ends, so \
793 the launch is refused"
794 )]
795 FormationLifelineUnreadable { reason: String },
796
797 /// `MURMUR_SPAWNER_LIFELINE` does not name a lifeline this session can hold: the value is not
798 /// a pipe's read end, it was read twice, or the launch cannot honour one (a caller that does
799 /// not own its process).
800 ///
801 /// `reason` names what is wrong, by descriptor number where there is one.
802 #[error(
803 "MURMUR_SPAWNER_LIFELINE does not carry this session's spawner lifeline: {reason}; a \
804 delegated child that cannot hear its spawner would keep running after the session that \
805 delegated to it has ended, so the launch is refused"
806 )]
807 SpawnerLifelineUnreadable { reason: String },
808
809 /// A session staged with a `mur run --deadline` was launched where the deadline cannot be
810 /// enforced: by a caller that does not own its process, for a script capsule, or on a platform
811 /// without the termination routine.
812 #[error(
813 "this session was staged with a deadline the launch cannot enforce: {reason}; a session \
814 that outlived its deadline would keep running past its launcher's limit, so the launch is \
815 refused"
816 )]
817 SessionDeadlineUnenforceable { reason: String },
818
819 /// The artifact a `transport: process` manifest names under `inference.driver` exports no
820 /// version of the `murmur:driver/process` interface at all, so it is not a process driver.
821 /// One exporting another version is [`RuntimeError::WitLevelMismatch`] instead.
822 #[error(
823 "artifact '{name}@{version}' is the transport: process driver but does not export \
824 {expected}; a process driver must be built against the process-driver world"
825 )]
826 ProcessDriverInterfaceMissing {
827 name: String,
828 version: String,
829 expected: String,
830 },
831
832 /// An artifact a launch stages names a `murmur:*` interface at a version other than the one
833 /// the WIT level this host serves lists for its package. `mismatched` holds every such
834 /// interface, in recorded order, and is never empty.
835 #[error(
836 "artifact '{name}@{version}' does not fit WIT level {level}, the level this mur serves: {}",
837 level_mismatch_clauses(*.level, .mismatched)
838 )]
839 WitLevelMismatch {
840 name: String,
841 version: String,
842 level: u64,
843 mismatched: Vec<UnservedInterface>,
844 },
845
846 /// The artifact a `transport: http` manifest names under `inference.driver` exports a
847 /// `murmur:driver/process@*` instance, so it drives a CLI rather than an HTTP API.
848 #[error(
849 "artifact '{name}@{version}' is the transport: http inference driver but exports {}, the \
850 process driver interface; name it under transport: process, or name an http driver",
851 .exported.join(", ")
852 )]
853 HttpDriverExportsProcessInterface {
854 name: String,
855 version: String,
856 exported: Vec<String>,
857 },
858
859 /// A `runtime: driver` artifact imports a `murmur:stream/*` instance at the served version of
860 /// the package under another interface name than `expected`, so it names an interface that
861 /// does not exist. `imported` holds every such import. A stream or `murmur:text/*` import at
862 /// another version is [`RuntimeError::WitLevelMismatch`] instead.
863 #[error(
864 "artifact '{name}@{version}' imports {}, which this host does not serve; a driver must \
865 import {expected} in its place",
866 .imported.join(", ")
867 )]
868 DriverStreamInterfaceNotServed {
869 name: String,
870 version: String,
871 imported: Vec<String>,
872 expected: String,
873 },
874
875 /// A process driver's `describe().required-env` names variables the manifest's
876 /// `capabilities.env.allow` does not declare. `missing` is in the driver's order, each name
877 /// once.
878 #[error(
879 "process driver '{name}@{version}' requires {} in its environment, which \
880 capabilities.env.allow does not declare",
881 .missing.join(", ")
882 )]
883 ProcessDriverRequiredEnvNotAllowed {
884 name: String,
885 version: String,
886 missing: Vec<String>,
887 },
888
889 /// A spend ceiling is in effect against a process driver whose `describe().reports-usage` is
890 /// `false`, so no turn it drives could ever be counted against that ceiling.
891 ///
892 /// `ceilings` names each ceiling in effect, in the order the operator would read them: the
893 /// manifest's `inference.max_session_tokens` first, then the effective config's
894 /// `spend.machine_tokens_per_day`.
895 #[error(
896 "process driver '{name}@{version}' reports no usage — its describe() sets \
897 reports-usage: false — so {} could never be enforced",
898 .ceilings.join(" and ")
899 )]
900 ProcessDriverReportsNoUsage {
901 name: String,
902 version: String,
903 ceilings: Vec<String>,
904 },
905
906 /// A process driver could not be instantiated with no grants, trapped in `describe`, or
907 /// described itself with an unusable `required-env` name. `message` is wasmtime's error, which
908 /// names an unsatisfied import, or names the unusable variable.
909 #[error("process driver '{name}@{version}' could not be loaded with no grants: {message}")]
910 ProcessDriverLoad {
911 name: String,
912 version: String,
913 message: String,
914 },
915
916 /// The executable a process capsule's harness runs as could not be resolved at staging.
917 /// `binary_source` names where the name came from, so the operator knows which field to
918 /// change: `inference.command`, or the process driver's own `describe()`.
919 #[error("harness binary '{binary}', named by {binary_source}, was not found on this host")]
920 HarnessBinaryNotFound {
921 binary: String,
922 binary_source: String,
923 },
924
925 /// The harness ended a turn in failure. Renders as [`E_RUN_033`]. `kind` is the driver's
926 /// `failure-kind`, spelled as it is in the WIT, or `max-turns` when this runtime stopped the
927 /// harness at the attempt's turn budget; `origin` distinguishes those two (`harness` /
928 /// `runtime`) and is what the trace's `harness_failed.source` records.
929 #[error("the harness turn failed ({kind}): {message}")]
930 HarnessTurnFailed {
931 kind: String,
932 message: String,
933 origin: String,
934 },
935
936 /// A turn launched `mode: resume` failed without the harness ever reporting the session it
937 /// was handed: the conversation this context names is gone from the harness's own store.
938 /// Renders as [`E_RUN_036`].
939 ///
940 /// `path` is the map file that still holds the id, or `None` on a launch that kept none. The
941 /// entry is deliberately left as it is — the next task in this context fails the same way
942 /// rather than quietly starting a new conversation, until somebody asks for it to be dropped
943 /// with `mur run --forget-session` or a message whose metadata carries `forgetSession: true`
944 /// under the door extension's key.
945 #[error(
946 "the harness '{harness}' could not continue session {session_id}, which context \
947 '{context_id}' names: {detail}{}",
948 harness_session_file_tail(.path)
949 )]
950 HarnessSessionGone {
951 context_id: String,
952 session_id: String,
953 harness: String,
954 path: Option<PathBuf>,
955 detail: String,
956 },
957
958 /// A forget was asked of a capsule that keeps no harness session: only
959 /// `inference.transport: process` has one, because only there does a harness own the
960 /// conversation. Raised at staging, so the launch leaves nothing behind.
961 ///
962 /// `transport` is the transport the capsule declared, or `none` for a capsule with no
963 /// `inference:` block at all.
964 #[error(
965 "--forget-session forgets the harness session a context names, and only \
966 inference.transport: process has one; this capsule's transport is '{transport}'"
967 )]
968 ForgetSessionUnsupportedTransport { transport: String },
969
970 /// A call into the process driver trapped, ran out of time, or refused. Renders as
971 /// [`E_RUN_034`]. `call` is the WIT function name; `message` is the driver's own refusal or
972 /// the failure's description.
973 #[error("process driver '{name}@{version}' failed in {call}: {message}")]
974 ProcessDriverCallFailed {
975 name: String,
976 version: String,
977 call: String,
978 message: String,
979 },
980
981 /// The harness went quiet: no stdout line and no bridge request for the whole inactivity
982 /// window, so the runtime killed it rather than waiting on a process that had stopped working.
983 /// Renders as [`E_RUN_035`].
984 #[error(
985 "the harness produced neither a line of output nor a tool call for {seconds}s and was \
986 killed"
987 )]
988 ProcessHarnessInactive { seconds: u64 },
989
990 #[error("runtime failure: {0}")]
991 Runtime(String),
992}
993
994/// The clauses of [`RuntimeError::WitLevelMismatch`]'s message, one per mismatched interface:
995/// `it exports <interface>, and level <N> has <package>@<version>`, joined with `; `.
996fn level_mismatch_clauses(level: u64, mismatched: &[UnservedInterface]) -> String {
997 mismatched
998 .iter()
999 .map(|mismatch| {
1000 let direction = match mismatch.direction {
1001 ContractDirection::Export => "exports",
1002 ContractDirection::Import => "imports",
1003 };
1004 format!(
1005 "it {direction} {}, and {}",
1006 mismatch.interface,
1007 level_has_phrase(level, mismatch)
1008 )
1009 })
1010 .collect::<Vec<_>>()
1011 .join("; ")
1012}
1013
1014/// The tail of [`RuntimeError::HarnessSessionGone`]'s message: where the session id that could
1015/// not be continued is recorded, so the operator can read it, or that this launch recorded it
1016/// nowhere.
1017fn harness_session_file_tail(path: &Option<PathBuf>) -> String {
1018 match path {
1019 Some(path) => format!(" (that id is recorded in {})", path.display()),
1020 None => " (this launch recorded that id in memory only)".to_string(),
1021 }
1022}
1023
1024impl RuntimeError {
1025 #[must_use]
1026 pub fn artifact_not_found(name: impl Into<String>, version: impl Into<String>) -> Self {
1027 Self::ArtifactNotFound {
1028 name: name.into(),
1029 version: version.into(),
1030 }
1031 }
1032
1033 #[must_use]
1034 pub fn artifact_integrity_failed(name: impl Into<String>, version: impl Into<String>) -> Self {
1035 Self::ArtifactIntegrityFailed {
1036 name: name.into(),
1037 version: version.into(),
1038 }
1039 }
1040
1041 #[must_use]
1042 pub fn wasi(path: PathBuf, message: impl Into<String>) -> Self {
1043 Self::WasiInit {
1044 path: path.display().to_string(),
1045 message: message.into(),
1046 }
1047 }
1048}
1049
1050impl RuntimeError {
1051 /// `error` as a runtime error: a refusal is [`Self::WorkdirPathRefused`], and an I/O failure
1052 /// whatever `io` makes of its path and source.
1053 pub(crate) fn from_workdir(
1054 error: crate::workdir_fs::WorkdirError,
1055 io: impl FnOnce(String, std::io::Error) -> Self,
1056 ) -> Self {
1057 match error {
1058 crate::workdir_fs::WorkdirError::Refused(refusal) => {
1059 Self::WorkdirPathRefused { refusal }
1060 }
1061 crate::workdir_fs::WorkdirError::Io { path, source } => io(path, source),
1062 }
1063 }
1064}
1065
1066/// A refusal is [`RuntimeError::WorkdirPathRefused`]; any I/O failure is
1067/// [`RuntimeError::CreateWorkdir`]. Use [`RuntimeError::from_workdir`] where that variant is wrong.
1068impl From<crate::workdir_fs::WorkdirError> for RuntimeError {
1069 fn from(error: crate::workdir_fs::WorkdirError) -> Self {
1070 Self::from_workdir(error, |path, source| Self::CreateWorkdir { path, source })
1071 }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::*;
1077
1078 #[test]
1079 fn cgroup_delegation_refusal_links_the_published_platform_section() {
1080 let message = RuntimeError::CgroupDelegationUnavailable { reason: "x".into() }.to_string();
1081 assert!(message.contains("(x)"), "{message}");
1082 assert!(
1083 message
1084 .contains("https://docs.murmur.nexus/reference/resource-limits/#platform-behavior"),
1085 "{message}"
1086 );
1087 assert!(!message.contains("docs/content"), "{message}");
1088 }
1089}