Skip to main content

capsule_runtime/
errors.rs

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