Skip to main content

capsule_runtime/
errors.rs

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