Skip to main content

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