Skip to main content

murmur_artifact/
runtime_manifest.rs

1use std::{fs, net::IpAddr, path::Path};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use url::Url;
6
7use crate::{
8    artifact_name::{ArtifactName, ArtifactNameError},
9    build::artifact_name_format_error,
10    manifest::{REGISTRY_BLOCK_KEY, REGISTRY_BLOCK_MESSAGE},
11    manifest_path::MANIFEST_FILENAME,
12    shell_words::split_shell_words,
13    trace_capture::{resolve_trace_capture, TraceCapture},
14    unknown_manifest_keys::{nearest_known_key, UnknownManifestKey},
15};
16
17// ── Lifecycle types ───────────────────────────────────────────────────────────
18
19#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum TaskAcceptance {
22    None,
23    #[default]
24    Single,
25    Queue,
26}
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum AfterTask {
31    #[default]
32    Exit,
33    Sleep,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ConversationMode {
39    #[default]
40    Stateless,
41    Threaded,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct LifecycleConfig {
46    pub task_acceptance: TaskAcceptance,
47    pub after_task: AfterTask,
48    pub queue_depth: usize,
49    /// If set, request-input calls that receive no follow-up within this many seconds
50    /// transition the task to "failed" with reason "input-timeout". Absent = wait indefinitely.
51    pub input_timeout_secs: Option<u64>,
52    /// Whether tasks sharing a contextId accumulate conversation history across turns.
53    /// Stateless (default): each task is fully independent; Threaded: history is loaded
54    /// and persisted per contextId within a session.
55    pub conversation_mode: ConversationMode,
56    /// Maximum times an `on-task-end` hook may reopen a single task (re-run its agent
57    /// loop with injected feedback). Defaults to 1 when absent. `0` disables reopening
58    /// entirely. Unlike `inference.max_turns`, `0` is a valid explicit value. Reopening
59    /// never grants turns past `inference.max_turns` — the two budgets share one
60    /// cumulative turn count.
61    pub max_task_reopens: u32,
62    /// How long a shell command runs in the foreground before it is demoted to the background.
63    /// `None` when not declared: a command runs in the foreground until it exits, nothing is
64    /// demoted on a clock, and the `background-command` tool is not offered. With a value, a
65    /// command that exits inside this window returns its output to the turn; one that outruns it
66    /// hands the turn a handle, keeps running, and reports back later as a `completion`-origin
67    /// task. `0` demotes at the first poll after the spawn, so effectively every command detaches.
68    pub shell_grace_secs: Option<u64>,
69    /// The single bound on a delegation, covering both waits one can have: how long a sub-capsule
70    /// started by `delegate-task` is watched, and how long a plan `capsule` step waits for its
71    /// answer. Defaults to 600 seconds. Absent is a ceiling rather than an absence: a capsule that
72    /// never declares this one is still bounded, because an unbounded delegation leaves a wedged
73    /// sub-capsule running for as long as the capsule that started it. `0` gives up at the first
74    /// poll after the task is delivered.
75    ///
76    /// Reaching the deadline ends the sub-capsule. A `delegate-task` caller is told in a
77    /// `completion`-origin task carrying `status: terminated` and a detail naming the bound; a plan
78    /// step's call returns `timed_out`. The clock starts when the sub-capsule reports itself ready,
79    /// not when its process is spawned, so a slow host does not spend this window on staging.
80    /// `MURMUR_DELEGATION_TIMEOUT_SECS` overrides this value when it names a positive number of
81    /// seconds.
82    pub delegation_deadline_secs: u64,
83    /// How long before a `mur run --deadline` the runtime tells the agent, once, that its session
84    /// is about to end. `None` takes the default the runtime derives from the deadline: five
85    /// minutes, or a tenth of the deadline when that is shorter, and at least one second. `0`
86    /// sends no notice. Inert for a session launched with no deadline.
87    pub deadline_notice_secs: Option<u64>,
88    /// Keeps a task's agent working past its turn ends: each time an attempt ends at a turn end,
89    /// the runtime sends [`KeepWorking::message`] as the next user message in the same
90    /// conversation, up to [`KeepWorking::limit`] times per task. `None` (the default, and what a
91    /// manifest without `lifecycle.keep_working` gets) ends a task at its first turn end.
92    pub keep_working: Option<KeepWorking>,
93    /// What "done" means for a task: each time an attempt stops at a turn end, the runtime runs
94    /// [`DoneCheck::command`] in the work folder and, while it fails, reopens the task with its
95    /// output, up to [`DoneCheck::limit`] times per task. `None` (the default, and what a manifest
96    /// without `lifecycle.done_check` gets) ends a task when the model stops.
97    pub done_check: Option<DoneCheck>,
98}
99
100/// `lifecycle.keep_working`: what the runtime sends when a task's attempt ends at a turn end, and
101/// how many times per task it does so. Both fields are required; see
102/// [`LifecycleConfig::keep_working`].
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct KeepWorking {
105    /// Sent verbatim, unfenced, as the next user message. Never blank.
106    pub message: String,
107    /// How many continuations one task may receive. At least 1.
108    pub limit: u32,
109}
110
111/// `lifecycle.done_check`: the command whose exit status says whether a task's work is done, and
112/// how many times per task a failing run may reopen the task; see [`LifecycleConfig::done_check`].
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct DoneCheck {
115    /// As declared. Split with [`split_shell_words`]; its first word is a
116    /// `capabilities.shell.allow` entry.
117    pub command: String,
118    /// How many times one task may be reopened by a failing run, counted across hook reopens and
119    /// separately from `max_task_reopens`. `0` runs and records the check without reopening.
120    pub limit: u32,
121    /// How long one run may take before the runtime kills it. At least 1.
122    pub timeout_secs: u64,
123}
124
125impl DoneCheck {
126    /// The default [`DoneCheck::timeout_secs`].
127    pub const DEFAULT_TIMEOUT_SECS: u64 = 600;
128
129    /// [`DoneCheck::command`] as the program and its arguments.
130    pub fn argv(&self) -> Vec<String> {
131        split_shell_words(&self.command)
132    }
133}
134
135impl LifecycleConfig {
136    /// Whether a session under this lifecycle is still there to receive work that arrives after
137    /// the task which started it ended — a demoted shell command's completion, a delegation's
138    /// outcome.
139    ///
140    /// `queue` + `sleep` is the only combination that qualifies, and the delegation lane and the
141    /// shell lane must agree on it: a capsule warned about one and not the other would be told its
142    /// lifecycle is both adequate and inadequate for the same reason.
143    pub fn can_receive_background_tasks(&self) -> bool {
144        self.task_acceptance == TaskAcceptance::Queue && self.after_task == AfterTask::Sleep
145    }
146
147    /// Whether this lifecycle's queue has room for a waiting task: `false` exactly for
148    /// `task_acceptance: queue` at `queue_depth: 0`, which the A2A door would turn into a door
149    /// refusing every task, with nowhere for background completions to wait.
150    ///
151    /// The manifest refuses such a lifecycle at load
152    /// ([`RuntimeManifestError::QueueWithoutRoom`]) and staging refuses one a
153    /// `--lifecycle-task-acceptance queue` override produces; both ask this. `queue_depth` means
154    /// nothing under `single` or `none`, so any value passes there.
155    pub fn queue_has_room(&self) -> bool {
156        !(self.task_acceptance == TaskAcceptance::Queue && self.queue_depth == 0)
157    }
158
159    /// How many tasks a capsule under this lifecycle holds at once: one running task plus
160    /// `queue_depth` waiting ones under `queue`, a single task under `single`, none under `none`.
161    ///
162    /// A `queue` lifecycle at `queue_depth: 0` is refused at load and at staging
163    /// ([`Self::queue_has_room`]), so no running capsule has one. The A2A door is the authority
164    /// this mirrors: the task registry in `capsule-runtime` (`TaskRegistry::can_accept`) decides
165    /// what is rejected, and a test there fails if the two drift apart.
166    pub fn tasks_held_at_once(&self) -> usize {
167        match self.task_acceptance {
168            TaskAcceptance::None => 0,
169            TaskAcceptance::Single => 1,
170            TaskAcceptance::Queue => self.queue_depth + 1,
171        }
172    }
173}
174
175impl Default for LifecycleConfig {
176    fn default() -> Self {
177        Self {
178            task_acceptance: TaskAcceptance::Single,
179            after_task: AfterTask::Exit,
180            queue_depth: 1,
181            input_timeout_secs: None,
182            conversation_mode: ConversationMode::Stateless,
183            max_task_reopens: 1,
184            shell_grace_secs: None,
185            delegation_deadline_secs: 600,
186            deadline_notice_secs: None,
187            keep_working: None,
188            done_check: None,
189        }
190    }
191}
192
193/// Lifecycle override: allows a parent (mur-roost) or CLI flag to constrain
194/// the lifecycle down from what the manifest declares.
195#[derive(Debug, Clone, Default)]
196pub struct LifecycleOverride {
197    pub task_acceptance: Option<TaskAcceptance>,
198    pub after_task: Option<AfterTask>,
199}
200
201/// Artifact taxonomy for entries declared in a capsule manifest.
202///
203/// Variant meanings:
204///
205/// - `Tool`: agent-callable tool implemented as a WASM component. Visible in MURMUR.md and the
206///   LLM tool list.
207/// - `Driver`: inference driver implemented as a WASM component. The runtime calls it as a
208///   processor. Hidden from the LLM.
209/// - `Hook`: event-triggered artifact implemented as a WASM component. The runtime calls it at
210///   lifecycle points; behavioral contract (binding, execution_mode, commit_policy) is declared
211///   in the artifact's own murmur.yaml. Hidden from the LLM.
212/// - `Skill`: documentation artifact; no executable binary. Visible in the LLM tool inventory.
213///   Invoking a skill by name returns the body of its `SKILL.md` (the text after the front
214///   block) as the tool result (just-in-time injection). No WASM dispatch occurs. A skill bound via
215///   `inference.system_prompt_artifact` is excluded from the callable inventory because it is
216///   already part of the system prompt.
217#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
218#[serde(rename_all = "lowercase")]
219pub enum ArtifactRuntime {
220    Tool,
221    Driver,
222    Hook,
223    Skill,
224}
225
226impl ArtifactRuntime {
227    /// Whether artifacts of this role appear in the LLM's tool inventory.
228    ///
229    /// Deliberately an exhaustive match with one arm per variant and no wildcard: a new
230    /// `ArtifactRuntime` variant must state its own visibility here, so adding a role that
231    /// should be LLM-visible cannot silently default to hidden.
232    pub fn is_llm_visible(&self) -> bool {
233        match self {
234            ArtifactRuntime::Tool => true,
235            ArtifactRuntime::Skill => true,
236            ArtifactRuntime::Driver => false,
237            ArtifactRuntime::Hook => false,
238        }
239    }
240
241    pub fn as_str(&self) -> &'static str {
242        match self {
243            ArtifactRuntime::Tool => "tool",
244            ArtifactRuntime::Driver => "driver",
245            ArtifactRuntime::Hook => "hook",
246            ArtifactRuntime::Skill => "skill",
247        }
248    }
249}
250
251// ── Hook configuration types ──────────────────────────────────────────────────
252
253/// Which lifecycle event(s) a hook binds to.
254///
255/// Declared via `binding:` in the artifact's own murmur.yaml.
256/// Defaults to `All` when the field is absent (receives all session events).
257#[derive(Debug, Clone, PartialEq, Eq)]
258pub enum HookBinding {
259    /// `"on-stage"` — fires during `stage_session`; always blocking.
260    OnStage,
261    /// `"on-session-start"` — fires once before the first inference turn.
262    OnSessionStart,
263    /// `"on-task-start"` — fires once per task, before that task's first inference turn.
264    OnTaskStart,
265    /// `"on-inference"` — fires after each inference response is parsed.
266    OnInference,
267    /// `"on-tool-call"` — fires after each model-requested tool invocation.
268    OnToolCall,
269    /// `"on-shell"` — fires after each shell command returns.
270    OnShell,
271    /// `"on-compaction"` — fires when the session token threshold is reached.
272    OnCompaction,
273    /// `"on-task-end"` — fires once per task, after that task's agent loop returns.
274    OnTaskEnd,
275    /// `"on-session-end"` — fires at session teardown.
276    OnSessionEnd,
277    /// No `binding:` field — receives all session events (not on-stage).
278    All,
279}
280
281impl HookBinding {
282    /// The manifest spelling of this binding, which is also the WIT lifecycle function
283    /// name the runtime dispatches to — the key `capsule-runtime`'s honored-arm table is
284    /// written against. [`HookBinding::All`] has no spelling of its own (it is what an
285    /// omitted `binding:` means), so it renders as `"all"` for diagnostics only.
286    #[must_use]
287    pub fn as_str(&self) -> &'static str {
288        match self {
289            Self::OnStage => "on-stage",
290            Self::OnSessionStart => "on-session-start",
291            Self::OnTaskStart => "on-task-start",
292            Self::OnInference => "on-inference",
293            Self::OnToolCall => "on-tool-call",
294            Self::OnShell => "on-shell",
295            Self::OnCompaction => "on-compaction",
296            Self::OnTaskEnd => "on-task-end",
297            Self::OnSessionEnd => "on-session-end",
298            Self::All => "all",
299        }
300    }
301}
302
303/// Whether the runtime waits for the hook result.
304///
305/// Declared via `execution_mode:` in the artifact's own murmur.yaml.
306/// Defaults to `Blocking` when the field is absent.
307#[derive(Debug, Clone, PartialEq, Eq)]
308pub enum HookExecutionMode {
309    /// `"blocking"` — runtime waits for result before proceeding.
310    Blocking,
311    /// `"async"` — runtime proceeds immediately; result not committed.
312    Async,
313}
314
315/// What the runtime does with a successful hook output.
316///
317/// Declared via `commit_policy:` in the artifact's own murmur.yaml.
318/// Defaults to `None` when the field is absent.
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub enum HookCommitPolicy {
321    /// `"none"` — output discarded.
322    None,
323    /// `"replace-context"` — runtime replaces conversation history. Valid only with on-compaction.
324    ReplaceContext,
325    /// `"write-manifests"` — runtime writes tool manifests to the runtime directory's tools/. Valid
326    /// only with on-stage.
327    WriteManifests,
328    /// `"reopen-task"` — runtime re-runs the task's agent loop with the hook's feedback.
329    /// Valid only with on-task-end.
330    ReopenTask,
331    /// `"seed-context"` — runtime places the hook's messages at the head of the task's
332    /// first message list, under the `context.seed_budget` ceiling. Valid only with
333    /// on-task-start.
334    SeedContext,
335    /// `"deny"` — the hook decides, immediately before the call is dispatched, whether the
336    /// call happens at all. Valid only with on-shell and on-tool-call, and only with an
337    /// explicit `binding:` naming one of them.
338    ///
339    /// The only policy that *subtracts*: every other value commits something the hook
340    /// produced, this one refuses something the capsule asked for. It narrows the manifest's
341    /// grant and can never widen it.
342    Deny,
343}
344
345impl HookCommitPolicy {
346    /// The manifest spelling of this policy, for diagnostics.
347    #[must_use]
348    pub fn as_str(&self) -> &'static str {
349        match self {
350            Self::None => "none",
351            Self::ReplaceContext => "replace-context",
352            Self::WriteManifests => "write-manifests",
353            Self::ReopenTask => "reopen-task",
354            Self::SeedContext => "seed-context",
355            Self::Deny => "deny",
356        }
357    }
358}
359
360/// What the runtime does with a lifecycle event for an `execution_mode: async` hook
361/// whose job queue is already full.
362///
363/// Declared via `on_overflow:` on the hook's entry in the **capsule operator's own**
364/// manifest — not in the hook artifact's bundled murmur.yaml, because keeping up with
365/// the agent loop is the operator's trade-off, not the hook author's. Inert on a hook
366/// that turns out to be `execution_mode: blocking` (a blocking hook is never queued);
367/// the operator-manifest parser cannot tell the two apart, since the mode lives in the
368/// artifact's own manifest and is only known at staging time.
369#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
370#[serde(rename_all = "snake_case")]
371pub enum HookOverflowPolicy {
372    /// `"drop"` (the default) — discard the event and count it. The agent loop never
373    /// waits on a slow hook; telemetry is lossy under sustained overload, and the loss
374    /// is reported rather than silent.
375    #[default]
376    Drop,
377    /// `"block"` — the agent loop waits for the hook's worker to make room. No event is
378    /// lost, at the cost of putting a slow hook back on the critical path.
379    Block,
380}
381
382impl HookOverflowPolicy {
383    /// The manifest spelling of this policy, for diagnostics.
384    #[must_use]
385    pub fn as_str(&self) -> &'static str {
386        match self {
387            Self::Drop => "drop",
388            Self::Block => "block",
389        }
390    }
391}
392
393/// Behavioral contract for a hook artifact, read from its own murmur.yaml.
394#[derive(Debug, Clone, PartialEq, Eq)]
395pub struct HookConfig {
396    pub binding: HookBinding,
397    pub execution_mode: HookExecutionMode,
398    pub commit_policy: HookCommitPolicy,
399}
400
401impl Default for HookConfig {
402    fn default() -> Self {
403        Self {
404            binding: HookBinding::All,
405            execution_mode: HookExecutionMode::Blocking,
406            commit_policy: HookCommitPolicy::None,
407        }
408    }
409}
410
411/// The one `commit_policy` a hook bound to `binding` can actually have committed, or
412/// `None` when no `commit_policy` value other than `none` is ever honored for it.
413///
414/// This is the manifest-side mirror of `capsule-runtime`'s `HONORED_OUTPUT_ARM` table,
415/// which decides — keyed on the same lifecycle function names [`HookBinding::as_str`]
416/// produces — which `hook-output` arm the runtime commits for each event. Two bindings
417/// return `None` for different reasons, and the distinction matters:
418///
419/// - `on-session-start` and `on-session-end` honor no arm at all; every output from them
420///   is discarded.
421/// - `on-tool-call` and `on-shell` honor `deny`, and only at their decision-point dispatch
422///   — the one that runs before the call. Their post-call observation dispatch honors
423///   nothing, which is why the runtime's table is keyed on the dispatch phase as well as
424///   the event name.
425/// - `on-inference` *does* honor an arm (`artifact`), but that arm has no
426///   `commit_policy` spelling — [`HookCommitPolicy`] has no `Artifact` variant — so no
427///   non-`none` policy is declarable for it either.
428///
429/// [`HookBinding::All`] (an omitted `binding:`) also returns `None`, and that is a third
430/// reason again: an `All`-bound hook is dispatched to *every* event, so there is no single
431/// honored policy to name. It is deliberately **not** validated against this function —
432/// [`parse_hook_config_from_yaml`] accepts every `commit_policy` for it, with the single
433/// exception of [`HookCommitPolicy::Deny`], which that function rejects outright for `All`.
434#[must_use]
435pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
436    match binding {
437        HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
438        HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
439        HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
440        HookBinding::OnTaskStart => Some(HookCommitPolicy::SeedContext),
441        HookBinding::OnToolCall | HookBinding::OnShell => Some(HookCommitPolicy::Deny),
442        HookBinding::OnSessionStart
443        | HookBinding::OnInference
444        | HookBinding::OnSessionEnd
445        | HookBinding::All => None,
446    }
447}
448
449/// Read a hook's behavioral contract from its murmur.yaml at the given path.
450pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
451    let content =
452        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
453            path: artifact_manifest_path.display().to_string(),
454            source,
455        })?;
456    parse_hook_config_from_yaml(&content).map_err(|msg| {
457        RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
458    })
459}
460
461/// Parse hook behavioral contract from an in-memory manifest YAML string.
462///
463/// `commit_policy: deny` is the one policy an omitted `binding:` cannot carry. Every other
464/// policy is accepted for [`HookBinding::All`] because an `All`-bound hook reaches every
465/// event and the runtime simply discards what a given event does not honor. `deny` is not
466/// discardable in that way: it is answered at a *decision point* standing in front of a call,
467/// and an `All`-bound hook would be asked to decide on shell and tool calls it was never
468/// written to judge — with every failure to answer, including a trap or a timeout, refusing
469/// the call. The binding is therefore required to name which of the two events the hook gates.
470pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
471    let v: serde_norway::Value =
472        serde_norway::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
473
474    let binding = v
475        .as_mapping()
476        .and_then(|m| m.get(serde_norway::Value::String("binding".to_string())))
477        .and_then(serde_norway::Value::as_str)
478        .map(|s| match s {
479            "on-stage" => Ok(HookBinding::OnStage),
480            "on-session-start" => Ok(HookBinding::OnSessionStart),
481            "on-task-start" => Ok(HookBinding::OnTaskStart),
482            "on-inference" => Ok(HookBinding::OnInference),
483            "on-tool-call" => Ok(HookBinding::OnToolCall),
484            "on-shell" => Ok(HookBinding::OnShell),
485            "on-compaction" => Ok(HookBinding::OnCompaction),
486            "on-task-end" => Ok(HookBinding::OnTaskEnd),
487            "on-session-end" => Ok(HookBinding::OnSessionEnd),
488            other => Err(format!(
489                "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
490                 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
491            )),
492        })
493        .transpose()?
494        .unwrap_or(HookBinding::All);
495
496    let execution_mode = v
497        .as_mapping()
498        .and_then(|m| m.get(serde_norway::Value::String("execution_mode".to_string())))
499        .and_then(serde_norway::Value::as_str)
500        .map(|s| match s {
501            "blocking" => Ok(HookExecutionMode::Blocking),
502            "async" => Ok(HookExecutionMode::Async),
503            other => Err(format!(
504                "unknown execution_mode '{other}'; expected: blocking, async"
505            )),
506        })
507        .transpose()?
508        .unwrap_or(HookExecutionMode::Blocking);
509
510    let commit_policy = v
511        .as_mapping()
512        .and_then(|m| m.get(serde_norway::Value::String("commit_policy".to_string())))
513        .and_then(serde_norway::Value::as_str)
514        .map(|s| match s {
515            "none" => Ok(HookCommitPolicy::None),
516            "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
517            "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
518            "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
519            "seed-context" => Ok(HookCommitPolicy::SeedContext),
520            "deny" => Ok(HookCommitPolicy::Deny),
521            other => Err(format!(
522                "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
523                 reopen-task, seed-context, deny"
524            )),
525        })
526        .transpose()?
527        .unwrap_or(HookCommitPolicy::None);
528
529    // Validation
530    if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
531        return Err(format!(
532            "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
533             (got '{}')",
534            commit_policy.as_str()
535        ));
536    }
537    if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
538        return Err(
539            "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
540             'on-stage'"
541                .to_string(),
542        );
543    }
544    // The one policy `All` cannot carry: a hook that does not name which of the two gated
545    // events it decides on would be dispatched at decision points it was never written for,
546    // and every non-`none` answer there refuses a call.
547    if binding == HookBinding::All && commit_policy == HookCommitPolicy::Deny {
548        return Err(
549            "commit_policy 'deny' requires an explicit binding: 'on-shell' or 'on-tool-call'; \
550             a hook with no binding: is dispatched at every event, including decision points \
551             it was not written to decide"
552                .to_string(),
553        );
554    }
555    // `binding` is the single source of truth for what the runtime commits; a declared
556    // `commit_policy` the binding can never honor is a mistake that is fully knowable here,
557    // at staging time, rather than mid-session as a `hook_dispatch_error`. An omitted
558    // `binding:` (`All`) reaches every event, so every other policy stays valid for it.
559    if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
560        let honored = commit_policy_for_binding(&binding);
561        if honored.as_ref() != Some(&commit_policy) {
562            let note = if binding == HookBinding::OnInference {
563                " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
564            } else {
565                ""
566            };
567            return Err(format!(
568                "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
569                 commit_policy '{}'{note}",
570                commit_policy.as_str(),
571                binding.as_str(),
572                binding.as_str(),
573                honored.as_ref().map_or("none", HookCommitPolicy::as_str)
574            ));
575        }
576    }
577
578    Ok(HookConfig {
579        binding,
580        execution_mode,
581        commit_policy,
582    })
583}
584
585#[derive(Debug, Clone, PartialEq, Eq)]
586pub struct RuntimeManifest {
587    pub name: String,
588    pub version: String,
589    pub artifacts: Vec<RuntimeArtifact>,
590    pub capabilities: Option<Capabilities>,
591    pub inference: Option<InferenceConfig>,
592    pub context: Option<ContextConfig>,
593    pub observability: Option<ObservabilityConfig>,
594    pub trace: Option<TraceConfig>,
595    pub network: Option<NetworkConfig>,
596    pub lifecycle: Option<LifecycleConfig>,
597    /// Read-only views onto the workdir that the operator opens to processes outside the capsule.
598    /// `None` means nothing is exported and every request to the resource plane is denied.
599    pub exports: Option<Exports>,
600    /// What a controller holding this session's control token may change while it runs. `None`
601    /// — the block absent, `control: {}`, or both lists empty — means the capsule has no control
602    /// surface and mints no token.
603    pub control: Option<ControlConfig>,
604    /// The `deploy:` block. `None` when the manifest declares none.
605    pub deploy: Option<DeployConfig>,
606    /// Every key the manifest declared that this build does not recognize, captured by each
607    /// `Raw*` block's `#[serde(flatten)]` overflow map instead of dropped.
608    ///
609    /// Ordered by the walk rather than by the manifest's own line order: a containing block's own
610    /// keys precede the blocks nested inside it, and one block's keys are alphabetical.
611    ///
612    /// Empty for a manifest written entirely in keys this build knows. Never a reason to refuse a
613    /// manifest — it is reported as `W-SEC-019` by
614    /// [`crate::unknown_manifest_keys::warn_on_unknown_manifest_keys`] and nothing else reads it.
615    pub unknown_keys: Vec<UnknownManifestKey>,
616}
617
618impl RuntimeManifest {
619    pub fn effective_lifecycle(&self) -> LifecycleConfig {
620        self.lifecycle.clone().unwrap_or_default()
621    }
622
623    /// Whether this capsule's door serves tasks a peer capsule's runtime sends it: `true` only
624    /// when the manifest declares `exports.peer_tasks.accept: true`. See
625    /// [`Exports::accepts_peer_tasks`].
626    ///
627    /// The capsule's own manifest is the only input. Nothing a launcher, an operator or a
628    /// formation passes can change the answer.
629    pub fn accepts_peer_tasks(&self) -> bool {
630        self.exports
631            .as_ref()
632            .is_some_and(Exports::accepts_peer_tasks)
633    }
634
635    /// The `mur` release `mur deploy` installs on the target: `deploy.mur_version`, or `None`
636    /// when the block or the key is absent or the value is blank.
637    pub fn deploy_mur_version(&self) -> Option<&str> {
638        self.deploy.as_ref()?.mur_version.as_deref()
639    }
640}
641
642/// The top-level `deploy:` block, read by `mur deploy` only. `mur run` takes no meaning from it.
643#[derive(Debug, Clone, Default, PartialEq, Eq)]
644pub struct DeployConfig {
645    /// The `mur` release `mur deploy` downloads, caches at `~/.murmur/bin/mur-{version}-{platform}`
646    /// and installs on the target. `None` for an absent or blank value, in which case the
647    /// deploying `mur`'s own version is installed.
648    pub mur_version: Option<String>,
649}
650
651/// A setting of a running session that a controller may change over the control surface, when
652/// `control.settings` names it.
653///
654/// Each variant is one manifest key, spelled by [`Self::wire_name`] in `control.settings`, in the
655/// control surface's paths and bodies, and in the trace. What a value must be, and where the agent
656/// loop reads it, is the runtime's to decide per variant.
657#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
658pub enum ControllableSetting {
659    /// `inference.max_tokens`: the per-turn output cap the agent loop sends as `max_tokens`.
660    /// `transport: http` only, on the same terms as the manifest key.
661    InferenceMaxTokens,
662    /// `inference.driver`: which driver choice serves the agent loop's inference calls, by name —
663    /// [`PRIMARY_DRIVER_CHOICE`] or an `inference.alternates` entry's `name`. `transport: http`
664    /// only, and only on a capsule that declares at least one alternate.
665    InferenceDriver,
666}
667
668impl ControllableSetting {
669    /// Every setting a manifest may list, in the order refusals name them.
670    pub const ALL: &'static [ControllableSetting] =
671        &[Self::InferenceMaxTokens, Self::InferenceDriver];
672
673    /// Every setting `control.agent_settings` may list: the ones the agent may change about
674    /// itself through a runtime-provided tool.
675    pub const AGENT_SETTABLE: &'static [ControllableSetting] = &[Self::InferenceDriver];
676
677    /// The manifest key this setting is, as written in `control.settings`.
678    pub fn wire_name(self) -> &'static str {
679        match self {
680            Self::InferenceMaxTokens => "inference.max_tokens",
681            Self::InferenceDriver => "inference.driver",
682        }
683    }
684
685    /// The setting `name` spells, or `None` for anything outside [`Self::ALL`].
686    pub fn from_wire_name(name: &str) -> Option<Self> {
687        Self::ALL
688            .iter()
689            .copied()
690            .find(|setting| setting.wire_name() == name)
691    }
692}
693
694/// The top-level `control:` block: what a controller may change on a running session.
695///
696/// Parsed only to a non-empty declaration; `control: {}` and two empty lists parse as no block.
697/// Nothing a controller sets is persisted, so this is also everything a restart starts from.
698#[derive(Debug, Clone, PartialEq, Eq, Default)]
699pub struct ControlConfig {
700    /// Settings a controller may change, in manifest order, each listed once.
701    pub settings: Vec<ControllableSetting>,
702    /// Credential names a controller supplies at run time, in manifest order, each listed once.
703    /// Every name backs some artifact's `gateway.api_key: ${NAME}`, and none backs the configured
704    /// inference driver's. Such a name is never looked up in the config or the environment.
705    pub secrets: Vec<String>,
706    /// Settings the agent may change about itself, in manifest order, each listed once. Only
707    /// [`ControllableSetting::AGENT_SETTABLE`] members. The grant is the existence of the
708    /// runtime-provided tool that changes the setting, and never a route to the control surface.
709    pub agent_settings: Vec<ControllableSetting>,
710}
711
712impl ControlConfig {
713    /// Whether `name` is a credential a controller supplies.
714    pub fn declares_secret(&self, name: &str) -> bool {
715        self.secrets.iter().any(|secret| secret == name)
716    }
717
718    /// Whether a controller may change `setting`.
719    pub fn controller_may_set(&self, setting: ControllableSetting) -> bool {
720        self.settings.contains(&setting)
721    }
722
723    /// Whether the agent may change `setting`.
724    pub fn agent_may_set(&self, setting: ControllableSetting) -> bool {
725        self.agent_settings.contains(&setting)
726    }
727}
728
729/// The top-level `network:` block, which configures the A2A door. Unrelated to
730/// `capabilities.network`, which is the guest's egress.
731#[derive(Debug, Clone, PartialEq, Eq)]
732pub struct NetworkConfig {
733    /// Internal port the capsule expects to listen on. Default 14159 when absent.
734    pub internal_port: Option<u16>,
735    /// `network.authentication`: how a caller of the door proves it may call. `None` is a public
736    /// door, which answers every caller that reaches the port.
737    pub authentication: Option<NetworkAuthentication>,
738}
739
740/// Every scope a door token can carry: the door's JSON-RPC methods that a token gates, by wire
741/// name, and `resources/files`, the operator resource plane. The runtime's method table is held
742/// to this list by a test, in both directions.
743pub const DOOR_SCOPES: &[&str] = &[
744    "SendMessage",
745    "SendStreamingMessage",
746    "murmur.WatchSession",
747    "GetTask",
748    "ListTasks",
749    "CancelTask",
750    "SubscribeToTask",
751    "murmur.StopSession",
752    "resources/files",
753];
754
755/// The retired door method names a credential's `scopes` may not list, each with the method that
756/// replaces it: the A2A 0.3 names of the A2A methods, and the 0.3-style names murmur's own methods
757/// had. A manifest naming one is refused with [`RuntimeManifestError::RetiredDoorScope`]: the door
758/// serves no retired name, and no token carries one.
759///
760/// A replacement outside [`DOOR_SCOPES`] is a method every authenticated caller may call, which a
761/// credential lists nothing for. A test holds every replacement to the runtime's method table and
762/// every retired name outside it.
763pub const RETIRED_DOOR_SCOPES: &[(&str, &str)] = &[
764    ("message/send", "SendMessage"),
765    ("message/stream", "SendStreamingMessage"),
766    ("tasks/get", "GetTask"),
767    ("tasks/cancel", "CancelTask"),
768    ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
769    ("stream/watch", "murmur.WatchSession"),
770    ("session/stop", "murmur.StopSession"),
771];
772
773/// The credential the runtime always mints for an authenticated door, holding every scope in
774/// [`DOOR_SCOPES`]. Reserved: no declared credential may take the name.
775pub const OPERATOR_CREDENTIAL: &str = "operator";
776
777/// Longest credential name `network.authentication.credentials` accepts.
778const MAX_CREDENTIAL_NAME_LEN: usize = 32;
779
780/// How a caller of the door authenticates. A required field with one value, so that an
781/// authenticated door is an explicit statement and a second scheme is an addition, not a default
782/// change.
783#[derive(Debug, Clone, Copy, PartialEq, Eq)]
784pub enum AuthenticationScheme {
785    /// `Authorization: Bearer <token>`, with a token the capsule's runtime mints at launch.
786    Bearer,
787}
788
789impl AuthenticationScheme {
790    /// Every scheme a manifest may name, in the order refusals list them.
791    pub const ALL: &'static [AuthenticationScheme] = &[Self::Bearer];
792
793    /// The scheme as written in `network.authentication.scheme`.
794    pub fn wire_name(self) -> &'static str {
795        match self {
796            Self::Bearer => "bearer",
797        }
798    }
799}
800
801/// One declared credential: a token the runtime mints beside the operator token, reaching only
802/// `scopes`.
803#[derive(Debug, Clone, PartialEq, Eq)]
804pub struct DoorCredential {
805    /// Matches `^[a-z][a-z0-9_-]{0,31}$` and is never [`OPERATOR_CREDENTIAL`].
806    pub name: String,
807    /// Non-empty, each entry in [`DOOR_SCOPES`] and listed once, in declared order.
808    pub scopes: Vec<String>,
809}
810
811/// `network.authentication`: the door refuses every caller that presents no token this session
812/// minted.
813#[derive(Debug, Clone, PartialEq, Eq)]
814pub struct NetworkAuthentication {
815    pub scheme: AuthenticationScheme,
816    /// The declared credentials, sorted by name. Empty when only the operator token is minted.
817    pub credentials: Vec<DoorCredential>,
818}
819
820// ── Exports ───────────────────────────────────────────────────────────────────
821
822/// Per-file read ceiling applied when `exports.files.max_bytes` is absent: 10Mi.
823pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
824
825/// What the operator discloses to processes *outside* the capsule, declared under the top-level
826/// `exports:` key.
827///
828/// A sibling of `capabilities:` rather than a member of it: a capability is something the guest
829/// holds and an export is a disclosure the operator makes. Declaring one
830/// gives the agent nothing it did not already have — it widens only the operator's reach inward,
831/// which is why it never enters the achieved-containment computation.
832#[derive(Debug, Clone, PartialEq, Eq)]
833pub struct Exports {
834    /// The declared read-only file surface. `None` — an `exports:` block with no `files:` —
835    /// means the resource plane is not declared and every request to it is denied.
836    pub files: Option<FileExport>,
837    /// The declared peer-handoff surface. A separate authoriser from [`Self::files`], over a
838    /// separate subtree: declaring one grants nothing about the other, and a capsule may declare
839    /// either, both or neither. `None` means the capsule mints no handles and its peer plane
840    /// answers `no_peer_plane`.
841    pub peer_files: Option<PeerFilesExport>,
842    /// The declared willingness to take tasks from other capsules. `None` — no `peer_tasks:` —
843    /// refuses them exactly as `accept: false` does; read it through
844    /// [`Self::accepts_peer_tasks`] rather than matching on it.
845    pub peer_tasks: Option<PeerTasksExport>,
846}
847
848impl Exports {
849    /// Whether this capsule consents to tasks from peer capsules: `true` only for an explicit
850    /// `exports.peer_tasks.accept: true`. Absent and `false` are the same answer.
851    ///
852    /// The one reading rule. [`RuntimeManifest::accepts_peer_tasks`] delegates here, so a caller
853    /// holding only the lowered `exports:` block decides exactly as one holding the manifest.
854    pub fn accepts_peer_tasks(&self) -> bool {
855        self.peer_tasks.is_some_and(|peer_tasks| peer_tasks.accept)
856    }
857}
858
859/// The `exports.peer_tasks` block: whether the capsule's A2A door serves a task another capsule's
860/// runtime sends it.
861///
862/// An export rather than a capability: tasks already arrive through the door, so consenting gives
863/// the agent nothing it did not have and opens the door to one more class of caller. It names no
864/// peer, and has no field to name one — it is a posture of this capsule, not a grant to anyone.
865/// It is independent of `network.authentication`: consenting neither requires nor implies a
866/// token.
867///
868/// A block rather than a bare boolean so that it can gain fields without a breaking change.
869#[derive(Debug, Clone, Copy, PartialEq, Eq)]
870pub struct PeerTasksExport {
871    /// Required, never inferred. `false` is the same posture as declaring no block at all.
872    pub accept: bool,
873}
874
875/// The `exports.files` block: one subtree of the workdir, readable and nothing else.
876#[derive(Debug, Clone, PartialEq, Eq)]
877pub struct FileExport {
878    /// Subtree of the capsule's accessible workdir this export opens, verbatim as declared
879    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
880    /// directory belongs to the runtime, which is the only component that may decide a path is
881    /// inside the root.
882    ///
883    /// Not required to exist when the capsule launches — the agent may create it during a task.
884    pub root: String,
885    /// Required, with exactly one legal value. A required field with one value is what makes the
886    /// read-only posture an explicit operator statement rather than an unstated default, and
887    /// leaves a future write mode somewhere to be declared rather than somewhere to leak in.
888    pub mode: ExportMode,
889    /// Per-file read ceiling in bytes, defaulting to [`DEFAULT_EXPORT_MAX_BYTES`]. Never an
890    /// aggregate budget: each file is judged on its own size, and a subtree whose total exceeds
891    /// this is still listed in full.
892    pub max_bytes: u64,
893}
894
895/// Handle lifetime applied when `exports.peer_files.max_ttl` is absent and the capsule is
896/// ephemeral (`lifecycle.after_task: exit`): 1h.
897pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
898
899/// The largest `exports.peer_files.max_ttl` a *persistent* capsule
900/// (`lifecycle.after_task: sleep`) may declare: 15m.
901///
902/// An ephemeral capsule needs no ceiling because teardown destroys the minting key and with it
903/// every outstanding handle. A persistent one has withdrawn that bound, so the declared lifetime
904/// becomes the only one — and a handle is not a durability mechanism. Enforced by the runtime,
905/// not here, because the rule reads `lifecycle.after_task` and this parser sees one block at a
906/// time.
907pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
908
909/// Per-file read ceiling applied when `exports.peer_files.max_bytes` is absent: 10Mi.
910pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
911
912/// The `exports.peer_files` block: the one subtree a `share-file` handle may name.
913///
914/// Deliberately not a mode of [`FileExport`]. The operator plane addresses files by path and
915/// enumerates them; this plane has no `list` verb and no path addressing at all, and its audience
916/// is another capsule rather than the operator. Two authorisers over two subtrees, so opening one
917/// never widens the other.
918#[derive(Debug, Clone, PartialEq, Eq)]
919pub struct PeerFilesExport {
920    /// Subtree of the capsule's accessible workdir a handle may name, verbatim as declared
921    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
922    /// directory belongs to the runtime. Not required to exist when the capsule launches.
923    pub root: String,
924    /// The declared `max_ttl`, in seconds, or `None` when the manifest declared none.
925    ///
926    /// Kept undefaulted because the two ephemerality cases answer an absent value differently:
927    /// an ephemeral capsule falls back to [`DEFAULT_PEER_HANDLE_TTL_SECS`], and a persistent one
928    /// refuses to launch. Substituting the default here would erase the difference before
929    /// anything could act on it. See [`Self::effective_max_ttl_secs`].
930    pub max_ttl_secs: Option<u64>,
931    /// Per-file ceiling on a redeemed read, defaulting to [`DEFAULT_PEER_FILES_MAX_BYTES`].
932    /// Per file, never an aggregate budget — the same terms as [`FileExport::max_bytes`], and a
933    /// separate value from it.
934    pub max_bytes: u64,
935}
936
937impl PeerFilesExport {
938    /// The handle lifetime ceiling this export actually applies: the declared `max_ttl`, or the
939    /// ephemeral default when none was declared.
940    ///
941    /// Correct for a persistent capsule too, because one that declared no `max_ttl` never
942    /// launches — the runtime refuses it before a plane is built.
943    pub fn effective_max_ttl_secs(&self) -> u64 {
944        self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
945    }
946}
947
948/// The `capabilities.peer_fetch` block: which peers this capsule may redeem a handle against.
949///
950/// A sibling of [`NetworkCapabilities`] rather than a member of it. Fetching a peer's bytes lands
951/// a file in this capsule's own workdir, so it is an ingestion path and a prompt-injection
952/// surface, and it gets its own operator control. Declaring a destination here does not widen
953/// `capabilities.network.allow`, and a destination allowed there is not redeemable unless it is
954/// also named here.
955#[derive(Debug, Clone, PartialEq, Eq)]
956pub struct PeerFetchCapabilities {
957    /// Network destinations a handle may be redeemed against. Required and non-empty when the
958    /// block is present: an empty list is a parse error, never a silent deny. Same syntax as
959    /// `capabilities.network.allow`, and matched by the same runtime rule matcher.
960    pub allow: Vec<String>,
961}
962
963/// The accepted spelling of every duration in the manifest, stated once so the parser and the
964/// error it produces cannot drift apart.
965pub const DURATION_ACCEPTED_FORM: &str =
966    "must be a duration: an integer, optionally suffixed s/m/h/d";
967
968/// Parses `90`, `30s`, `15m`, `1h`, `14d` into a whole number of seconds.
969///
970/// A bare integer is seconds. Suffixes are lowercase and single-character; `5 minutes` and `30S`
971/// are rejected rather than guessed at, on the same terms as [`parse_byte_size`]. Returns the
972/// accepted-form sentence as its error so every call site reports the same rule.
973pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
974    let trimmed = input.trim();
975    let (digits, multiplier) = match trimmed.strip_suffix('s') {
976        Some(digits) => (digits, 1u64),
977        None => match trimmed.strip_suffix('m') {
978            Some(digits) => (digits, 60),
979            None => match trimmed.strip_suffix('h') {
980                Some(digits) => (digits, 3600),
981                None => match trimmed.strip_suffix('d') {
982                    Some(digits) => (digits, 86_400),
983                    None => (trimmed, 1),
984                },
985            },
986        },
987    };
988    // Not trimmed again: the whole input was trimmed above, so a space surviving here is *inside*
989    // the value (`5 minutes`, `30 m`), which is not a spelling this accepts.
990    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
991        return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
992    }
993    digits
994        .parse::<u64>()
995        .ok()
996        .and_then(|value| value.checked_mul(multiplier))
997        .ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
998}
999
1000#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1001pub enum ExportMode {
1002    #[serde(rename = "read-only")]
1003    ReadOnly,
1004}
1005
1006impl ExportMode {
1007    pub fn as_str(self) -> &'static str {
1008        match self {
1009            ExportMode::ReadOnly => "read-only",
1010        }
1011    }
1012}
1013
1014impl std::fmt::Display for ExportMode {
1015    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1016        f.write_str(self.as_str())
1017    }
1018}
1019
1020/// The accepted spelling of every byte count in the manifest, stated once so the parser and the
1021/// error it produces cannot drift apart.
1022pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
1023
1024/// Parses `4096`, `1Ki`, `10Mi`, `2Gi` into a byte count.
1025///
1026/// Binary suffixes only, and case-sensitively: `10MB` is rejected rather than guessed at, because
1027/// a manifest that means 10 000 000 and a manifest that means 10 485 760 must not both parse.
1028/// Returns the accepted-form sentence as its error so every call site reports the same rule.
1029pub fn parse_byte_size(input: &str) -> Result<u64, String> {
1030    let trimmed = input.trim();
1031    let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
1032        Some(digits) => (digits, 1024u64),
1033        None => match trimmed.strip_suffix("Mi") {
1034            Some(digits) => (digits, 1024 * 1024),
1035            None => match trimmed.strip_suffix("Gi") {
1036                Some(digits) => (digits, 1024 * 1024 * 1024),
1037                None => (trimmed, 1),
1038            },
1039        },
1040    };
1041    // Deliberately not trimmed again: the whole input was trimmed above, so any space left here
1042    // is *inside* the value (`10 Mi`), which is not a spelling this accepts.
1043    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
1044        return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
1045    }
1046    digits
1047        .parse::<u64>()
1048        .ok()
1049        .and_then(|value| value.checked_mul(multiplier))
1050        .ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
1051}
1052
1053#[derive(Debug, Clone, PartialEq, Eq)]
1054pub struct RuntimeArtifact {
1055    pub name: String,
1056    pub version: String,
1057    pub runtime: ArtifactRuntime,
1058    /// Optional local source path. When set, the runtime resolves the artifact directly from
1059    /// this path instead of from the registry — no `.mur.zip`, no publish. For a skill the path
1060    /// names a `SKILL.md` file or a directory holding one, spelled exactly so.
1061    /// Relative paths resolve against the directory containing `murmur.yaml`.
1062    /// Only permitted when `local_source` is true.
1063    pub source: Option<String>,
1064    /// Whether this artifact may be resolved from a local `source:` path. Declared via
1065    /// `local_source:` in the project manifest's artifact entry; when absent it defaults to
1066    /// true for `runtime: skill` and false for every other role, which is exactly the role-based
1067    /// gate this field replaced.
1068    pub local_source: bool,
1069    /// Whether this artifact's payload may be bound as the system prompt via
1070    /// `inference.system_prompt_artifact`. Declared via `prompt_payload:` in the project
1071    /// manifest's artifact entry; when absent it defaults to true for `runtime: skill` and
1072    /// false for every other role.
1073    pub prompt_payload: bool,
1074    /// Per-artifact capability grant, declared via `capabilities:` on this entry in the
1075    /// **capsule operator's own** manifest. Recognized on `runtime: hook`, `runtime: tool`,
1076    /// and `runtime: driver` entries; a `runtime: skill` entry carrying the key is rejected
1077    /// at parse time, because nothing would enforce it and a silently-ignored grant reads
1078    /// like a scoped artifact.
1079    ///
1080    /// What `None` (the key absent) means depends on the role, deliberately:
1081    /// - `hook`: full default-deny — no network and no preopened directory.
1082    /// - `tool`/`driver`: the unchanged capsule-wide ceiling. A declared block *narrows*
1083    ///   from that ceiling and can never widen past it.
1084    ///
1085    /// Deliberately never sourced from the artifact's own bundled `murmur.yaml` (see
1086    /// [`parse_hook_config_from_yaml`], which parses that file and knows nothing about
1087    /// capabilities) — an artifact pulled from a registry cannot self-grant.
1088    ///
1089    /// Reuses the whole [`Capabilities`] type for vocabulary consistency with the
1090    /// capsule-wide block, but only `network` and `filesystem` are consumed by the runtime
1091    /// per-artifact; the other sub-blocks are inert here.
1092    pub capabilities: Option<Capabilities>,
1093    /// What the runtime does when this hook's async job queue is full, declared via
1094    /// `on_overflow:` on this entry. Defaults to [`HookOverflowPolicy::Drop`] when absent.
1095    /// Accepted only on `runtime: hook` entries — every other role is rejected at parse
1096    /// time, since nothing would ever consult it there.
1097    pub on_overflow: HookOverflowPolicy,
1098    /// Operator-authored configuration for this artifact alone, declared via `config:` on this
1099    /// entry. Held as the raw YAML node: this crate accepts any mapping, and the runtime is what
1100    /// lowers it to the JSON delivered as `MURMUR_ARTIFACT_CONFIG` and refuses a shape that
1101    /// cannot travel that way.
1102    ///
1103    /// `None` is the key being absent, which means the variable is absent from the guest
1104    /// environment. `Some(Value::Null)` is `config:` written with nothing under it — a
1105    /// declaration that carries nothing, refused by the runtime rather than treated as absent.
1106    ///
1107    /// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
1108    /// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
1109    /// [`Self::capabilities`]. Operator-sourced only, and never read from the artifact's own
1110    /// bundled `murmur.yaml` — an artifact pulled from a registry cannot configure itself.
1111    ///
1112    /// Plaintext in a file that is also an audit record: secrets belong in `${VAR}` references
1113    /// and the credential-stripping path, not here.
1114    pub config: Option<serde_norway::Value>,
1115    /// The third-party upstream this artifact reaches through the credential gateway, and the
1116    /// key the runtime presents there on its behalf, declared via `gateway:` on this entry.
1117    ///
1118    /// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
1119    /// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
1120    /// [`Self::capabilities`]. A `runtime: driver` entry may carry it only when it is the
1121    /// configured `transport: http` driver, whose gateway is the metered inference gateway.
1122    ///
1123    /// Operator-sourced only, and never read from the artifact's own bundled `murmur.yaml`: the
1124    /// artifact says how the key is presented (`upstream_auth:`), never where it goes.
1125    pub gateway: Option<ArtifactGateway>,
1126}
1127
1128impl RuntimeArtifact {
1129    /// The name a staged session knows this artifact by: the bare part of its full name, or a
1130    /// `source:` entry's own name. It is the model-facing tool name and the directory under
1131    /// `<runtime dir>/tools/`.
1132    #[must_use]
1133    pub fn tool_name(&self) -> &str {
1134        tool_name_of(&self.name)
1135    }
1136
1137    /// The full name this entry resolves by. A `source:` entry, named by its bare name, has none.
1138    pub fn artifact_name(&self) -> Result<ArtifactName, ArtifactNameError> {
1139        ArtifactName::parse(&self.name)
1140    }
1141}
1142
1143/// The bare part of an artifact reference as a manifest writes it: the text after the `/` of a
1144/// full name, or the whole of a bare one.
1145#[must_use]
1146pub fn tool_name_of(name: &str) -> &str {
1147    name.split_once('/').map_or(name, |(_, bare)| bare)
1148}
1149
1150/// One artifact's credential gateway, as the operator declared it on the artifact's entry.
1151///
1152/// Debug never prints a literal key: [`ApiKeyReference`]'s own `Debug` redacts it.
1153#[derive(Debug, Clone, PartialEq, Eq)]
1154pub struct ArtifactGateway {
1155    /// Absolute `http`/`https` upstream URL with a host and no query or fragment. Plain `http`
1156    /// only to `localhost` or a loopback IP literal.
1157    pub endpoint: String,
1158    /// The key presented upstream. `None` for a keyless gateway, or when the manifest was parsed
1159    /// with [`RuntimeManifest::from_yaml_str_without_secrets`].
1160    pub api_key: Option<ApiKeyReference>,
1161    /// `keyless: true` as the operator wrote it: this upstream takes no key, and the gateway
1162    /// forwards with no credential header. Exclusive with `api_key`; kept as written in both
1163    /// secret modes. Never derived from the endpoint's address.
1164    pub keyless: bool,
1165}
1166
1167#[derive(Debug, Clone, PartialEq, Eq)]
1168pub struct InferenceDriver {
1169    pub artifact: String,
1170    pub config: Option<String>,
1171}
1172
1173/// The name of the driver choice `inference.driver.artifact` with `inference.model` forms. Reserved:
1174/// no `inference.alternates` entry may take it.
1175pub const PRIMARY_DRIVER_CHOICE: &str = "primary";
1176
1177/// One `inference.alternates` entry: a named pair of a declared driver and a model that the
1178/// agent loop may be switched to while the capsule runs.
1179///
1180/// Takes no `driver.config` of its own: `inference.driver.config` reaches every declared driver,
1181/// and per-driver settings are that driver's `artifacts[].config`.
1182#[derive(Debug, Clone, PartialEq, Eq)]
1183pub struct InferenceAlternate {
1184    /// `[a-z][a-z0-9-]{0,62}`, unique among the alternates, and never [`PRIMARY_DRIVER_CHOICE`].
1185    pub name: String,
1186    /// The model string the driver sends, never empty.
1187    pub model: String,
1188    /// `driver.artifact`: an `artifacts:` entry with `runtime: driver`, which carries its own
1189    /// `gateway:` unless it is the primary's own driver.
1190    pub driver: String,
1191}
1192
1193/// What a `gateway.api_key` names, as the manifest wrote it. Parsing never resolves it: the
1194/// runtime looks the value up at staging, where it knows where credentials are kept.
1195#[derive(Clone, PartialEq, Eq)]
1196pub enum ApiKeyReference {
1197    /// `${NAME}`: a credential name, looked up in the global config's `credentials:` map and
1198    /// then in the launching environment.
1199    Environment(String),
1200    /// Any other non-empty value, used verbatim as the key.
1201    Literal(String),
1202}
1203
1204impl std::fmt::Debug for ApiKeyReference {
1205    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1206        match self {
1207            Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
1208            Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
1209        }
1210    }
1211}
1212
1213#[derive(Debug, Clone, PartialEq, Eq)]
1214pub struct InferenceConfig {
1215    pub transport: String,
1216    pub model: String,
1217    /// WASM driver artifact. Always present: the inference driver under `transport: http`, the
1218    /// process driver under `transport: process`.
1219    pub driver: Option<InferenceDriver>,
1220    /// The executable to run instead of the one the process driver's `describe()` names. Absent
1221    /// for `transport: http`, and optional under `transport: process`, where the driver names its
1222    /// own binary.
1223    pub command: Option<String>,
1224    pub compaction: Option<CompactionConfig>,
1225    pub system_prompt: Option<String>,
1226    pub system_prompt_file: Option<String>,
1227    /// Name of a skill artifact declared in `artifacts:` whose `SKILL.md` body is used as the
1228    /// system prompt. Mutually exclusive with `system_prompt` and `system_prompt_file`. The skill is
1229    /// excluded from the callable tool inventory (it's already in the system prompt).
1230    pub system_prompt_artifact: Option<String>,
1231    /// `inference.instruction_files`: work-folder files appended to the system prompt at launch,
1232    /// each as one labelled block after the manifest's own prompt, as the manifest wrote them and
1233    /// in its order. Every entry is relative and has no `..` component. `None` reads nothing and
1234    /// leaves the system prompt as the prompt keys alone make it; `Some` of an empty list also
1235    /// reads nothing, and the trace records the empty list.
1236    pub instruction_files: Option<Vec<String>>,
1237    /// `inference.max_turns`: the most inference turns one task may take, across its reopens and
1238    /// continuations, enforced as a hard ceiling by the runtime. `None` sets no cap.
1239    pub max_turns: Option<u32>,
1240    /// Maximum output tokens the model may generate per turn (`max_tokens` in the driver wire
1241    /// payload). `None` means the manifest didn't set it, and each driver choice's cap is the
1242    /// `max_output_tokens` its driver's `models:` table declares for its model.
1243    /// `transport: http` only — rejected at parse time under `transport: process`.
1244    ///
1245    /// Unrelated to [`ContextConfig::max_tokens`], which is the session-wide token budget that
1246    /// drives compaction. This one is a per-turn *output* cap.
1247    pub max_tokens: Option<u32>,
1248    /// `inference.max_session_tokens`: the most input plus output tokens this session may spend.
1249    /// `None` sets no session ceiling.
1250    ///
1251    /// Valid on both transports, measured differently on each: under `transport: http` it counts
1252    /// the runtime's own measurement of every driver call, and under `transport: process` the
1253    /// counts the harness's driver reports for each turn. Setting it against a process driver
1254    /// that reports no usage is refused at load.
1255    pub max_session_tokens: Option<u64>,
1256    /// `inference.tool_refresh`: when an artifact installed mid-session reaches the tool array
1257    /// the agent loop sends the model. Always [`ToolRefresh::Compaction`] under
1258    /// `transport: process`, where the key is refused at parse time and nothing reads it.
1259    pub tool_refresh: ToolRefresh,
1260    /// `inference.alternates`: the other driver choices the agent loop may be switched to, in
1261    /// manifest order. Empty under `transport: process`, where the key is refused at parse time,
1262    /// and for every capsule that declares none.
1263    pub alternates: Vec<InferenceAlternate>,
1264    /// `inference.retry`: how a driver call that failed transiently is made again. Always
1265    /// [`InferenceRetry::default`] under `transport: process`, where the key is refused at parse
1266    /// time and the harness retries its own provider calls.
1267    pub retry: InferenceRetry,
1268}
1269
1270/// `inference.retry`: the bounds on making a `transport: http` driver call again after a
1271/// transient failure. Every key is optional and takes its default when omitted.
1272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1273pub struct InferenceRetry {
1274    /// The most retries one call makes after its first attempt, 0–20. 0 disables retrying.
1275    pub max_retries: u32,
1276    /// The backoff before the first retry, in milliseconds, 1–60000. Each later retry doubles it.
1277    pub initial_delay_ms: u32,
1278    /// The longest backoff, and the longest provider-requested wait the runtime honours, in
1279    /// milliseconds: 1–3600000, and at least `initial_delay_ms`.
1280    pub max_delay_ms: u32,
1281}
1282
1283impl InferenceRetry {
1284    /// The most `max_retries` may be.
1285    pub const MAX_RETRIES: u32 = 20;
1286    /// The most `initial_delay_ms` may be.
1287    pub const MAX_INITIAL_DELAY_MS: u32 = 60_000;
1288    /// The most `max_delay_ms` may be.
1289    pub const MAX_DELAY_MS: u32 = 3_600_000;
1290}
1291
1292impl Default for InferenceRetry {
1293    fn default() -> Self {
1294        Self {
1295            max_retries: 6,
1296            initial_delay_ms: 1_000,
1297            max_delay_ms: 60_000,
1298        }
1299    }
1300}
1301
1302/// `inference.tool_refresh`: the turn boundary at which the http agent loop rebuilds the tool
1303/// array it sends the model, once the session's installed set has changed.
1304///
1305/// The array comes first in every provider's cached prompt prefix, so rebuilding it is a full
1306/// prompt-cache miss on the call that carries it. The start of the next task rebuilds it under
1307/// either value.
1308#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1309pub enum ToolRefresh {
1310    /// Rebuild before the first inference call after a compaction commits a replacement
1311    /// context, whose message history is already a full cache miss.
1312    #[default]
1313    Compaction,
1314    /// Rebuild before the next inference call after the install.
1315    Immediate,
1316}
1317
1318impl ToolRefresh {
1319    /// Every value, in the order refusals name them.
1320    pub const ALL: [ToolRefresh; 2] = [Self::Compaction, Self::Immediate];
1321
1322    /// The value as written in the manifest and in `trace.jsonl`.
1323    #[must_use]
1324    pub fn wire_name(self) -> &'static str {
1325        match self {
1326            Self::Compaction => "compaction",
1327            Self::Immediate => "immediate",
1328        }
1329    }
1330}
1331
1332#[derive(Debug, Clone, PartialEq)]
1333pub struct CompactionConfig {
1334    /// Fraction of context_window at which compaction fires (0.0–1.0]. Default: 0.98.
1335    pub threshold: Option<f32>,
1336    /// Model for compaction. None means use the primary inference model.
1337    pub model: Option<String>,
1338    /// System prompt for compaction. None means the compaction hook picks its own default.
1339    /// Mutually exclusive with `system_prompt_file`.
1340    pub system_prompt: Option<String>,
1341    /// Path to a local file whose contents are used as the compaction system prompt,
1342    /// resolved relative to the manifest directory when the session launches. Mutually
1343    /// exclusive with `system_prompt`.
1344    pub system_prompt_file: Option<String>,
1345    /// When true, every committed compaction appends one JSON line recording the
1346    /// replacement summary to `out/compaction-summaries.jsonl` in the session's records
1347    /// directory.
1348    /// None (the key absent) is equivalent to false: nothing is written.
1349    pub dump_summaries: Option<bool>,
1350    /// Output cap of every `run-inference` call made while a hook serves `on-compaction`, and
1351    /// the amount the spend meter reserves for each. Never 0. None keeps the cap other hook
1352    /// calls get: the primary driver's `models:` ceiling for the model called, else the
1353    /// primary choice's cap.
1354    pub max_tokens: Option<u32>,
1355    /// Compact JSON of `inference.compaction.driver_config`, always an object. Its top-level
1356    /// keys replace or add to those of `inference.driver.config` for the calls `max_tokens`
1357    /// caps, and for no other call.
1358    pub driver_config: Option<String>,
1359}
1360
1361// Threshold values are validated to (0.0, 1.0] at parse time so NaN is impossible.
1362impl Eq for CompactionConfig {}
1363
1364/// `context.seed_budget` when the manifest leaves it out: a tenth of the window.
1365/// Small enough that a seed cannot crowd out the task it precedes, large enough that a
1366/// memory hook has room to say something.
1367pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
1368
1369/// `context.seed_overflow_margin` when the manifest leaves it out. Slack above
1370/// `seed_budget` within which an over-budget seed is trimmed rather than summarized —
1371/// a few tokens over must not buy an inference call.
1372pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
1373
1374#[derive(Debug, Clone, PartialEq)]
1375pub struct ContextConfig {
1376    /// `context.max_tokens`: the context window compaction and the seed budget measure against,
1377    /// for every driver choice. `None` leaves each choice the `context_window` its driver's
1378    /// `models:` table declares for its model, and a choice with neither has no window.
1379    pub max_tokens: Option<u32>,
1380    /// Whether the runtime keeps a durable conversation record for this capsule. `true` unless
1381    /// the manifest says `record: off`; `false` turns the mechanism off entirely, creating
1382    /// nothing under `~/.murmur/conversations/`.
1383    pub record: bool,
1384    /// Directory segment under `~/.murmur/conversations/` this capsule's records live in.
1385    /// `None` means the capsule name. Validated as one path segment by the runtime, on the same
1386    /// terms as `capabilities.state.store`. Inert when [`Self::record`] is `false`.
1387    pub record_store: Option<String>,
1388    /// Fraction of [`Self::max_tokens`] an `on-task-start` hook's `seed-context` may
1389    /// occupy, in `0.0..=1.0`. Always populated; [`DEFAULT_SEED_BUDGET`] when the key is
1390    /// absent. Inert without `max_tokens`: a fraction of no ceiling is no ceiling, and a
1391    /// seed with no ceiling is rejected rather than committed unbounded.
1392    pub seed_budget: f32,
1393    /// Slack above the seed budget, as a fraction of it, within which an over-budget seed
1394    /// is trimmed from its front rather than handed to the compaction hook, in
1395    /// `0.0..=1.0`. Always populated; [`DEFAULT_SEED_OVERFLOW_MARGIN`] when the key is
1396    /// absent.
1397    pub seed_overflow_margin: f32,
1398    /// What bounds this capsule's conversation records. `None` — the `retain:` block was absent
1399    /// — keeps every record whole and forever. Inert when [`Self::record`] is `false`.
1400    pub retain: Option<ContextRetainConfig>,
1401    /// The `context.images` block: how many images a tool may return and the driver is shown.
1402    /// Always populated; [`ContextImagesConfig::default`] when the block is absent.
1403    pub images: ContextImagesConfig,
1404}
1405
1406/// `context.images.keep` when the key is absent.
1407pub const DEFAULT_IMAGES_KEEP: u32 = 5;
1408/// `context.images.max_bytes` when the key is absent: 5 MiB.
1409pub const DEFAULT_IMAGES_MAX_BYTES: u64 = 5 * 1024 * 1024;
1410/// `context.images.max_per_turn` when the key is absent.
1411pub const DEFAULT_IMAGES_MAX_PER_TURN: u32 = 5;
1412
1413/// The `context.images` block: the limits on images a tool returns or an A2A message carries.
1414///
1415/// Every key is optional and at least 1. A capsule with no `context:` block, or no `images:`
1416/// block in it, gets [`Self::default`].
1417#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1418pub struct ContextImagesConfig {
1419    /// How many of the newest images in the conversation the driver receives as bytes. Each older
1420    /// image reaches it as one line naming the image. Read only on `transport: http`, where the
1421    /// runtime holds the conversation.
1422    pub keep: u32,
1423    /// The largest image, in bytes, a tool result or an A2A message may carry.
1424    pub max_bytes: u64,
1425    /// How many images one model turn's tool calls may return between them, and one A2A
1426    /// message may carry. Read only on `transport: http`.
1427    pub max_per_turn: u32,
1428}
1429
1430impl Default for ContextImagesConfig {
1431    fn default() -> Self {
1432        Self {
1433            keep: DEFAULT_IMAGES_KEEP,
1434            max_bytes: DEFAULT_IMAGES_MAX_BYTES,
1435            max_per_turn: DEFAULT_IMAGES_MAX_PER_TURN,
1436        }
1437    }
1438}
1439
1440// Both fractions are validated to 0.0..=1.0 at parse time, so NaN is impossible.
1441impl Eq for ContextConfig {}
1442
1443#[derive(Debug, Clone, PartialEq, Eq)]
1444pub struct ObservabilityConfig {
1445    pub otel_endpoint: Option<String>,
1446    pub eval: Option<EvalConfig>,
1447}
1448
1449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1450pub struct TraceConfig {
1451    /// How much of each turn's driver request this session's trace keeps — resolved from
1452    /// `trace.capture` by [`crate::trace_capture::resolve_trace_capture`].
1453    pub capture: TraceCapture,
1454    /// What bounds the session directories under the workdir. `None` — the `retain:` block was
1455    /// absent — keeps every session forever; there is no default policy, because a mechanism
1456    /// that deletes an operator's traces unless they opt out is the one default-allow in a
1457    /// runtime that is default-deny everywhere else.
1458    pub retain: Option<TraceRetainConfig>,
1459}
1460
1461/// The `trace.retain` block: what bounds the set of session directories beside the running one.
1462///
1463/// Both keys are optional and ANDed — a session survives only if it is inside every limit
1464/// declared. An empty block is a parse error, not a no-op: omitting `retain:` is how a capsule
1465/// says "keep everything".
1466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1467pub struct TraceRetainConfig {
1468    /// Newest session directories to keep, counting the running session itself. `None` means
1469    /// the count is unbounded. Never `Some(0)`: truncating the set to nothing would delete the
1470    /// running session's own trace, so it is refused at parse time.
1471    pub max_sessions: Option<u32>,
1472    /// Age beyond which a session directory is removed, in seconds, measured from the
1473    /// millisecond timestamp inside its own uuid-v7 `ses_` id. `None` means no age limit.
1474    pub max_age_secs: Option<u64>,
1475}
1476
1477/// The `context.retain` block: what bounds this capsule's conversation records.
1478///
1479/// Both keys are optional and ANDed. `max_messages` truncates the front of the record the
1480/// launch opens; `max_age_secs` removes a context directory this capsule owns and has not
1481/// written to inside the window.
1482#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1483pub struct ContextRetainConfig {
1484    /// Messages to keep at the tail of a record. `None` means the record grows without bound.
1485    /// Never `Some(0)`: truncating a record to nothing is `mur conversation rm`, not retention.
1486    pub max_messages: Option<u32>,
1487    /// Age beyond which a record this capsule owns is removed, in seconds, measured from the
1488    /// last write to its `conversation.jsonl`. `None` means no age limit.
1489    pub max_age_secs: Option<u64>,
1490}
1491
1492#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1493pub struct EvalConfig {
1494    #[serde(skip_serializing_if = "Option::is_none")]
1495    pub dataset_id: Option<String>,
1496    pub scorers: Vec<ScorerConfig>,
1497}
1498
1499#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1500#[serde(tag = "type", rename_all = "snake_case")]
1501pub enum ScorerConfig {
1502    ExitOk { name: String },
1503    MaxTurns { name: String, max: u32 },
1504    MaxTokens { name: String, max: u64 },
1505    ToolSequence { name: String, expected: Vec<String> },
1506    LlmJudge { name: String },
1507}
1508
1509#[derive(Debug, Clone, PartialEq, Eq)]
1510pub struct NetworkCapabilities {
1511    pub allow: Vec<String>,
1512    /// Whether the capsule's shell subprocess tree may create `AF_UNIX` sockets. `false` unless
1513    /// the manifest says otherwise: a local daemon socket — `/var/run/docker.sock` above all —
1514    /// is an unmediated path to host root, and the `allow` list above only governs IP
1515    /// destinations, so nothing else in this block constrains it. Declaring `true` is the
1516    /// deliberate, auditable widening for a capsule that genuinely needs a local daemon socket;
1517    /// it is capsule-wide and coarse (a domain, not a per-path allowlist). `AF_NETLINK` and
1518    /// `AF_PACKET` have no corresponding key and are always denied.
1519    pub unix_sockets: bool,
1520}
1521
1522#[derive(Debug, Clone, PartialEq, Eq)]
1523pub struct FilesystemCapabilities {
1524    pub scope: Option<String>,
1525    /// Whether anything written *into* the session workdir may be executed. `false` unless the
1526    /// manifest says otherwise, and that default is the whole point: with it, the workdir's own
1527    /// Landlock `PathBeneath` rule withholds the `Execute` right, so the kernel — evaluating the
1528    /// resolved path itself, with no userspace round trip — refuses to exec a binary the capsule
1529    /// produced, whatever it is named. That is what makes `capabilities.shell.allow` a complete
1530    /// and sound statement rather than a name-matching convention.
1531    ///
1532    /// `true` takes the `Execute` right back for compile-and-run workflows (a capsule that
1533    /// `gcc`/`cargo build`s inside its workdir and then runs the result). The cost is not
1534    /// negotiable and is not hidden: anything the capsule writes into the workdir can then run
1535    /// regardless of `shell.allow`, so the allowlist stops being an enforceable property of that
1536    /// capsule. A capsule declaring it can therefore never achieve
1537    /// [`ContainmentClass::Scoped`] — see `capsule_runtime::containment` — and pairing it with
1538    /// `capabilities.containment: scoped` is refused at launch.
1539    pub workdir_exec: bool,
1540    /// Workdir-relative subtrees the capsule may read but must not write, in the same vocabulary
1541    /// [`Self::scope`] uses. Empty when the key is absent, which is the whole workdir writable —
1542    /// the behaviour of every capsule that declares nothing here.
1543    ///
1544    /// Entries are trimmed and empty ones dropped at parse; path *shape* is judged by the
1545    /// runtime (`capsule_runtime::protected_paths`), exactly as `scope`'s shape is.
1546    pub read_only: Vec<String>,
1547    /// Whether a `runtime: hook` entry's `"."` is the session's records directory's `hooks/`,
1548    /// read-write, in place of a workdir [`Self::scope`], so what the hook writes is outside the
1549    /// workdir the capsule's commands write. `false` when the key is absent. Refused at parse on any other role, in the capsule-wide
1550    /// block, and beside a `scope` on the same entry.
1551    pub records: bool,
1552}
1553
1554/// The `capabilities.task_io` block on a `runtime: hook` artifact entry — the operator's
1555/// grant of the `murmur:task-io/read` host import to that one hook. Recognized nowhere else:
1556/// on any other role, and in the capsule-wide block, it is rejected at parse time rather than
1557/// accepted and silently inert.
1558#[derive(Debug, Clone, PartialEq, Eq)]
1559pub struct TaskIoCapabilities {
1560    /// Whether this hook may read the in-scope task's input and result text. Never inferred:
1561    /// a `task_io:` block that omits `read:` is rejected, on the same terms as
1562    /// `capabilities.shell.interpreter_runtime[].dirs[].list_dir`.
1563    pub read: bool,
1564}
1565
1566/// The `capabilities.conversation` block on a `runtime: hook` artifact entry — the operator's
1567/// grant of the `murmur:conversation/read` host import to that one hook. Rejected at parse time
1568/// on any other artifact role; accepted but inert in the capsule-wide block, which warns
1569/// `W-SEC-016` at staging.
1570#[derive(Debug, Clone, PartialEq, Eq)]
1571pub struct ConversationCapabilities {
1572    /// Whether this hook may read the capsule's durable conversation record. Never inferred: a
1573    /// `conversation:` block that omits `read:` is rejected, exactly as `task_io:` is.
1574    pub read: bool,
1575}
1576
1577/// The `capabilities.plan` block — the operator's grant of the runtime-provided `submit-plan`
1578/// tool to the capsule's agent loop. Read from the capsule-wide block only: an artifact entry
1579/// declaring it grants nothing, because a plan is submitted by the agent and not by an artifact.
1580#[derive(Debug, Clone, PartialEq, Eq)]
1581pub struct PlanCapabilities {
1582    /// Whether this capsule's model may submit a plan for the runtime to schedule. Never
1583    /// inferred: a `plan:` block that omits `submit:` is rejected, exactly as `task_io:` is.
1584    pub submit: bool,
1585}
1586
1587/// The `capabilities.harness` block — the operator's grant of host directories to a
1588/// `transport: process` capsule's harness, beyond what its class gives every command. Read from
1589/// the capsule-wide block only, and valid only on a `transport: process` capsule. Each path a
1590/// process driver's `describe().harness-needs` declares is granted only where an entry here names
1591/// that directory or an ancestor of it, with at least the need's access; what is granted is the
1592/// driver's path at the driver's access, never the wider entry. A host need is covered separately
1593/// by `capabilities.network.allow`. See [`HarnessPathEntry`].
1594#[derive(Debug, Clone, PartialEq, Eq)]
1595pub struct HarnessCapabilities {
1596    /// `capabilities.harness.paths`, in declaration order.
1597    pub paths: Vec<HarnessPathEntry>,
1598}
1599
1600/// One `capabilities.harness.paths` entry: a host directory and what the harness may do there — a
1601/// folder below `~/` or `/`, `read` or `read-write` — declared by the capsule, not the operator,
1602/// and reaching the harness only.
1603#[derive(Debug, Clone, PartialEq, Eq)]
1604pub struct HarnessPathEntry {
1605    /// `~/<rest>` under the home `mur` runs with, or an absolute path. A directory.
1606    pub path: String,
1607    pub access: HarnessPathAccess,
1608}
1609
1610/// What a `capabilities.harness.paths` entry lets the harness do. Required in every entry: there
1611/// is no default.
1612#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1613pub enum HarnessPathAccess {
1614    Read,
1615    ReadWrite,
1616}
1617
1618impl HarnessPathAccess {
1619    /// The spelling in the manifest and in every report.
1620    #[must_use]
1621    pub fn wire_name(self) -> &'static str {
1622        match self {
1623            Self::Read => "read",
1624            Self::ReadWrite => "read-write",
1625        }
1626    }
1627
1628    /// The access `value` spells, or `None` for anything but `read` and `read-write`.
1629    #[must_use]
1630    pub fn parse(value: &str) -> Option<Self> {
1631        match value {
1632            "read" => Some(Self::Read),
1633            "read-write" => Some(Self::ReadWrite),
1634            _ => None,
1635        }
1636    }
1637}
1638
1639/// The `capabilities.install` block — the operator's grant to pull artifacts into a running
1640/// session through `murmur:artifact-manager/manage.pull`. Read from the capsule-wide block only.
1641/// Each entry is an exact artifact name, a `<prefix>*` or `*`, as
1642/// [`crate::install_pattern::validate`] accepts it, kept verbatim in declaration order. At least
1643/// one list is non-empty: omitting the block, not declaring an empty one, is how a capsule grants
1644/// nothing.
1645#[derive(Debug, Clone, PartialEq, Eq)]
1646pub struct InstallCapabilities {
1647    /// Entries admitting skill artifacts (`SKILL.md`, no executable payload).
1648    pub skill: Vec<String>,
1649    /// Entries admitting tool artifacts.
1650    pub tool: Vec<String>,
1651    /// `capabilities.install.from`: where a pull may find what it installs.
1652    pub from: InstallFrom,
1653}
1654
1655/// `capabilities.install.from`: the places a pull is answered from. Absent is
1656/// [`InstallFrom::Stores`].
1657#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1658pub enum InstallFrom {
1659    /// `[stores]`: the project store, then the global store. A pull fetches nothing.
1660    #[default]
1661    Stores,
1662    /// `[stores, sources]`: the stores first, then the GitHub source of the artifact's scope,
1663    /// fetched by the runtime itself and written to no store.
1664    StoresAndSources,
1665}
1666
1667impl InstallFrom {
1668    /// Whether a pull may fetch from a scope's source.
1669    #[must_use]
1670    pub fn sources(self) -> bool {
1671        self == Self::StoresAndSources
1672    }
1673
1674    /// The list as a manifest writes it, without brackets: `stores` or `stores, sources`.
1675    #[must_use]
1676    pub fn as_list(self) -> &'static str {
1677        match self {
1678            Self::Stores => "stores",
1679            Self::StoresAndSources => "stores, sources",
1680        }
1681    }
1682}
1683
1684#[derive(Debug, Clone, PartialEq, Eq)]
1685pub struct ShellCapabilities {
1686    pub allow: Vec<String>,
1687    pub strip_env: Option<Vec<String>>,
1688    pub baseline_env: Option<Vec<String>>,
1689    /// Typed grants that widen an already-allowlisted `allow` binary's Landlock filesystem
1690    /// scope to the exact host directories its import machinery needs *outside* the workdir
1691    /// (a path-based interpreter like CPython, whose stdlib the `DT_NEEDED` closure cannot
1692    /// reach). Empty unless the manifest declares `capabilities.shell.interpreter_runtime`.
1693    /// This can only ever name specific directories with an explicit per-directory
1694    /// enumerability flag — it has no field that expands a whole install prefix.
1695    pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1696    /// Typed staged-runtime grants that name a pinned host runtime tree to bind-mount read-only
1697    /// into a `sealed` capsule's composed root. Empty unless the manifest declares
1698    /// `capabilities.shell.staged_runtime`. Mutually exclusive with [`Self::interpreter_runtime`]
1699    /// per binary: staging the tree *into* the root is what makes widening Landlock *out to* the
1700    /// host unnecessary, so declaring both for one binary is a contradiction, not a layering.
1701    pub staged_runtime: Vec<StagedRuntimeGrant>,
1702}
1703
1704/// One `capabilities.shell.interpreter_runtime` entry: an already-allowlisted binary plus the
1705/// exact host directories outside the workdir its import machinery must reach. Declaring one
1706/// couples the capsule to a specific host interpreter-version layout, so it fires `W-SEC-009`
1707/// at staging; it exists only to bridge until the staged runtime bind-mount ships.
1708#[derive(Debug, Clone, PartialEq, Eq)]
1709pub struct InterpreterRuntimeGrant {
1710    /// A binary that MUST already appear in this same block's `allow`. This mechanism can only
1711    /// narrow filesystem access alongside an exec grant that already exists — it never itself
1712    /// grants exec.
1713    pub binary: String,
1714    /// The host directories to grant. Never empty (a grant with no directories is rejected at
1715    /// parse time).
1716    pub dirs: Vec<InterpreterRuntimeDir>,
1717}
1718
1719/// One directory inside an [`InterpreterRuntimeGrant`], with its author-declared enumerability.
1720#[derive(Debug, Clone, PartialEq, Eq)]
1721pub struct InterpreterRuntimeDir {
1722    /// An absolute host path (must start with `/`) outside the workdir.
1723    pub path: String,
1724    /// `true` grants `Execute + ReadFile + ReadDir` — the directory's own entries can be
1725    /// enumerated (what CPython's `FileFinder` needs for a `sys.path` entry). `false` grants
1726    /// `Execute + ReadFile` only — files inside can still be opened by exact name, but the
1727    /// directory itself cannot be listed. Never inferred: the author must write it explicitly.
1728    pub list_dir: bool,
1729}
1730
1731/// One `capabilities.shell.staged_runtime` entry: an already-allowlisted binary, the absolute host
1732/// path of a pinned runtime tree that already exists on the launch host, and the `pin` string
1733/// identifying which build that tree is.
1734///
1735/// The mechanism this describes is the inverse of [`InterpreterRuntimeGrant`]. That one widens a
1736/// capsule's Landlock scope *outwards* so an interpreter can reach its stdlib where the host
1737/// happens to keep it — which couples the capsule to one host's directory layout and is why it
1738/// fires `W-SEC-009`. This one moves the tree *inwards*: the composed root of a `sealed` capsule
1739/// carries the runtime at the same absolute path, bind-mounted read-only, so nothing outside the
1740/// root has to stay reachable. Declaring it therefore requires an effective `sealed` floor — there
1741/// is no composed root to stage into below that — and it is refused alongside an
1742/// `interpreter_runtime` grant for the same binary.
1743///
1744/// `source_path` is deliberately a path to an *already-pinned tree* (a vendored toolchain
1745/// directory, a baked-in conda env), never a bare install prefix guessed at random: the runtime
1746/// does not resolve, discover or version-sniff it.
1747#[derive(Debug, Clone, PartialEq, Eq)]
1748pub struct StagedRuntimeGrant {
1749    /// A binary that MUST already appear in this same block's `allow`. Like
1750    /// [`InterpreterRuntimeGrant::binary`], this mechanism never itself grants exec — it only says
1751    /// where the runtime behind an existing exec grant comes from.
1752    pub binary: String,
1753    /// Absolute host path of the runtime tree to stage. Bind-mounted read-only at this same
1754    /// absolute path inside the composed root, so `sys.prefix`, shebangs and anything a previous
1755    /// turn recorded keep resolving.
1756    pub source_path: String,
1757    /// Non-empty, explicit identifier of *which build* the tree at `source_path` is. Never
1758    /// inferred from the tree's contents: it exists so a human can compare the declared pin across
1759    /// two hosts and confirm the same interpreter build shipped to both. The runtime treats it as
1760    /// an opaque string and does not parse it.
1761    pub pin: String,
1762}
1763
1764#[derive(Debug, Clone, PartialEq, Eq)]
1765pub struct SpawnCapabilities {
1766    pub allow: Vec<String>,
1767    /// `capabilities.spawn.workdir_snapshot`: the entries of [`Self::allow`] whose every
1768    /// delegation starts in a copy of this capsule's work folder as it stood when the delegation
1769    /// was made, rather than in an empty one. Full names, each also in `allow`; empty when the
1770    /// key is absent.
1771    pub workdir_snapshot: Vec<String>,
1772}
1773
1774/// Host environment variables a WASM guest may observe, from `capabilities.env`.
1775///
1776/// An empty `allow` is legitimate (a declared-but-empty block is a no-op), so unlike
1777/// `capabilities.shell.allow` this is not rejected at parse time.
1778#[derive(Debug, Clone, PartialEq, Eq, Default)]
1779pub struct EnvCapabilities {
1780    pub allow: Vec<String>,
1781}
1782
1783/// Per-guest execution limits from `capabilities.limits`.
1784///
1785/// Every field is `Option` because this type carries only what the manifest actually
1786/// declared — an omitted field stays `None` here and the runtime substitutes its own
1787/// default (see `capsule_runtime::limits::ExecutionLimits`). Keeping the "absent" state
1788/// distinct from "explicitly set" is what lets the runtime own the defaults in one place
1789/// instead of duplicating them into the manifest parser.
1790#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1791pub struct ResourceLimits {
1792    /// Cap on a guest's linear-memory growth, in bytes.
1793    pub memory_bytes: Option<usize>,
1794    /// Cap on a guest's table growth, in elements.
1795    pub table_elements: Option<usize>,
1796    /// Cap on the number of instances a single store may create.
1797    pub instances: Option<usize>,
1798    /// Wall-clock budget for a single guest invocation, in seconds.
1799    pub deadline_seconds: Option<u64>,
1800}
1801
1802/// Host-process (OS-level) resource bounds from `capabilities.resources`.
1803///
1804/// Distinct from [`ResourceLimits`] (`capabilities.limits`) in both mechanism and subject:
1805/// that block bounds a WASM *guest* inside its wasmtime store, this one bounds every OS
1806/// *subprocess* the runtime spawns — `rlimit(2)` ceilings applied before `execve`, a Linux
1807/// cgroup v2 scope around the whole process tree, and a periodic workdir-size check. A capsule
1808/// that cannot escape containment can still wedge its host by forking, allocating, or writing
1809/// without bound; this is the block that stops that.
1810///
1811/// Every field is `Option` for the same reason [`ResourceLimits`]'s are: this type carries only
1812/// what the manifest actually declared, and the runtime substitutes its own default for
1813/// anything omitted (see `capsule_runtime::resources::HostResourceLimits`). An omitted field —
1814/// or an omitted block — means defaults, never "unlimited".
1815#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1816pub struct ResourceCapabilities {
1817    /// `RLIMIT_NPROC` headroom for a spawned subprocess that has no cgroup scope: the uid's task
1818    /// count at that spawn plus this value, counted in threads on Linux and processes on macOS.
1819    /// No effect on Linux when the subprocess tree has a cgroup scope, which `cgroup_pids_max`
1820    /// bounds instead.
1821    pub max_processes: Option<u64>,
1822    /// `RLIMIT_NOFILE` hard ceiling on each spawned subprocess.
1823    pub max_open_files: Option<u64>,
1824    /// `RLIMIT_FSIZE` hard ceiling, in bytes, on each spawned subprocess.
1825    pub max_file_size_bytes: Option<u64>,
1826    /// CPU time, in CPU-seconds, after which `RLIMIT_CPU` sends each spawned subprocess
1827    /// `SIGXCPU`, with `SIGKILL` one second later.
1828    pub cpu_seconds: Option<u64>,
1829    /// `RLIMIT_AS` (Linux) / `RLIMIT_DATA` (macOS) hard ceiling, in bytes, on each spawned
1830    /// subprocess.
1831    pub memory_bytes: Option<u64>,
1832    /// cgroup v2 `memory.max`, in bytes — aggregate across the whole subprocess tree. Linux only.
1833    pub cgroup_memory_bytes: Option<u64>,
1834    /// cgroup v2 `pids.max` — aggregate across the whole subprocess tree. Linux only.
1835    pub cgroup_pids_max: Option<u64>,
1836    /// cgroup v2 `cpu.max` quota as a percentage of one core (200 = two cores). Linux only.
1837    pub cgroup_cpu_percent: Option<u32>,
1838    /// cgroup v2 `io.max` read+write bytes/sec on the workdir's backing device. Linux only,
1839    /// best-effort (the backing device cannot always be resolved).
1840    pub cgroup_io_bytes_per_sec: Option<u64>,
1841    /// Ceiling on total workdir size, in bytes, enforced by a periodic check on every platform.
1842    pub workdir_max_bytes: Option<u64>,
1843}
1844
1845/// Durable, capsule-scoped state for one tool, driver or hook: a host directory outside every
1846/// session workdir, preopened into the guest as `state/`.
1847///
1848/// Absent is deny — no declaration means no second preopen and no directory anywhere. The store
1849/// is keyed by capsule rather than by workdir precisely so it survives a launch that gets a fresh
1850/// session workdir, and so two capsules launched in the same directory never share one.
1851#[derive(Debug, Clone, PartialEq, Eq, Default)]
1852pub struct StateCapabilities {
1853    /// Directory name under `~/.murmur/state/`. `None` means "use the capsule name", which is
1854    /// what the overwhelming majority of declarations want.
1855    ///
1856    /// Read from the capsule operator's own manifest entry and never from the artifact's bundled
1857    /// manifest, so a registry-pulled tool cannot claim a store that already exists. The *shape*
1858    /// of the name is not checked here — a store name is a runtime concern, refused as
1859    /// `E-CAP-009`, exactly as `capabilities.filesystem.scope`'s shape is refused as `E-CAP-002`.
1860    pub store: Option<String>,
1861}
1862
1863// ── Containment class ─────────────────────────────────────────────────────────
1864
1865/// How strongly the host must contain a capsule's *subprocess* tree, declared as a floor
1866/// by the operator and satisfied (or not) by the host's kernel.
1867///
1868/// Declaration order is enforcement order — the `Ord` derive is load-bearing, so variants
1869/// must stay listed weakest-first. Combining floors from several sources is `max`, never
1870/// `min` (see [`effective_containment_floor`]): no source can lower what another raised.
1871///
1872/// The class an operator *declares* is a requirement. The class a host actually *achieves*
1873/// is derived only from a live kernel probe (`capsule_runtime::containment`), never from
1874/// this declaration.
1875#[derive(
1876    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1877)]
1878#[serde(rename_all = "snake_case")]
1879pub enum ContainmentClass {
1880    /// No containment at all: subprocesses run as the operator, with their `HOME`, their
1881    /// whole environment, the host filesystem and network, and no `no_new_privs`, so `sudo`
1882    /// works wherever the operator's sudoers allows it. Only the operator chooses it, with
1883    /// `mur run --unconfined` or `containment: unconfined` in `~/.murmur/config.yaml`; a
1884    /// manifest, a project config and `--containment` cannot name it, so it is absent from
1885    /// [`ContainmentClass::DECLARABLE`]. It meets no declared floor.
1886    Unconfined,
1887    /// No kernel mechanism required. Capability declarations are honored by the runtime's
1888    /// own dispatch layer and by convention; anything the host cannot mediate is warned
1889    /// about, not refused. Every host satisfies this, including macOS. The default.
1890    #[default]
1891    Advisory,
1892    /// Landlock filesystem mediation + seccomp exec/network allowlisting over the *host*
1893    /// filesystem: every path outside the workdir must be an explicit manifest grant.
1894    /// Requires Linux 5.13+ with a usable Landlock ABI.
1895    Scoped,
1896    /// A private filesystem root: mount-namespace + `pivot_root` isolation onto a composed
1897    /// root, so the host filesystem is not merely mediated but absent. Achievable on an
1898    /// uncontainerised Linux host with a usable Landlock ABI, unprivileged user namespaces,
1899    /// and — where AppArmor's `restrict_unprivileged_userns` is active — the shipped
1900    /// `mur-sealed` profile loaded. A host that cannot back it refuses the launch with a
1901    /// mechanism-specific reason rather than silently degrading to `Scoped` or `Advisory`.
1902    /// See `docs/content/reference/sealed-containment-manual-verification.md`.
1903    Sealed,
1904}
1905
1906impl ContainmentClass {
1907    /// The lowercase wire name, identical in the manifest, the workspace config, the
1908    /// `--containment` flag, `trace.jsonl`, and `--explain-scope` output.
1909    pub fn as_str(self) -> &'static str {
1910        match self {
1911            Self::Unconfined => "unconfined",
1912            Self::Advisory => "advisory",
1913            Self::Scoped => "scoped",
1914            Self::Sealed => "sealed",
1915        }
1916    }
1917
1918    /// Every value, weakest first.
1919    pub const ALL: [ContainmentClass; 4] =
1920        [Self::Unconfined, Self::Advisory, Self::Scoped, Self::Sealed];
1921
1922    /// The classes a manifest, a project config or `--containment` may name, weakest first —
1923    /// the single source for the "must be one of: …" text.
1924    pub const DECLARABLE: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1925}
1926
1927impl std::fmt::Display for ContainmentClass {
1928    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1929        f.write_str(self.as_str())
1930    }
1931}
1932
1933/// A string that is not a containment class name.
1934#[derive(Debug, Clone, PartialEq, Eq)]
1935pub struct ParseContainmentClassError {
1936    pub value: String,
1937}
1938
1939impl std::fmt::Display for ParseContainmentClassError {
1940    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1941        let names: Vec<&str> = ContainmentClass::DECLARABLE
1942            .iter()
1943            .map(|class| class.as_str())
1944            .collect();
1945        write!(
1946            f,
1947            "must be one of: {}; got '{}'",
1948            names.join(", "),
1949            self.value
1950        )
1951    }
1952}
1953
1954impl std::error::Error for ParseContainmentClassError {}
1955
1956impl std::str::FromStr for ContainmentClass {
1957    type Err = ParseContainmentClassError;
1958
1959    fn from_str(value: &str) -> Result<Self, Self::Err> {
1960        match value.trim() {
1961            "unconfined" => Ok(Self::Unconfined),
1962            "advisory" => Ok(Self::Advisory),
1963            "scoped" => Ok(Self::Scoped),
1964            "sealed" => Ok(Self::Sealed),
1965            other => Err(ParseContainmentClassError {
1966                value: other.to_string(),
1967            }),
1968        }
1969    }
1970}
1971
1972/// Why a manifest cannot declare `capabilities.containment: unconfined`.
1973pub const UNCONFINED_NOT_DECLARABLE: &str = "unconfined is not a declarable floor; only the operator chooses it, with `mur run --unconfined` or `containment: unconfined` in ~/.murmur/config.yaml";
1974
1975/// The one floor a single `mur run` invocation must clear: the *strongest* class any source
1976/// asked for, defaulting to [`ContainmentClass::Advisory`] when no source declares one.
1977///
1978/// Monotonic by construction — an absent source does not participate, and no present source
1979/// can lower what another raised. Argument order carries no precedence. The inputs are
1980/// declarable classes; the unconfined stance is resolved before a floor is computed, so it
1981/// never reaches this function.
1982pub fn effective_containment_floor(
1983    workspace_default: Option<ContainmentClass>,
1984    manifest_declared: Option<ContainmentClass>,
1985    cli_flag: Option<ContainmentClass>,
1986) -> ContainmentClass {
1987    [workspace_default, manifest_declared, cli_flag]
1988        .into_iter()
1989        .flatten()
1990        .max()
1991        .unwrap_or_default()
1992}
1993
1994#[derive(Debug, Clone, PartialEq, Eq)]
1995pub struct Capabilities {
1996    pub network: Option<NetworkCapabilities>,
1997    /// Which peers this capsule may redeem a peer-file handle against. `None` means no
1998    /// `fetch-peer-file` tool exists and nothing can be fetched — absent is deny.
1999    pub peer_fetch: Option<PeerFetchCapabilities>,
2000    pub filesystem: Option<FilesystemCapabilities>,
2001    pub shell: Option<ShellCapabilities>,
2002    pub spawn: Option<SpawnCapabilities>,
2003    pub env: Option<EnvCapabilities>,
2004    pub limits: Option<ResourceLimits>,
2005    pub resources: Option<ResourceCapabilities>,
2006    /// Durable state store for this artifact. `None` — the overwhelmingly common case — is deny:
2007    /// no `state/` preopen, and no directory created anywhere. See [`StateCapabilities`].
2008    pub state: Option<StateCapabilities>,
2009    /// Per-hook grant of the `murmur:task-io/read` host import. Only ever `Some` on a
2010    /// `runtime: hook` artifact entry — see [`TaskIoCapabilities`].
2011    pub task_io: Option<TaskIoCapabilities>,
2012    /// Per-hook grant of the `murmur:conversation/read` host import — see
2013    /// [`ConversationCapabilities`].
2014    pub conversation: Option<ConversationCapabilities>,
2015    /// Capsule-wide grant of the runtime-provided `submit-plan` tool. `None` is deny: the tool's
2016    /// synthetic manifest is not written, so it is absent from the model's inventory entirely.
2017    pub plan: Option<PlanCapabilities>,
2018    /// Capsule-wide grant to pull skills and tools at runtime. `None` is deny: every
2019    /// `manage.pull` is refused. See [`InstallCapabilities`].
2020    pub install: Option<InstallCapabilities>,
2021    /// Capsule-wide grant of host directories to a `transport: process` harness. `None` is deny:
2022    /// the harness is granted no path beyond what its class gives every command. Valid only on a
2023    /// `transport: process` capsule. See [`HarnessCapabilities`].
2024    pub harness: Option<HarnessCapabilities>,
2025    /// Minimum containment class this capsule declares. `None` (the overwhelmingly common
2026    /// case) means the capsule states no requirement and inherits whatever the workspace
2027    /// config or `--containment` asks for, defaulting to `advisory`.
2028    pub containment: Option<ContainmentClass>,
2029}
2030
2031#[derive(Debug, Error)]
2032pub enum RuntimeManifestError {
2033    #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
2034    NotFound(String),
2035    #[error("{0}")]
2036    YamlSyntax(String),
2037    #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
2038    MissingField { field: String },
2039    #[error(
2040        "{}: invalid artifact declaration at index {index}: {message}",
2041        MANIFEST_FILENAME
2042    )]
2043    InvalidArtifact { index: usize, message: String },
2044    #[error(
2045        "{}: invalid inference config for '{field}': {message}",
2046        MANIFEST_FILENAME
2047    )]
2048    InvalidInferenceConfig { field: String, message: String },
2049    #[error(
2050        "{}: invalid capability config for '{field}': {message}",
2051        MANIFEST_FILENAME
2052    )]
2053    InvalidCapabilities { field: String, message: String },
2054    /// A `capabilities.shell.allow` entry that is not one bare binary name. `reason` completes
2055    /// "'<entry>' …".
2056    #[error(
2057        "{}: invalid capability config for 'capabilities.shell.allow[{index}]': '{}' {reason}; \
2058         an entry is a bare binary name",
2059        MANIFEST_FILENAME,
2060        entry.escape_debug()
2061    )]
2062    ShellAllowEntry {
2063        index: usize,
2064        entry: String,
2065        reason: &'static str,
2066    },
2067    #[error(
2068        "{}: invalid exports config for '{field}': {message}",
2069        MANIFEST_FILENAME
2070    )]
2071    InvalidExports { field: String, message: String },
2072    #[error(
2073        "{}: invalid control config for '{field}': {message}",
2074        MANIFEST_FILENAME
2075    )]
2076    InvalidControl { field: String, message: String },
2077    #[error(
2078        "{}: invalid network config for '{field}': {message}",
2079        MANIFEST_FILENAME
2080    )]
2081    InvalidNetworkConfig { field: String, message: String },
2082    /// A credential's `scopes` lists a retired door method name, one of [`RETIRED_DOOR_SCOPES`].
2083    /// `replacement` is the method name that replaces it.
2084    #[error(
2085        "{}: invalid network config for '{field}': '{scope}' is a retired name of a door \
2086         method, and the door answers only the names its card lists",
2087        MANIFEST_FILENAME
2088    )]
2089    RetiredDoorScope {
2090        field: String,
2091        scope: String,
2092        replacement: &'static str,
2093    },
2094    /// `lifecycle.task_acceptance: queue` with `lifecycle.queue_depth: 0`; see
2095    /// [`LifecycleConfig::queue_has_room`].
2096    #[error(
2097        "{}: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves task_acceptance: \
2098         queue no room for a waiting task",
2099        MANIFEST_FILENAME
2100    )]
2101    QueueWithoutRoom,
2102    /// A top-level `mur_version` key, whatever its value. The message names the replacement so a
2103    /// surface that prints only the message still points at it.
2104    #[error(
2105        "{}: 'mur_version' is not a top-level key — the mur release a deploy installs is pinned \
2106         as 'deploy.mur_version'",
2107        MANIFEST_FILENAME
2108    )]
2109    TopLevelMurVersion,
2110    #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
2111    InvalidTraceConfig { field: String, message: String },
2112    /// A `lifecycle` field holds a value the runtime cannot act on; `field` is its dotted path.
2113    #[error(
2114        "{}: invalid lifecycle config for '{field}': {message}",
2115        MANIFEST_FILENAME
2116    )]
2117    InvalidLifecycleConfig { field: String, message: String },
2118    /// A field naming an artifact to resolve does not hold a full `<scope>/<name>`, or a
2119    /// `source:` entry's name is not a bare name. `hint` names the form to write instead.
2120    #[error("{}: invalid artifact name in '{field}': {message}", MANIFEST_FILENAME)]
2121    InvalidArtifactName {
2122        field: String,
2123        message: String,
2124        hint: Option<String>,
2125    },
2126    /// The manifest declares a top-level [`REGISTRY_BLOCK_KEY`].
2127    #[error("{}", REGISTRY_BLOCK_MESSAGE)]
2128    RegistryBlock,
2129    /// The manifest names [`HOST_PATHS_KEY`] at the top level, under `capabilities`, under
2130    /// `capabilities.filesystem`, or under an artifact entry's `capabilities`; `field` is the
2131    /// dotted path. Only the operator's `~/.murmur/config.yaml` lends a host folder.
2132    #[error(
2133        "{}: '{field}' cannot be declared in a manifest — host folders are lent by the operator, \
2134         never asked for by a capsule",
2135        MANIFEST_FILENAME
2136    )]
2137    HostPathsDeclared { field: String },
2138    /// A `gateway:` names an upstream but neither binds a non-blank `api_key` nor declares
2139    /// `keyless: true`. Decided on what the manifest wrote, so it fires identically with and
2140    /// without secret resolution.
2141    #[error(
2142        "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
2143         gateway.endpoint '{endpoint}' but binds no credential",
2144        MANIFEST_FILENAME
2145    )]
2146    GatewayWithoutCredential {
2147        index: usize,
2148        name: String,
2149        endpoint: String,
2150    },
2151    #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
2152    Io {
2153        path: String,
2154        #[source]
2155        source: std::io::Error,
2156    },
2157}
2158
2159#[derive(Debug, Deserialize)]
2160struct RawRuntimeManifest {
2161    name: Option<String>,
2162    version: Option<String>,
2163    #[serde(default)]
2164    artifacts: Vec<RawArtifact>,
2165    #[serde(default)]
2166    capabilities: Option<RawCapabilities>,
2167    #[serde(default)]
2168    inference: Option<RawInferenceConfig>,
2169    #[serde(default)]
2170    context: Option<RawContextConfig>,
2171    #[serde(default)]
2172    observability: Option<RawObservabilityConfig>,
2173    #[serde(default)]
2174    trace: Option<RawTraceConfig>,
2175    #[serde(default)]
2176    network: Option<RawNetworkConfig>,
2177    #[serde(default)]
2178    lifecycle: Option<RawLifecycleConfig>,
2179    #[serde(default)]
2180    exports: Option<RawExports>,
2181    #[serde(default)]
2182    control: Option<RawControl>,
2183    #[serde(default)]
2184    deploy: Option<RawDeploy>,
2185    /// Captured only to refuse it, as [`RuntimeManifestError::TopLevelMurVersion`]: the pin is
2186    /// `deploy.mur_version`. Present-detection rather than a typed value, so a number, a blank
2187    /// string and a bare `mur_version:` are all refused.
2188    #[serde(default, deserialize_with = "deserialize_present")]
2189    mur_version: Option<serde_norway::Value>,
2190    /// Captured only to refuse it. `config:` is delivered to one artifact through that artifact's
2191    /// own grant, so a capsule-wide block reaches nothing; without this field it would be one of
2192    /// the unrecognized top-level keys this manifest silently ignores.
2193    #[serde(default, deserialize_with = "deserialize_present")]
2194    config: Option<serde_norway::Value>,
2195    /// Read by `mur build` and `mur publish` off this same file to select the packaging type.
2196    /// Declared here so a manifest that is both runnable and publishable does not report its own
2197    /// build-side keys as unrecognized; the run path takes no meaning from it and never inspects
2198    /// the value, so any shape parses.
2199    #[serde(default)]
2200    #[allow(dead_code)]
2201    runtime: Option<serde_norway::Value>,
2202    /// Read by `mur build` and `mur publish`, which accept only `wasm`. Ignored on the run path,
2203    /// and untyped for the same reason as `runtime:` above.
2204    #[serde(default)]
2205    #[allow(dead_code)]
2206    implementation: Option<serde_norway::Value>,
2207    /// Read by `mur build` and `mur publish` as the declared packaging type, which overrides the
2208    /// one derived from `runtime:`. Ignored on the run path, and untyped for
2209    /// the same reason as `runtime:` above.
2210    #[serde(default)]
2211    #[allow(dead_code)]
2212    execution: Option<serde_norway::Value>,
2213    /// Read by `mur build` as the list of companion files to pack beside the manifest. Ignored on
2214    /// the run path, and untyped for the same reason as `runtime:` above.
2215    #[serde(default)]
2216    #[allow(dead_code)]
2217    requires_files: Option<serde_norway::Value>,
2218    #[serde(flatten)]
2219    unknown: UnknownKeys,
2220}
2221
2222#[derive(Debug, Deserialize)]
2223struct RawDeploy {
2224    #[serde(default)]
2225    mur_version: Option<String>,
2226    #[serde(flatten)]
2227    unknown: UnknownKeys,
2228}
2229
2230#[derive(Debug, Deserialize)]
2231struct RawControl {
2232    /// Untyped, so a value that is not a list of strings is refused as an
2233    /// [`RuntimeManifestError::InvalidControl`] naming the field rather than as a serde type error
2234    /// naming a line number.
2235    #[serde(default)]
2236    settings: Option<serde_norway::Value>,
2237    #[serde(default)]
2238    secrets: Option<serde_norway::Value>,
2239    #[serde(default)]
2240    agent_settings: Option<serde_norway::Value>,
2241    #[serde(flatten)]
2242    unknown: UnknownKeys,
2243}
2244
2245#[derive(Debug, Deserialize)]
2246struct RawExports {
2247    #[serde(default)]
2248    files: Option<RawFileExport>,
2249    #[serde(default)]
2250    peer_files: Option<RawPeerFilesExport>,
2251    #[serde(default)]
2252    peer_tasks: Option<RawPeerTasksExport>,
2253    #[serde(flatten)]
2254    unknown: UnknownKeys,
2255}
2256
2257#[derive(Debug, Deserialize)]
2258struct RawPeerTasksExport {
2259    // `Option` so an omitted `accept` is distinguishable from an explicit `false`: the omission
2260    // is refused rather than defaulted, because consent is never inferred.
2261    #[serde(default)]
2262    accept: Option<bool>,
2263    #[serde(flatten)]
2264    unknown: UnknownKeys,
2265}
2266
2267#[derive(Debug, Deserialize)]
2268struct RawPeerFilesExport {
2269    #[serde(default)]
2270    root: Option<String>,
2271    /// Untyped for the same reason as [`RawFileExport::max_bytes`]: `30m` is a YAML string and a
2272    /// bare `1800` is a YAML integer, and a wrong-shaped value has to reach the parser to be
2273    /// reported as an [`RuntimeManifestError::InvalidExports`] naming the field.
2274    #[serde(default)]
2275    max_ttl: Option<serde_norway::Value>,
2276    #[serde(default)]
2277    max_bytes: Option<serde_norway::Value>,
2278    #[serde(flatten)]
2279    unknown: UnknownKeys,
2280}
2281
2282#[derive(Debug, Deserialize)]
2283struct RawFileExport {
2284    #[serde(default)]
2285    root: Option<String>,
2286    #[serde(default)]
2287    mode: Option<String>,
2288    /// Deliberately untyped: `max_bytes` accepts both a bare integer and a suffixed string, and
2289    /// a wrong-shaped value has to reach [`parse_exports`] to be reported as an
2290    /// [`RuntimeManifestError::InvalidExports`] naming the field rather than as a serde type
2291    /// error naming a line number.
2292    #[serde(default)]
2293    max_bytes: Option<serde_norway::Value>,
2294    #[serde(flatten)]
2295    unknown: UnknownKeys,
2296}
2297
2298#[derive(Debug, Deserialize)]
2299struct RawLifecycleConfig {
2300    #[serde(default)]
2301    task_acceptance: Option<TaskAcceptance>,
2302    #[serde(default)]
2303    after_task: Option<AfterTask>,
2304    #[serde(default)]
2305    queue_depth: Option<usize>,
2306    #[serde(default)]
2307    input_timeout_secs: Option<u64>,
2308    #[serde(default)]
2309    conversation: Option<ConversationMode>,
2310    #[serde(default)]
2311    max_task_reopens: Option<u32>,
2312    #[serde(default)]
2313    shell_grace_secs: Option<u64>,
2314    #[serde(default)]
2315    delegation_deadline_secs: Option<u64>,
2316    #[serde(default)]
2317    deadline_notice_secs: Option<u64>,
2318    #[serde(default)]
2319    keep_working: Option<RawKeepWorking>,
2320    #[serde(default)]
2321    done_check: Option<RawDoneCheck>,
2322    #[serde(flatten)]
2323    unknown: UnknownKeys,
2324}
2325
2326#[derive(Debug, Deserialize)]
2327struct RawDoneCheck {
2328    #[serde(default)]
2329    command: Option<String>,
2330    #[serde(default)]
2331    limit: Option<u32>,
2332    #[serde(default)]
2333    timeout_secs: Option<u64>,
2334    #[serde(flatten)]
2335    unknown: UnknownKeys,
2336}
2337
2338#[derive(Debug, Deserialize)]
2339struct RawKeepWorking {
2340    #[serde(default)]
2341    message: Option<String>,
2342    #[serde(default)]
2343    limit: Option<u32>,
2344    #[serde(flatten)]
2345    unknown: UnknownKeys,
2346}
2347
2348#[derive(Debug, Deserialize)]
2349struct RawNetworkConfig {
2350    internal_port: Option<u16>,
2351    #[serde(default)]
2352    authentication: Option<RawNetworkAuthentication>,
2353    #[serde(flatten)]
2354    unknown: UnknownKeys,
2355}
2356
2357#[derive(Debug, Deserialize)]
2358struct RawNetworkAuthentication {
2359    #[serde(default)]
2360    scheme: Option<String>,
2361    /// Keyed by credential name. A `BTreeMap`, so the lowered credentials come out sorted by name
2362    /// whatever order the manifest wrote them in.
2363    #[serde(default)]
2364    credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
2365    #[serde(flatten)]
2366    unknown: UnknownKeys,
2367}
2368
2369#[derive(Debug, Deserialize)]
2370struct RawDoorCredential {
2371    /// Untyped, so a value that is not a list of strings is refused as an
2372    /// [`RuntimeManifestError::InvalidNetworkConfig`] naming the field rather than as a serde type
2373    /// error naming a line number.
2374    #[serde(default)]
2375    scopes: Option<serde_norway::Value>,
2376    #[serde(flatten)]
2377    unknown: UnknownKeys,
2378}
2379
2380#[derive(Debug, Deserialize)]
2381struct RawContextConfig {
2382    max_tokens: Option<u32>,
2383    /// `on` | `off`. Kept as a raw `String` so an unrecognized value reports as an invalid
2384    /// value for this key rather than as a serde variant error against the whole block.
2385    #[serde(default)]
2386    record: Option<String>,
2387    #[serde(default)]
2388    record_store: Option<String>,
2389    #[serde(default)]
2390    seed_budget: Option<f32>,
2391    #[serde(default)]
2392    seed_overflow_margin: Option<f32>,
2393    /// Untyped so an empty block, a null block and an unknown key inside it are each reported
2394    /// as an invalid `context.retain` rather than as a serde message about a struct field.
2395    #[serde(default, deserialize_with = "present_yaml_value")]
2396    retain: Option<serde_norway::Value>,
2397    #[serde(default)]
2398    images: Option<RawContextImages>,
2399    #[serde(flatten)]
2400    unknown: UnknownKeys,
2401}
2402
2403/// Untyped values, so `10MB` or `-1` reaches [`parse_context_images`] and is reported as an
2404/// invalid `context.images.*` naming the key rather than as a serde type error.
2405#[derive(Debug, Deserialize)]
2406struct RawContextImages {
2407    #[serde(default)]
2408    keep: Option<serde_norway::Value>,
2409    #[serde(default)]
2410    max_bytes: Option<serde_norway::Value>,
2411    #[serde(default)]
2412    max_per_turn: Option<serde_norway::Value>,
2413    #[serde(flatten)]
2414    unknown: UnknownKeys,
2415}
2416
2417#[derive(Debug, Deserialize)]
2418struct RawObservabilityConfig {
2419    #[serde(default)]
2420    otel_endpoint: Option<String>,
2421    #[serde(default)]
2422    eval: Option<RawEvalConfig>,
2423    #[serde(flatten)]
2424    unknown: UnknownKeys,
2425}
2426
2427#[derive(Debug, Deserialize)]
2428struct RawTraceConfig {
2429    /// Untyped so an unparseable mode reaches [`resolve_trace_capture`] and is reported as a
2430    /// [`RuntimeManifestError::InvalidTraceConfig`] naming the field and the accepted values,
2431    /// rather than as a serde message about an unknown variant.
2432    #[serde(default)]
2433    capture: Option<String>,
2434    /// Untyped for the same reason as `context.retain` — see [`RawContextConfig::retain`].
2435    #[serde(default, deserialize_with = "present_yaml_value")]
2436    retain: Option<serde_norway::Value>,
2437    #[serde(flatten)]
2438    unknown: UnknownKeys,
2439}
2440
2441#[derive(Debug, Deserialize)]
2442struct RawEvalConfig {
2443    #[serde(default)]
2444    dataset_id: Option<String>,
2445    #[serde(default)]
2446    scorers: Vec<RawScorerConfig>,
2447    #[serde(flatten)]
2448    unknown: UnknownKeys,
2449}
2450
2451#[derive(Debug, Deserialize)]
2452struct RawScorerConfig {
2453    #[serde(rename = "type")]
2454    scorer_type: String,
2455    name: Option<String>,
2456    max: Option<u64>,
2457    expected: Option<Vec<String>>,
2458    #[serde(flatten)]
2459    unknown: UnknownKeys,
2460}
2461
2462#[derive(Debug, Deserialize)]
2463struct RawArtifact {
2464    name: Option<String>,
2465    version: Option<String>,
2466    runtime: Option<String>,
2467    #[serde(default)]
2468    source: Option<String>,
2469    #[serde(default)]
2470    local_source: Option<bool>,
2471    #[serde(default)]
2472    prompt_payload: Option<bool>,
2473    #[serde(default)]
2474    capabilities: Option<RawCapabilities>,
2475    #[serde(default)]
2476    on_overflow: Option<String>,
2477    /// Untyped, and deserialized through [`deserialize_present`] rather than plain `Option`, so
2478    /// `config:` written with nothing under it arrives as `Some(Value::Null)` instead of
2479    /// collapsing into the absent case. The two mean different things: absent grants no variable,
2480    /// while an empty declaration is a written statement that carries nothing and is refused.
2481    #[serde(default, deserialize_with = "deserialize_present")]
2482    config: Option<serde_norway::Value>,
2483    /// `Some(None)` is `gateway:` written with nothing under it, refused as a block with no
2484    /// endpoint rather than collapsed into the absent case.
2485    #[serde(default, deserialize_with = "deserialize_present")]
2486    gateway: Option<Option<RawArtifactGateway>>,
2487    #[serde(flatten)]
2488    unknown: UnknownKeys,
2489}
2490
2491#[derive(Debug, Deserialize)]
2492struct RawArtifactGateway {
2493    #[serde(default)]
2494    endpoint: Option<String>,
2495    /// `Some(None)` is `api_key:` written as YAML null: present for the contradiction with
2496    /// `keyless: true`, and no credential for the missing-credential refusal.
2497    #[serde(default, deserialize_with = "deserialize_present")]
2498    api_key: Option<Option<String>>,
2499    /// Untyped, so a non-boolean is refused as an invalid declaration naming the field.
2500    #[serde(default)]
2501    keyless: Option<serde_norway::Value>,
2502    #[serde(flatten)]
2503    unknown: UnknownKeys,
2504}
2505
2506/// Deserialize a present key into `Some(value)` even when its value is YAML null, leaving `None`
2507/// to mean the key was absent and `#[serde(default)]` supplied it.
2508///
2509/// serde's own `Option` impl maps a null value to `None`, which erases exactly the distinction
2510/// `config:` rests on.
2511fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
2512where
2513    T: Deserialize<'de>,
2514    D: serde::Deserializer<'de>,
2515{
2516    T::deserialize(deserializer).map(Some)
2517}
2518
2519#[derive(Debug, Deserialize)]
2520struct RawCapabilities {
2521    #[serde(default)]
2522    network: Option<RawNetworkCapabilities>,
2523    #[serde(default)]
2524    peer_fetch: Option<RawPeerFetchCapabilities>,
2525    #[serde(default)]
2526    filesystem: Option<RawFilesystemCapabilities>,
2527    #[serde(default)]
2528    shell: Option<RawShellCapabilities>,
2529    #[serde(default)]
2530    spawn: Option<RawSpawnCapabilities>,
2531    #[serde(default)]
2532    env: Option<RawEnvCapabilities>,
2533    #[serde(default)]
2534    limits: Option<RawResourceLimits>,
2535    #[serde(default)]
2536    resources: Option<RawResourceCapabilities>,
2537    #[serde(default)]
2538    state: Option<RawStateCapabilities>,
2539    #[serde(default)]
2540    task_io: Option<RawTaskIoCapabilities>,
2541    #[serde(default)]
2542    conversation: Option<RawConversationCapabilities>,
2543    #[serde(default)]
2544    plan: Option<RawPlanCapabilities>,
2545    #[serde(default)]
2546    install: Option<RawInstallCapabilities>,
2547    #[serde(default)]
2548    harness: Option<RawHarnessCapabilities>,
2549    /// Kept as a raw `String` rather than a `ContainmentClass` so a typo reports through
2550    /// `InvalidCapabilities` like every other bad capability value, instead of a bare serde
2551    /// "unknown variant" error attributed to the whole `capabilities:` block.
2552    #[serde(default)]
2553    containment: Option<String>,
2554    #[serde(flatten)]
2555    unknown: UnknownKeys,
2556}
2557
2558#[derive(Debug, Deserialize)]
2559struct RawTaskIoCapabilities {
2560    // `Option` so an omitted `read` is distinguishable from an explicit `false`: the omission
2561    // is rejected outright rather than defaulted, because a capability is never inferred.
2562    #[serde(default)]
2563    read: Option<bool>,
2564    #[serde(flatten)]
2565    unknown: UnknownKeys,
2566}
2567
2568#[derive(Debug, Deserialize)]
2569struct RawConversationCapabilities {
2570    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2571    // than defaulted.
2572    #[serde(default)]
2573    read: Option<bool>,
2574    #[serde(flatten)]
2575    unknown: UnknownKeys,
2576}
2577
2578#[derive(Debug, Deserialize)]
2579struct RawPlanCapabilities {
2580    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2581    // than defaulted.
2582    #[serde(default)]
2583    submit: Option<bool>,
2584    #[serde(flatten)]
2585    unknown: UnknownKeys,
2586}
2587
2588#[derive(Debug, Deserialize)]
2589struct RawInstallCapabilities {
2590    #[serde(default)]
2591    skill: Vec<String>,
2592    #[serde(default)]
2593    tool: Vec<String>,
2594    #[serde(default)]
2595    from: Option<Vec<String>>,
2596    #[serde(flatten)]
2597    unknown: UnknownKeys,
2598}
2599
2600#[derive(Debug, Deserialize)]
2601struct RawHarnessCapabilities {
2602    #[serde(default)]
2603    paths: Vec<RawHarnessPathEntry>,
2604    #[serde(flatten)]
2605    unknown: UnknownKeys,
2606}
2607
2608#[derive(Debug, Deserialize)]
2609struct RawHarnessPathEntry {
2610    #[serde(default)]
2611    path: Option<String>,
2612    #[serde(default)]
2613    access: Option<String>,
2614    #[serde(flatten)]
2615    unknown: UnknownKeys,
2616}
2617
2618#[derive(Debug, Deserialize)]
2619struct RawSpawnCapabilities {
2620    #[serde(default)]
2621    allow: Vec<String>,
2622    #[serde(default)]
2623    workdir_snapshot: Vec<String>,
2624    #[serde(flatten)]
2625    unknown: UnknownKeys,
2626}
2627
2628#[derive(Debug, Deserialize)]
2629struct RawEnvCapabilities {
2630    #[serde(default)]
2631    allow: Vec<String>,
2632    #[serde(flatten)]
2633    unknown: UnknownKeys,
2634}
2635
2636#[derive(Debug, Deserialize)]
2637struct RawResourceLimits {
2638    #[serde(default)]
2639    memory_bytes: Option<usize>,
2640    #[serde(default)]
2641    table_elements: Option<usize>,
2642    #[serde(default)]
2643    instances: Option<usize>,
2644    #[serde(default)]
2645    deadline_seconds: Option<u64>,
2646    #[serde(flatten)]
2647    unknown: UnknownKeys,
2648}
2649
2650#[derive(Debug, Deserialize)]
2651struct RawStateCapabilities {
2652    #[serde(default)]
2653    store: Option<String>,
2654    #[serde(flatten)]
2655    unknown: UnknownKeys,
2656}
2657
2658#[derive(Debug, Deserialize)]
2659struct RawResourceCapabilities {
2660    #[serde(default)]
2661    max_processes: Option<u64>,
2662    #[serde(default)]
2663    max_open_files: Option<u64>,
2664    #[serde(default)]
2665    max_file_size_bytes: Option<u64>,
2666    #[serde(default)]
2667    cpu_seconds: Option<u64>,
2668    #[serde(default)]
2669    memory_bytes: Option<u64>,
2670    #[serde(default)]
2671    cgroup_memory_bytes: Option<u64>,
2672    #[serde(default)]
2673    cgroup_pids_max: Option<u64>,
2674    #[serde(default)]
2675    cgroup_cpu_percent: Option<u32>,
2676    #[serde(default)]
2677    cgroup_io_bytes_per_sec: Option<u64>,
2678    #[serde(default)]
2679    workdir_max_bytes: Option<u64>,
2680    #[serde(flatten)]
2681    unknown: UnknownKeys,
2682}
2683
2684#[derive(Debug, Deserialize)]
2685struct RawNetworkCapabilities {
2686    #[serde(default)]
2687    allow: Vec<String>,
2688    #[serde(default)]
2689    unix_sockets: bool,
2690    #[serde(flatten)]
2691    unknown: UnknownKeys,
2692}
2693
2694#[derive(Debug, Deserialize)]
2695struct RawPeerFetchCapabilities {
2696    #[serde(default)]
2697    allow: Vec<String>,
2698    #[serde(flatten)]
2699    unknown: UnknownKeys,
2700}
2701
2702#[derive(Debug, Deserialize)]
2703struct RawFilesystemCapabilities {
2704    #[serde(default)]
2705    scope: Option<String>,
2706    /// Same shape and defaulting convention as `RawNetworkCapabilities::unix_sockets`: a plain
2707    /// `bool` that is always present after parsing and defaults to `false`, never an `Option`.
2708    /// "The key is absent" and "the key says `false`" are the same declaration.
2709    #[serde(default)]
2710    workdir_exec: bool,
2711    /// Always a list after parsing, never an `Option`: an absent key and an empty list are the
2712    /// same declaration — nothing is protected.
2713    #[serde(default)]
2714    read_only: Vec<String>,
2715    /// Same defaulting convention as `workdir_exec`.
2716    #[serde(default)]
2717    records: bool,
2718    #[serde(flatten)]
2719    unknown: UnknownKeys,
2720}
2721
2722#[derive(Debug, Deserialize)]
2723struct RawShellCapabilities {
2724    #[serde(default)]
2725    allow: Vec<String>,
2726    #[serde(default)]
2727    strip_env: Option<Vec<String>>,
2728    #[serde(default)]
2729    baseline_env: Option<Vec<String>>,
2730    #[serde(default)]
2731    interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
2732    #[serde(default)]
2733    staged_runtime: Vec<RawStagedRuntimeGrant>,
2734    #[serde(flatten)]
2735    unknown: UnknownKeys,
2736}
2737
2738#[derive(Debug, Deserialize)]
2739struct RawInterpreterRuntimeGrant {
2740    #[serde(default)]
2741    binary: Option<String>,
2742    #[serde(default)]
2743    dirs: Vec<RawInterpreterRuntimeDir>,
2744    #[serde(flatten)]
2745    unknown: UnknownKeys,
2746}
2747
2748#[derive(Debug, Deserialize)]
2749struct RawInterpreterRuntimeDir {
2750    #[serde(default)]
2751    path: Option<String>,
2752    // `Option` so an omitted `list_dir` is distinguishable from an explicit `false`: the
2753    // omission is rejected outright rather than defaulted, because enumerability is never
2754    // inferred.
2755    #[serde(default)]
2756    list_dir: Option<bool>,
2757    #[serde(flatten)]
2758    unknown: UnknownKeys,
2759}
2760
2761#[derive(Debug, Deserialize)]
2762struct RawStagedRuntimeGrant {
2763    #[serde(default)]
2764    binary: Option<String>,
2765    #[serde(default)]
2766    source_path: Option<String>,
2767    // `Option` so an omitted `pin` reaches `parse_staged_runtime` and is rejected there with a
2768    // field-naming message, rather than defaulting to an empty string that would silently mean
2769    // "unpinned" — the one thing this field exists to make impossible.
2770    #[serde(default)]
2771    pin: Option<String>,
2772    #[serde(flatten)]
2773    unknown: UnknownKeys,
2774}
2775
2776#[derive(Debug, Deserialize)]
2777struct RawInferenceConfig {
2778    transport: Option<String>,
2779    /// Removed: the driver's upstream is `gateway.endpoint` on its artifact entry. Deserialized
2780    /// only so `parse_inference` can refuse it by name, naming where it moved.
2781    #[serde(default, deserialize_with = "deserialize_present")]
2782    endpoint: Option<serde_norway::Value>,
2783    model: Option<String>,
2784    /// Removed: the driver's key is `gateway.api_key` on its artifact entry. Deserialized only so
2785    /// `parse_inference` can refuse it by name, naming where it moved.
2786    #[serde(default, deserialize_with = "deserialize_present")]
2787    api_key: Option<serde_norway::Value>,
2788    #[serde(default)]
2789    command: Option<String>,
2790    #[serde(default)]
2791    system_prompt: Option<String>,
2792    #[serde(default)]
2793    system_prompt_file: Option<String>,
2794    #[serde(default)]
2795    system_prompt_artifact: Option<String>,
2796    #[serde(default)]
2797    instruction_files: Option<Vec<String>>,
2798    #[serde(default)]
2799    driver: Option<RawInferenceDriver>,
2800    #[serde(default)]
2801    compaction: Option<RawCompactionConfig>,
2802    #[serde(default)]
2803    max_turns: Option<u32>,
2804    /// The `inference` spelling of the reopen budget is not a live field. It is deserialized
2805    /// only so `parse_inference` can refuse it; without it serde would ignore the key.
2806    #[serde(default)]
2807    max_task_reopens: Option<u32>,
2808    #[serde(default)]
2809    max_tokens: Option<u32>,
2810    #[serde(default)]
2811    max_session_tokens: Option<u64>,
2812    #[serde(default)]
2813    tool_refresh: Option<String>,
2814    #[serde(default)]
2815    alternates: Option<Vec<RawInferenceAlternate>>,
2816    #[serde(default)]
2817    retry: Option<RawInferenceRetry>,
2818    #[serde(flatten)]
2819    unknown: UnknownKeys,
2820}
2821
2822#[derive(Debug, Deserialize)]
2823struct RawInferenceRetry {
2824    #[serde(default)]
2825    max_retries: Option<u32>,
2826    #[serde(default)]
2827    initial_delay_ms: Option<u32>,
2828    #[serde(default)]
2829    max_delay_ms: Option<u32>,
2830    #[serde(flatten)]
2831    unknown: UnknownKeys,
2832}
2833
2834#[derive(Debug, Deserialize)]
2835struct RawInferenceAlternate {
2836    #[serde(default)]
2837    name: Option<String>,
2838    #[serde(default)]
2839    model: Option<String>,
2840    #[serde(default)]
2841    driver: Option<RawInferenceDriver>,
2842    #[serde(flatten)]
2843    unknown: UnknownKeys,
2844}
2845
2846#[derive(Debug, Deserialize)]
2847struct RawCompactionConfig {
2848    threshold: Option<f32>,
2849    model: Option<String>,
2850    system_prompt: Option<String>,
2851    system_prompt_file: Option<String>,
2852    dump_summaries: Option<bool>,
2853    max_tokens: Option<u32>,
2854    #[serde(default)]
2855    driver_config: Option<serde_norway::Value>,
2856    #[serde(flatten)]
2857    unknown: UnknownKeys,
2858}
2859
2860#[derive(Debug, Deserialize)]
2861struct RawInferenceDriver {
2862    artifact: Option<String>,
2863    #[serde(default)]
2864    config: Option<serde_norway::Value>,
2865    #[serde(flatten)]
2866    unknown: UnknownKeys,
2867}
2868
2869/// The keys one `Raw*` block carried that no field of it claims.
2870///
2871/// A `BTreeMap` so the capture order is the manifest's key order sorted, which keeps the
2872/// `W-SEC-019` lines for one manifest identical on every run. The values are discarded: a key this
2873/// build does not recognize is reported by name and by containing block, and printing whatever an
2874/// operator wrote under it would put manifest contents into a diagnostic that has no use for them.
2875type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2876
2877/// A `Raw*` deserialization block that captures the keys this build does not recognize.
2878///
2879/// Implemented for every `Raw*` struct in this file, which is asserted structurally by
2880/// `every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names` rather than left to
2881/// review: a manifest key added without its block learning about it is exactly the defect
2882/// `W-SEC-019` exists to report.
2883trait RawBlock {
2884    /// Every key serde accepts on this block, in declaration order and under the name serde
2885    /// matches (so a `#[serde(rename)]` field appears under its renamed spelling). This is the
2886    /// candidate set [`crate::unknown_manifest_keys::nearest_known_key`] suggests from.
2887    const KNOWN_KEYS: &'static [&'static str];
2888
2889    fn unknown_keys(&self) -> &UnknownKeys;
2890
2891    /// Descends into the blocks this one owns, in declaration order, so the reported paths read
2892    /// down the manifest the way the document does.
2893    ///
2894    /// Defaulted to a no-op, which is correct for a block that owns no other block. A block that
2895    /// does own one must override this: `every_raw_struct_descends_into_the_blocks_it_owns` reads
2896    /// this file's own text and fails, naming the struct and the field, if it does not — an
2897    /// unwalked block reports none of its keys, silently.
2898    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2899        let _ = (path, out);
2900    }
2901}
2902
2903impl RawBlock for RawRuntimeManifest {
2904    const KNOWN_KEYS: &'static [&'static str] = &[
2905        "name",
2906        "version",
2907        "artifacts",
2908        "capabilities",
2909        "inference",
2910        "context",
2911        "observability",
2912        "trace",
2913        "network",
2914        "lifecycle",
2915        "exports",
2916        "control",
2917        "deploy",
2918        "mur_version",
2919        "config",
2920        "runtime",
2921        "implementation",
2922        "execution",
2923        "requires_files",
2924    ];
2925    fn unknown_keys(&self) -> &UnknownKeys {
2926        &self.unknown
2927    }
2928
2929    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2930        let artifacts = child_path(path, "artifacts");
2931        for (index, artifact) in self.artifacts.iter().enumerate() {
2932            collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2933        }
2934        if let Some(capabilities) = &self.capabilities {
2935            collect_block(capabilities, &child_path(path, "capabilities"), out);
2936        }
2937        if let Some(inference) = &self.inference {
2938            collect_block(inference, &child_path(path, "inference"), out);
2939        }
2940        if let Some(context) = &self.context {
2941            collect_block(context, &child_path(path, "context"), out);
2942        }
2943        if let Some(observability) = &self.observability {
2944            collect_block(observability, &child_path(path, "observability"), out);
2945        }
2946        if let Some(trace) = &self.trace {
2947            collect_block(trace, &child_path(path, "trace"), out);
2948        }
2949        if let Some(network) = &self.network {
2950            collect_block(network, &child_path(path, "network"), out);
2951        }
2952        if let Some(lifecycle) = &self.lifecycle {
2953            collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2954        }
2955        if let Some(exports) = &self.exports {
2956            collect_block(exports, &child_path(path, "exports"), out);
2957        }
2958        if let Some(control) = &self.control {
2959            collect_block(control, &child_path(path, "control"), out);
2960        }
2961        if let Some(deploy) = &self.deploy {
2962            collect_block(deploy, &child_path(path, "deploy"), out);
2963        }
2964    }
2965}
2966
2967impl RawBlock for RawDeploy {
2968    const KNOWN_KEYS: &'static [&'static str] = &["mur_version"];
2969    fn unknown_keys(&self) -> &UnknownKeys {
2970        &self.unknown
2971    }
2972}
2973
2974impl RawBlock for RawControl {
2975    const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
2976    fn unknown_keys(&self) -> &UnknownKeys {
2977        &self.unknown
2978    }
2979}
2980
2981impl RawBlock for RawExports {
2982    const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files", "peer_tasks"];
2983    fn unknown_keys(&self) -> &UnknownKeys {
2984        &self.unknown
2985    }
2986
2987    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2988        if let Some(files) = &self.files {
2989            collect_block(files, &child_path(path, "files"), out);
2990        }
2991        if let Some(peer_files) = &self.peer_files {
2992            collect_block(peer_files, &child_path(path, "peer_files"), out);
2993        }
2994        if let Some(peer_tasks) = &self.peer_tasks {
2995            collect_block(peer_tasks, &child_path(path, "peer_tasks"), out);
2996        }
2997    }
2998}
2999
3000impl RawBlock for RawPeerTasksExport {
3001    const KNOWN_KEYS: &'static [&'static str] = &["accept"];
3002    fn unknown_keys(&self) -> &UnknownKeys {
3003        &self.unknown
3004    }
3005}
3006
3007impl RawBlock for RawPeerFilesExport {
3008    const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
3009    fn unknown_keys(&self) -> &UnknownKeys {
3010        &self.unknown
3011    }
3012}
3013
3014impl RawBlock for RawFileExport {
3015    const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
3016    fn unknown_keys(&self) -> &UnknownKeys {
3017        &self.unknown
3018    }
3019}
3020
3021/// Resolves `lifecycle.keep_working`, refusing a missing or blank `message` and a missing or zero
3022/// `limit`. The message keeps the operator's bytes as written: only the blank check trims.
3023fn parse_keep_working(raw: RawKeepWorking) -> Result<KeepWorking, RuntimeManifestError> {
3024    let invalid = |field: &str, message: &str| RuntimeManifestError::InvalidLifecycleConfig {
3025        field: format!("lifecycle.keep_working.{field}"),
3026        message: message.to_string(),
3027    };
3028    let message = match raw.message {
3029        None => return Err(invalid("message", "is required")),
3030        Some(message) if message.trim().is_empty() => {
3031            return Err(invalid("message", "must not be blank"))
3032        }
3033        Some(message) => message,
3034    };
3035    let limit = match raw.limit {
3036        None => return Err(invalid("limit", "is required")),
3037        Some(0) => {
3038            return Err(invalid(
3039                "limit",
3040                "must be at least 1; omit lifecycle.keep_working to turn the option off",
3041            ))
3042        }
3043        Some(limit) => limit,
3044    };
3045    Ok(KeepWorking { message, limit })
3046}
3047
3048/// Resolves `lifecycle.done_check`, refusing a missing, blank or wordless `command`, a command
3049/// whose program `shell_allow` (`capabilities.shell.allow`) does not list, a missing `limit` and a
3050/// zero `timeout_secs`. The command keeps the operator's bytes as written.
3051fn parse_done_check(
3052    raw: RawDoneCheck,
3053    shell_allow: &[String],
3054) -> Result<DoneCheck, RuntimeManifestError> {
3055    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidLifecycleConfig {
3056        field: format!("lifecycle.done_check.{field}"),
3057        message,
3058    };
3059    let command = match raw.command {
3060        None => return Err(invalid("command", "is required".into())),
3061        Some(command) if command.trim().is_empty() => {
3062            return Err(invalid("command", "must not be blank".into()))
3063        }
3064        Some(command) => command,
3065    };
3066    let Some(program) = split_shell_words(&command).into_iter().next() else {
3067        return Err(invalid("command", "must name a program".into()));
3068    };
3069    if !shell_allow.contains(&program) {
3070        return Err(invalid(
3071            "command",
3072            format!("runs '{program}', which is not in capabilities.shell.allow"),
3073        ));
3074    }
3075    let Some(limit) = raw.limit else {
3076        return Err(invalid("limit", "is required".into()));
3077    };
3078    let timeout_secs = match raw.timeout_secs {
3079        None => DoneCheck::DEFAULT_TIMEOUT_SECS,
3080        Some(0) => return Err(invalid("timeout_secs", "must be at least 1".into())),
3081        Some(secs) => secs,
3082    };
3083    Ok(DoneCheck {
3084        command,
3085        limit,
3086        timeout_secs,
3087    })
3088}
3089
3090impl RawBlock for RawLifecycleConfig {
3091    const KNOWN_KEYS: &'static [&'static str] = &[
3092        "task_acceptance",
3093        "after_task",
3094        "queue_depth",
3095        "input_timeout_secs",
3096        "conversation",
3097        "max_task_reopens",
3098        "shell_grace_secs",
3099        "delegation_deadline_secs",
3100        "deadline_notice_secs",
3101        "keep_working",
3102        "done_check",
3103    ];
3104    fn unknown_keys(&self) -> &UnknownKeys {
3105        &self.unknown
3106    }
3107
3108    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3109        if let Some(keep_working) = &self.keep_working {
3110            collect_block(keep_working, &child_path(path, "keep_working"), out);
3111        }
3112        if let Some(done_check) = &self.done_check {
3113            collect_block(done_check, &child_path(path, "done_check"), out);
3114        }
3115    }
3116}
3117
3118impl RawBlock for RawDoneCheck {
3119    const KNOWN_KEYS: &'static [&'static str] = &["command", "limit", "timeout_secs"];
3120    fn unknown_keys(&self) -> &UnknownKeys {
3121        &self.unknown
3122    }
3123}
3124
3125impl RawBlock for RawKeepWorking {
3126    const KNOWN_KEYS: &'static [&'static str] = &["message", "limit"];
3127    fn unknown_keys(&self) -> &UnknownKeys {
3128        &self.unknown
3129    }
3130}
3131
3132impl RawBlock for RawNetworkConfig {
3133    const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
3134    fn unknown_keys(&self) -> &UnknownKeys {
3135        &self.unknown
3136    }
3137
3138    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3139        if let Some(authentication) = &self.authentication {
3140            collect_block(authentication, &child_path(path, "authentication"), out);
3141        }
3142    }
3143}
3144
3145impl RawBlock for RawNetworkAuthentication {
3146    const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
3147    fn unknown_keys(&self) -> &UnknownKeys {
3148        &self.unknown
3149    }
3150
3151    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3152        let credentials_path = child_path(path, "credentials");
3153        for (name, credential) in self.credentials.iter().flatten() {
3154            collect_block(credential, &child_path(&credentials_path, name), out);
3155        }
3156    }
3157}
3158
3159impl RawBlock for RawDoorCredential {
3160    const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
3161    fn unknown_keys(&self) -> &UnknownKeys {
3162        &self.unknown
3163    }
3164}
3165
3166impl RawBlock for RawContextConfig {
3167    const KNOWN_KEYS: &'static [&'static str] = &[
3168        "max_tokens",
3169        "record",
3170        "record_store",
3171        "seed_budget",
3172        "seed_overflow_margin",
3173        "retain",
3174        "images",
3175    ];
3176    fn unknown_keys(&self) -> &UnknownKeys {
3177        &self.unknown
3178    }
3179
3180    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3181        if let Some(images) = &self.images {
3182            collect_block(images, &child_path(path, "images"), out);
3183        }
3184    }
3185}
3186
3187impl RawBlock for RawContextImages {
3188    const KNOWN_KEYS: &'static [&'static str] = &["keep", "max_bytes", "max_per_turn"];
3189    fn unknown_keys(&self) -> &UnknownKeys {
3190        &self.unknown
3191    }
3192}
3193
3194impl RawBlock for RawObservabilityConfig {
3195    const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
3196    fn unknown_keys(&self) -> &UnknownKeys {
3197        &self.unknown
3198    }
3199
3200    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3201        if let Some(eval) = &self.eval {
3202            collect_block(eval, &child_path(path, "eval"), out);
3203        }
3204    }
3205}
3206
3207impl RawBlock for RawTraceConfig {
3208    const KNOWN_KEYS: &'static [&'static str] = &["capture", "retain"];
3209    fn unknown_keys(&self) -> &UnknownKeys {
3210        &self.unknown
3211    }
3212}
3213
3214impl RawBlock for RawEvalConfig {
3215    const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
3216    fn unknown_keys(&self) -> &UnknownKeys {
3217        &self.unknown
3218    }
3219
3220    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3221        let scorers = child_path(path, "scorers");
3222        for (index, scorer) in self.scorers.iter().enumerate() {
3223            collect_block(scorer, &format!("{scorers}[{index}]"), out);
3224        }
3225    }
3226}
3227
3228impl RawBlock for RawScorerConfig {
3229    const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
3230    fn unknown_keys(&self) -> &UnknownKeys {
3231        &self.unknown
3232    }
3233}
3234
3235impl RawBlock for RawArtifact {
3236    const KNOWN_KEYS: &'static [&'static str] = &[
3237        "name",
3238        "version",
3239        "runtime",
3240        "source",
3241        "local_source",
3242        "prompt_payload",
3243        "capabilities",
3244        "on_overflow",
3245        "config",
3246        "gateway",
3247    ];
3248    fn unknown_keys(&self) -> &UnknownKeys {
3249        &self.unknown
3250    }
3251
3252    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3253        if let Some(capabilities) = &self.capabilities {
3254            collect_block(capabilities, &child_path(path, "capabilities"), out);
3255        }
3256        if let Some(Some(gateway)) = &self.gateway {
3257            collect_block(gateway, &child_path(path, "gateway"), out);
3258        }
3259    }
3260}
3261
3262impl RawBlock for RawArtifactGateway {
3263    const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
3264    fn unknown_keys(&self) -> &UnknownKeys {
3265        &self.unknown
3266    }
3267}
3268
3269impl RawBlock for RawCapabilities {
3270    const KNOWN_KEYS: &'static [&'static str] = &[
3271        "network",
3272        "peer_fetch",
3273        "filesystem",
3274        "shell",
3275        "spawn",
3276        "env",
3277        "limits",
3278        "resources",
3279        "state",
3280        "task_io",
3281        "conversation",
3282        "plan",
3283        "install",
3284        "harness",
3285        "containment",
3286    ];
3287    fn unknown_keys(&self) -> &UnknownKeys {
3288        &self.unknown
3289    }
3290
3291    /// Reached from both [`RawRuntimeManifest`] and [`RawArtifact`], so the capsule-wide block and
3292    /// a per-artifact one report through identical code and differ only in the `path` they are
3293    /// handed — `capabilities.shell` against `artifacts[0].capabilities.shell`.
3294    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3295        if let Some(network) = &self.network {
3296            collect_block(network, &child_path(path, "network"), out);
3297        }
3298        if let Some(peer_fetch) = &self.peer_fetch {
3299            collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
3300        }
3301        if let Some(filesystem) = &self.filesystem {
3302            collect_block(filesystem, &child_path(path, "filesystem"), out);
3303        }
3304        if let Some(shell) = &self.shell {
3305            collect_block(shell, &child_path(path, "shell"), out);
3306        }
3307        if let Some(spawn) = &self.spawn {
3308            collect_block(spawn, &child_path(path, "spawn"), out);
3309        }
3310        if let Some(env) = &self.env {
3311            collect_block(env, &child_path(path, "env"), out);
3312        }
3313        if let Some(limits) = &self.limits {
3314            collect_block(limits, &child_path(path, "limits"), out);
3315        }
3316        if let Some(resources) = &self.resources {
3317            collect_block(resources, &child_path(path, "resources"), out);
3318        }
3319        if let Some(state) = &self.state {
3320            collect_block(state, &child_path(path, "state"), out);
3321        }
3322        if let Some(task_io) = &self.task_io {
3323            collect_block(task_io, &child_path(path, "task_io"), out);
3324        }
3325        if let Some(conversation) = &self.conversation {
3326            collect_block(conversation, &child_path(path, "conversation"), out);
3327        }
3328        if let Some(plan) = &self.plan {
3329            collect_block(plan, &child_path(path, "plan"), out);
3330        }
3331        if let Some(install) = &self.install {
3332            collect_block(install, &child_path(path, "install"), out);
3333        }
3334        if let Some(harness) = &self.harness {
3335            collect_block(harness, &child_path(path, "harness"), out);
3336        }
3337    }
3338}
3339
3340impl RawBlock for RawTaskIoCapabilities {
3341    const KNOWN_KEYS: &'static [&'static str] = &["read"];
3342    fn unknown_keys(&self) -> &UnknownKeys {
3343        &self.unknown
3344    }
3345}
3346
3347impl RawBlock for RawConversationCapabilities {
3348    const KNOWN_KEYS: &'static [&'static str] = &["read"];
3349    fn unknown_keys(&self) -> &UnknownKeys {
3350        &self.unknown
3351    }
3352}
3353
3354impl RawBlock for RawPlanCapabilities {
3355    const KNOWN_KEYS: &'static [&'static str] = &["submit"];
3356    fn unknown_keys(&self) -> &UnknownKeys {
3357        &self.unknown
3358    }
3359}
3360
3361impl RawBlock for RawInstallCapabilities {
3362    const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool", "from"];
3363    fn unknown_keys(&self) -> &UnknownKeys {
3364        &self.unknown
3365    }
3366}
3367
3368impl RawBlock for RawHarnessCapabilities {
3369    const KNOWN_KEYS: &'static [&'static str] = &["paths"];
3370    fn unknown_keys(&self) -> &UnknownKeys {
3371        &self.unknown
3372    }
3373    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3374        let paths = child_path(path, "paths");
3375        for (index, entry) in self.paths.iter().enumerate() {
3376            collect_block(entry, &format!("{paths}[{index}]"), out);
3377        }
3378    }
3379}
3380
3381impl RawBlock for RawHarnessPathEntry {
3382    const KNOWN_KEYS: &'static [&'static str] = &["path", "access"];
3383    fn unknown_keys(&self) -> &UnknownKeys {
3384        &self.unknown
3385    }
3386}
3387
3388impl RawBlock for RawSpawnCapabilities {
3389    const KNOWN_KEYS: &'static [&'static str] = &["allow", "workdir_snapshot"];
3390    fn unknown_keys(&self) -> &UnknownKeys {
3391        &self.unknown
3392    }
3393}
3394
3395impl RawBlock for RawEnvCapabilities {
3396    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3397    fn unknown_keys(&self) -> &UnknownKeys {
3398        &self.unknown
3399    }
3400}
3401
3402impl RawBlock for RawResourceLimits {
3403    const KNOWN_KEYS: &'static [&'static str] = &[
3404        "memory_bytes",
3405        "table_elements",
3406        "instances",
3407        "deadline_seconds",
3408    ];
3409    fn unknown_keys(&self) -> &UnknownKeys {
3410        &self.unknown
3411    }
3412}
3413
3414impl RawBlock for RawStateCapabilities {
3415    const KNOWN_KEYS: &'static [&'static str] = &["store"];
3416    fn unknown_keys(&self) -> &UnknownKeys {
3417        &self.unknown
3418    }
3419}
3420
3421impl RawBlock for RawResourceCapabilities {
3422    const KNOWN_KEYS: &'static [&'static str] = &[
3423        "max_processes",
3424        "max_open_files",
3425        "max_file_size_bytes",
3426        "cpu_seconds",
3427        "memory_bytes",
3428        "cgroup_memory_bytes",
3429        "cgroup_pids_max",
3430        "cgroup_cpu_percent",
3431        "cgroup_io_bytes_per_sec",
3432        "workdir_max_bytes",
3433    ];
3434    fn unknown_keys(&self) -> &UnknownKeys {
3435        &self.unknown
3436    }
3437}
3438
3439impl RawBlock for RawNetworkCapabilities {
3440    const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
3441    fn unknown_keys(&self) -> &UnknownKeys {
3442        &self.unknown
3443    }
3444}
3445
3446impl RawBlock for RawPeerFetchCapabilities {
3447    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3448    fn unknown_keys(&self) -> &UnknownKeys {
3449        &self.unknown
3450    }
3451}
3452
3453impl RawBlock for RawFilesystemCapabilities {
3454    const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only", "records"];
3455    fn unknown_keys(&self) -> &UnknownKeys {
3456        &self.unknown
3457    }
3458}
3459
3460impl RawBlock for RawShellCapabilities {
3461    const KNOWN_KEYS: &'static [&'static str] = &[
3462        "allow",
3463        "strip_env",
3464        "baseline_env",
3465        "interpreter_runtime",
3466        "staged_runtime",
3467    ];
3468    fn unknown_keys(&self) -> &UnknownKeys {
3469        &self.unknown
3470    }
3471
3472    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3473        let interpreter_runtime = child_path(path, "interpreter_runtime");
3474        for (index, grant) in self.interpreter_runtime.iter().enumerate() {
3475            collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
3476        }
3477        let staged_runtime = child_path(path, "staged_runtime");
3478        for (index, grant) in self.staged_runtime.iter().enumerate() {
3479            collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
3480        }
3481    }
3482}
3483
3484impl RawBlock for RawInterpreterRuntimeGrant {
3485    const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
3486    fn unknown_keys(&self) -> &UnknownKeys {
3487        &self.unknown
3488    }
3489
3490    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3491        let dirs = child_path(path, "dirs");
3492        for (index, dir) in self.dirs.iter().enumerate() {
3493            collect_block(dir, &format!("{dirs}[{index}]"), out);
3494        }
3495    }
3496}
3497
3498impl RawBlock for RawInterpreterRuntimeDir {
3499    const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
3500    fn unknown_keys(&self) -> &UnknownKeys {
3501        &self.unknown
3502    }
3503}
3504
3505impl RawBlock for RawStagedRuntimeGrant {
3506    const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
3507    fn unknown_keys(&self) -> &UnknownKeys {
3508        &self.unknown
3509    }
3510}
3511
3512impl RawBlock for RawInferenceConfig {
3513    const KNOWN_KEYS: &'static [&'static str] = &[
3514        "transport",
3515        "endpoint",
3516        "model",
3517        "api_key",
3518        "command",
3519        "system_prompt",
3520        "system_prompt_file",
3521        "system_prompt_artifact",
3522        "instruction_files",
3523        "driver",
3524        "compaction",
3525        "max_turns",
3526        "max_task_reopens",
3527        "max_tokens",
3528        "max_session_tokens",
3529        "tool_refresh",
3530        "alternates",
3531        "retry",
3532    ];
3533    fn unknown_keys(&self) -> &UnknownKeys {
3534        &self.unknown
3535    }
3536
3537    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3538        if let Some(driver) = &self.driver {
3539            collect_block(driver, &child_path(path, "driver"), out);
3540        }
3541        if let Some(compaction) = &self.compaction {
3542            collect_block(compaction, &child_path(path, "compaction"), out);
3543        }
3544        if let Some(retry) = &self.retry {
3545            collect_block(retry, &child_path(path, "retry"), out);
3546        }
3547        if let Some(alternates) = &self.alternates {
3548            let alternates_path = child_path(path, "alternates");
3549            for (index, alternate) in alternates.iter().enumerate() {
3550                collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
3551            }
3552        }
3553    }
3554}
3555
3556impl RawBlock for RawInferenceRetry {
3557    const KNOWN_KEYS: &'static [&'static str] =
3558        &["max_retries", "initial_delay_ms", "max_delay_ms"];
3559    fn unknown_keys(&self) -> &UnknownKeys {
3560        &self.unknown
3561    }
3562}
3563
3564impl RawBlock for RawInferenceAlternate {
3565    const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
3566    fn unknown_keys(&self) -> &UnknownKeys {
3567        &self.unknown
3568    }
3569
3570    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3571        if let Some(driver) = &self.driver {
3572            collect_block(driver, &child_path(path, "driver"), out);
3573        }
3574    }
3575}
3576
3577impl RawBlock for RawCompactionConfig {
3578    const KNOWN_KEYS: &'static [&'static str] = &[
3579        "threshold",
3580        "model",
3581        "system_prompt",
3582        "system_prompt_file",
3583        "dump_summaries",
3584        "max_tokens",
3585        "driver_config",
3586    ];
3587    fn unknown_keys(&self) -> &UnknownKeys {
3588        &self.unknown
3589    }
3590}
3591
3592impl RawBlock for RawInferenceDriver {
3593    const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
3594    fn unknown_keys(&self) -> &UnknownKeys {
3595        &self.unknown
3596    }
3597}
3598
3599/// Walks a parsed raw manifest and reports every key no block of it claimed, each block's own
3600/// keys before the blocks it owns, in the order the blocks appear in the manifest's own type.
3601///
3602/// Called once, immediately after `serde_norway` returns and before any field is moved out, so the
3603/// result covers the whole document rather than the parts validation happens to reach. Reporting
3604/// only; nothing here can refuse a manifest.
3605fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
3606    let mut out = Vec::new();
3607    collect_block(raw, "", &mut out);
3608    out
3609}
3610
3611/// One block's captured keys, each paired with the nearest key that block does recognize, then
3612/// the same for every block it owns.
3613///
3614/// `path` is empty for the manifest root, which is what
3615/// [`crate::unknown_manifest_keys::UnknownManifestKey`] renders as "at the top level".
3616fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
3617    for key in block.unknown_keys().keys() {
3618        out.push(UnknownManifestKey {
3619            key: key.clone(),
3620            block_path: path.to_string(),
3621            nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
3622        });
3623    }
3624    block.walk_children(path, out);
3625}
3626
3627/// The dotted path of `field` inside the block at `parent`, built in one place so no
3628/// [`RawBlock::walk_children`] has to reproduce the rule that the manifest root has no prefix.
3629/// An indexed child appends its own `[index]` to the result.
3630fn child_path(parent: &str, field: &str) -> String {
3631    if parent.is_empty() {
3632        return field.to_string();
3633    }
3634    format!("{parent}.{field}")
3635}
3636
3637/// Read a manifest file's text, mapping a missing file to
3638/// [`RuntimeManifestError::NotFound`] and any other read failure to
3639/// [`RuntimeManifestError::Io`].
3640///
3641/// For a caller that needs the manifest text as well as the parsed manifest — `mur doctor` reads
3642/// the references a manifest makes out of the same bytes it parses, so the file is opened once.
3643pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
3644    fs::read_to_string(path).map_err(|source| {
3645        if source.kind() == std::io::ErrorKind::NotFound {
3646            return RuntimeManifestError::NotFound(path.display().to_string());
3647        }
3648
3649        RuntimeManifestError::Io {
3650            path: path.display().to_string(),
3651            source,
3652        }
3653    })
3654}
3655
3656#[must_use = "validated runtime manifest is required before run"]
3657pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
3658    let content = read_runtime_manifest_text(path)?;
3659
3660    RuntimeManifest::from_yaml_str(&content)
3661}
3662
3663/// One `${VAR}` reference a manifest makes, and the field that makes it.
3664#[derive(Debug, Clone, PartialEq, Eq)]
3665pub struct ReferencedEnvVariable {
3666    /// The manifest field holding the reference, e.g. `artifacts.murmur-driver-anthropic.gateway.api_key`.
3667    pub field: String,
3668    /// The variable the reference names.
3669    pub variable: String,
3670}
3671
3672impl ReferencedEnvVariable {
3673    /// Whether the reference is a gateway's `api_key` — a credential the runtime also answers
3674    /// from the global config's `credentials:` map, not only from the environment.
3675    pub fn is_gateway_credential(&self) -> bool {
3676        self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
3677    }
3678}
3679
3680/// The narrow view of a manifest this scan needs: the fields whose value may be an `${ENV_REF}`,
3681/// and nothing else. Every other key is ignored, so a manifest this build would refuse for an
3682/// unrelated reason still yields its references.
3683///
3684/// Deliberately not one of the `Raw*` structs: those carry the unknown-key overflow map that
3685/// makes `warn_on_unknown_manifest_keys` complete, and this one is a second, partial reader of a
3686/// document that reader has already covered.
3687#[derive(Deserialize)]
3688struct ReferencedManifestFields {
3689    #[serde(default)]
3690    artifacts: Vec<ReferencedArtifactFields>,
3691}
3692
3693#[derive(Deserialize)]
3694struct ReferencedArtifactFields {
3695    #[serde(default)]
3696    name: Option<String>,
3697    #[serde(default)]
3698    gateway: Option<ReferencedGatewayFields>,
3699}
3700
3701#[derive(Deserialize)]
3702struct ReferencedGatewayFields {
3703    #[serde(default)]
3704    api_key: Option<String>,
3705}
3706
3707/// Every environment variable `manifest_yaml` references through an `${ENV_REF}`, sorted by
3708/// variable name and deduplicated.
3709///
3710/// Never fails and never reads the environment: unparseable YAML, an absent field and a literal
3711/// (non-reference) value all yield an empty list. What counts as a reference is decided by the
3712/// same `parse_env_reference` the resolving parse applies, so a name reported here is exactly a
3713/// name [`RuntimeManifest::from_yaml_str`] would have looked up.
3714pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
3715    let Ok(raw) = serde_norway::from_str::<ReferencedManifestFields>(manifest_yaml) else {
3716        return Vec::new();
3717    };
3718
3719    let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
3720    for artifact in raw.artifacts {
3721        let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
3722            continue;
3723        };
3724        if let Some(variable) = parse_env_reference(api_key.trim()) {
3725            let name = artifact.name.unwrap_or_default();
3726            referenced.push(ReferencedEnvVariable {
3727                field: format!("artifacts.{}.gateway.api_key", name.trim()),
3728                variable: variable.to_string(),
3729            });
3730        }
3731    }
3732
3733    referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
3734    referenced.dedup();
3735    referenced
3736}
3737
3738/// Whether a parse keeps what the manifest says about a secret it references.
3739///
3740/// The two [`RuntimeManifest`] constructors differ by this value and nothing else, so validation
3741/// cannot drift between a manifest `mur run` accepts and one a referee accepts. Neither reads the
3742/// process environment.
3743#[derive(Clone, Copy, PartialEq, Eq)]
3744enum SecretResolution {
3745    /// Every `gateway.api_key` is kept as an [`ApiKeyReference`], unresolved.
3746    Resolve,
3747    /// Every `gateway.api_key` is left unread. Each parsed [`ArtifactGateway::api_key`] is `None`
3748    /// whatever the manifest wrote.
3749    Skip,
3750}
3751
3752impl RuntimeManifest {
3753    pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
3754        Self::parse(input, SecretResolution::Resolve)
3755    }
3756
3757    /// Parse a manifest without resolving any secret it references.
3758    ///
3759    /// Validation is identical to [`RuntimeManifest::from_yaml_str`] on every block, `artifacts`
3760    /// included, with one difference: every resulting [`ArtifactGateway::api_key`] is `None`,
3761    /// whether the manifest wrote an `${ENV_REF}` or a literal key.
3762    ///
3763    /// For a caller that reads a foreign capsule's manifest for its capability policy alone —
3764    /// `mur-roost` refereeing a delegation — and is entitled to none of the credentials that
3765    /// manifest names.
3766    pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
3767        Self::parse(input, SecretResolution::Skip)
3768    }
3769
3770    fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
3771        let raw: RawRuntimeManifest = serde_norway::from_str(input).map_err(|err| {
3772            if let Some(location) = err.location() {
3773                RuntimeManifestError::YamlSyntax(format!(
3774                    "{}: YAML syntax error at line {}, column {}: {}",
3775                    MANIFEST_FILENAME,
3776                    location.line(),
3777                    location.column(),
3778                    err
3779                ))
3780            } else {
3781                RuntimeManifestError::YamlSyntax(format!(
3782                    "{MANIFEST_FILENAME}: YAML syntax error: {err}"
3783                ))
3784            }
3785        })?;
3786
3787        // The key is not a field of `RawRuntimeManifest`, so it lands in the unknown-key map.
3788        if raw.unknown.contains_key(REGISTRY_BLOCK_KEY) {
3789            return Err(RuntimeManifestError::RegistryBlock);
3790        }
3791        // Neither is `host_paths`, in any block a capsule could write it in.
3792        check_no_host_paths(&raw)?;
3793
3794        // Collected before any field is moved out of `raw`, so the walk sees the whole document
3795        // rather than the part validation happens to reach before its first refusal.
3796        let unknown_keys = collect_unknown_keys(&raw);
3797
3798        if raw.mur_version.is_some() {
3799            return Err(RuntimeManifestError::TopLevelMurVersion);
3800        }
3801
3802        let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
3803            RuntimeManifestError::MissingField {
3804                field: "name".to_string(),
3805            }
3806        })?;
3807
3808        let version = raw
3809            .version
3810            .filter(|s| !s.trim().is_empty())
3811            .ok_or_else(|| RuntimeManifestError::MissingField {
3812                field: "version".to_string(),
3813            })?;
3814
3815        // Read off the raw entries because a parse that skips secrets keeps no `ApiKeyReference`,
3816        // and `control.secrets` is checked against what the manifest wrote either way.
3817        let gateway_references = raw_gateway_references(&raw.artifacts);
3818
3819        let artifacts = raw
3820            .artifacts
3821            .into_iter()
3822            .enumerate()
3823            .map(|(index, artifact)| {
3824                let name = artifact
3825                    .name
3826                    .filter(|s| !s.trim().is_empty())
3827                    .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3828                        index,
3829                        message: "missing required field 'name'".to_string(),
3830                    })?;
3831
3832                let source = artifact.source.filter(|s| !s.trim().is_empty());
3833                check_entry_name(index, &name, source.is_some())?;
3834
3835                let runtime = match artifact.runtime.as_deref() {
3836                    None | Some("tool") => ArtifactRuntime::Tool,
3837                    Some("wasm") | Some("native") => {
3838                        return Err(RuntimeManifestError::InvalidArtifact {
3839                            index,
3840                            message: format!(
3841                                "use 'runtime: tool' for a tool (got '{}')",
3842                                artifact.runtime.as_deref().unwrap_or("")
3843                            ),
3844                        });
3845                    }
3846                    Some("driver") => ArtifactRuntime::Driver,
3847                    Some("hook") => ArtifactRuntime::Hook,
3848                    Some("skill") => ArtifactRuntime::Skill,
3849                    Some(other) => {
3850                        return Err(RuntimeManifestError::InvalidArtifact {
3851                            index,
3852                            message: format!(
3853                                "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
3854                            ),
3855                        });
3856                    }
3857                };
3858
3859                // Capabilities are keyed on declared properties, not on the role. When the
3860                // property is absent we derive it from the role, which reproduces the previous
3861                // skill-only gate exactly and leaves every existing manifest unchanged.
3862                let local_source = artifact
3863                    .local_source
3864                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3865                let prompt_payload = artifact
3866                    .prompt_payload
3867                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3868
3869                // `source:` requires the artifact to declare `local_source: true`.
3870                if source.is_some() && !local_source {
3871                    return Err(RuntimeManifestError::InvalidArtifact {
3872                        index,
3873                        message: format!(
3874                            "artifact '{name}' declares 'source:' but does not declare \
3875                             'local_source: true' (runtime: {})",
3876                            runtime.as_str()
3877                        ),
3878                    });
3879                }
3880
3881                // When `source:` is present, `version:` is optional — substitute "local".
3882                // If both are set, `version:` is ignored and a warning is printed to stderr.
3883                let version = if source.is_some() {
3884                    if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
3885                        crate::warn_line(format_args!(
3886                            "warning: artifact '{name}' declares both 'source:' and \
3887                             'version: {explicit}'; version is ignored for local-source skills \
3888                             (using 'local')"
3889                        ));
3890                    }
3891                    "local".to_string()
3892                } else {
3893                    artifact
3894                        .version
3895                        .filter(|s| !s.trim().is_empty())
3896                        .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3897                            index,
3898                            message: "missing required field 'version'".to_string(),
3899                        })?
3900                };
3901
3902                // Per-artifact `capabilities:` is enforced on every role that actually
3903                // executes — `hook` (default-deny grant) and `tool`/`driver` (narrowing
3904                // below the capsule ceiling). A `skill` has no execution surface, so the
3905                // block would be silently ignored there; that reads like a scoped artifact
3906                // when it is not, hence a parse error rather than a no-op.
3907                let capabilities = match artifact.capabilities {
3908                    None => None,
3909                    Some(raw_caps) => {
3910                        // The install grant is held by the session, not by one artifact: a
3911                        // per-artifact block would reach no pull at all.
3912                        if raw_caps.install.is_some() {
3913                            return Err(RuntimeManifestError::InvalidArtifact {
3914                                index,
3915                                message: format!(
3916                                    "artifact '{name}' declares 'capabilities.install:'; the \
3917                                     grant is capsule-wide and is only recognized in the \
3918                                     top-level capabilities block"
3919                                ),
3920                            });
3921                        }
3922                        if raw_caps.harness.is_some() {
3923                            return Err(RuntimeManifestError::InvalidArtifact {
3924                                index,
3925                                message: format!(
3926                                    "artifact '{name}' declares 'capabilities.harness:'; the \
3927                                     grant is capsule-wide and reaches the process harness, so it \
3928                                     is only recognized in the top-level capabilities block"
3929                                ),
3930                            });
3931                        }
3932                        if runtime == ArtifactRuntime::Skill {
3933                            return Err(RuntimeManifestError::InvalidArtifact {
3934                                index,
3935                                message: format!(
3936                                    "artifact '{name}' declares per-artifact 'capabilities:' but \
3937                                     has 'runtime: {}'; per-artifact capabilities are only \
3938                                     recognized on 'runtime: hook', 'runtime: tool', and \
3939                                     'runtime: driver' entries — use the capsule-wide top-level \
3940                                     'capabilities:' block instead",
3941                                    runtime.as_str()
3942                                ),
3943                            });
3944                        }
3945                        // `task_io` grants the `murmur:task-io/read` host import, which only
3946                        // hook components can be handed. On a tool or driver entry nothing
3947                        // would enforce it, and a silently-inert grant reads like a scoped
3948                        // artifact — the same reason `on_overflow:` below is a parse error off
3949                        // a hook.
3950                        if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
3951                            return Err(RuntimeManifestError::InvalidArtifact {
3952                                index,
3953                                message: format!(
3954                                    "artifact '{name}' declares 'capabilities.task_io:' but has \
3955                                     'runtime: {}'; the key grants the murmur:task-io/read host \
3956                                     import and is only recognized on 'runtime: hook' entries",
3957                                    runtime.as_str()
3958                                ),
3959                            });
3960                        }
3961                        // `filesystem.records` replaces a hook's workdir scope with the records
3962                        // directory's `hooks/`. Only a hook is preopened that way, and a hook has
3963                        // one `"."`, so the two declarations cannot both hold.
3964                        if let Some(filesystem) = raw_caps
3965                            .filesystem
3966                            .as_ref()
3967                            .filter(|filesystem| filesystem.records)
3968                        {
3969                            if runtime != ArtifactRuntime::Hook {
3970                                return Err(RuntimeManifestError::InvalidArtifact {
3971                                    index,
3972                                    message: format!(
3973                                        "artifact '{name}' declares \
3974                                         'capabilities.filesystem.records: true' but has \
3975                                         'runtime: {}'; the key gives a hook the records \
3976                                         directory's hooks/ and is only recognized on \
3977                                         'runtime: hook' entries",
3978                                        runtime.as_str()
3979                                    ),
3980                                });
3981                            }
3982                            if filesystem
3983                                .scope
3984                                .as_deref()
3985                                .is_some_and(|scope| !scope.trim().is_empty())
3986                            {
3987                                return Err(RuntimeManifestError::InvalidArtifact {
3988                                    index,
3989                                    message: format!(
3990                                        "artifact '{name}' declares both \
3991                                         'capabilities.filesystem.records: true' and \
3992                                         'capabilities.filesystem.scope'; a hook's \".\" is \
3993                                         either the records directory's hooks/ or a workdir \
3994                                         scope — declare one"
3995                                    ),
3996                                });
3997                            }
3998                        }
3999                        // `conversation` grants the `murmur:conversation/read` host import, on the
4000                        // same terms as `task_io` above: only a hook component's world can import
4001                        // it, so on any other role the grant would be silently inert.
4002                        if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
4003                            return Err(RuntimeManifestError::InvalidArtifact {
4004                                index,
4005                                message: format!(
4006                                    "artifact '{name}' declares 'capabilities.conversation:' but \
4007                                     has 'runtime: {}'; the key grants the \
4008                                     murmur:conversation/read host import and is only recognized \
4009                                     on 'runtime: hook' entries",
4010                                    runtime.as_str()
4011                                ),
4012                            });
4013                        }
4014                        parse_capabilities(Some(raw_caps))?
4015                    }
4016                };
4017
4018                // `on_overflow:` governs an async hook's job queue, which only exists for
4019                // `runtime: hook`. On any other role it would be silently ignored, so it is
4020                // a parse error for the same reason per-artifact `capabilities:` is on a
4021                // skill. It stays legal on a *blocking* hook entry (simply inert): the
4022                // execution mode lives in the artifact's own manifest and is unknown here.
4023                let on_overflow = match artifact.on_overflow.as_deref() {
4024                    None => HookOverflowPolicy::default(),
4025                    Some(raw) => {
4026                        if runtime != ArtifactRuntime::Hook {
4027                            return Err(RuntimeManifestError::InvalidArtifact {
4028                                index,
4029                                message: format!(
4030                                    "artifact '{name}' declares 'on_overflow:' but has \
4031                                     'runtime: {}'; the key governs an async hook's job queue \
4032                                     and is only recognized on 'runtime: hook' entries",
4033                                    runtime.as_str()
4034                                ),
4035                            });
4036                        }
4037                        match raw {
4038                            "drop" => HookOverflowPolicy::Drop,
4039                            "block" => HookOverflowPolicy::Block,
4040                            other => {
4041                                return Err(RuntimeManifestError::InvalidArtifact {
4042                                    index,
4043                                    message: format!(
4044                                        "artifact '{name}' declares unknown on_overflow \
4045                                         '{other}'; expected: drop, block"
4046                                    ),
4047                                });
4048                            }
4049                        }
4050                    }
4051                };
4052
4053                // `config:` reaches an artifact through that artifact's own grant, and a skill
4054                // holds no grant and runs no component — nothing would ever deliver it. Refused
4055                // rather than ignored, for the reason the two gates above are: a key that reads
4056                // like configuration and is silently dropped is worse than one that is refused.
4057                // The block's *shape* is the runtime's business, not this parser's; only the
4058                // role is decided here.
4059                if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
4060                    return Err(RuntimeManifestError::InvalidArtifact {
4061                        index,
4062                        message: format!(
4063                            "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
4064                             is recognized only on 'runtime: hook', 'runtime: tool', and \
4065                             'runtime: driver' entries",
4066                            runtime.as_str()
4067                        ),
4068                    });
4069                }
4070
4071                let gateway = match artifact.gateway {
4072                    None => None,
4073                    Some(raw_gateway) => {
4074                        if runtime == ArtifactRuntime::Skill {
4075                            return Err(RuntimeManifestError::InvalidArtifact {
4076                                index,
4077                                message: format!(
4078                                    "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
4079                                     the key is recognized only on 'runtime: hook', \
4080                                     'runtime: tool', and 'runtime: driver' entries",
4081                                    runtime.as_str()
4082                                ),
4083                            });
4084                        }
4085                        Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
4086                    }
4087                };
4088
4089                Ok(RuntimeArtifact {
4090                    name,
4091                    version,
4092                    runtime,
4093                    source,
4094                    local_source,
4095                    prompt_payload,
4096                    capabilities,
4097                    on_overflow,
4098                    config: artifact.config,
4099                    gateway,
4100                })
4101            })
4102            .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
4103
4104        // The capsule-wide block reaches capsule, tool and driver components, none of which
4105        // can be handed the murmur:task-io/read import. Rejected rather than ignored, on the
4106        // same terms as declaring it on a non-hook artifact entry.
4107        if raw
4108            .capabilities
4109            .as_ref()
4110            .is_some_and(|caps| caps.task_io.is_some())
4111        {
4112            return Err(RuntimeManifestError::InvalidCapabilities {
4113                field: "capabilities.task_io".to_string(),
4114                message: "is recognized only on 'runtime: hook' artifact entries, not in the \
4115                          capsule-wide capabilities block — the grant is per-hook"
4116                    .to_string(),
4117            });
4118        }
4119        // `filesystem.records` names a hook's preopen, and the capsule-wide block preopens nothing
4120        // for any hook. Refused rather than ignored, on the `task_io` block's terms.
4121        if raw
4122            .capabilities
4123            .as_ref()
4124            .and_then(|caps| caps.filesystem.as_ref())
4125            .is_some_and(|filesystem| filesystem.records)
4126        {
4127            return Err(RuntimeManifestError::InvalidCapabilities {
4128                field: "capabilities.filesystem.records".to_string(),
4129                message: "is recognized only on 'runtime: hook' artifact entries, not in the \
4130                          capsule-wide capabilities block — the grant is per-hook"
4131                    .to_string(),
4132            });
4133        }
4134        // Config is delivered on one artifact's own grant, so there is no capsule-wide form of
4135        // it: the capsule's guest holds no artifact grant, and a top-level block would reach no
4136        // component at all. Refused rather than ignored, on the same terms as the capsule-wide
4137        // `task_io` block above.
4138        if raw.config.is_some() {
4139            return Err(RuntimeManifestError::InvalidCapabilities {
4140                field: "config".to_string(),
4141                message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
4142                          'runtime: driver' entry that reads it — there is no capsule-wide \
4143                          config block"
4144                    .to_string(),
4145            });
4146        }
4147        let capabilities = parse_capabilities(raw.capabilities)?;
4148        let inference = parse_inference(raw.inference)?;
4149        refuse_install_under_process_transport(capabilities.as_ref(), inference.as_ref())?;
4150        validate_driver_gateways(&artifacts, inference.as_ref())?;
4151        validate_driver_choices(&artifacts, inference.as_ref())?;
4152        validate_process_driver_entry(&artifacts, inference.as_ref())?;
4153
4154        // Validate system_prompt_artifact: must name a declared artifact whose payload may be
4155        // bound as the system prompt (`prompt_payload`, defaulted from the role when absent).
4156        if let Some(ref sp_art) = inference
4157            .as_ref()
4158            .and_then(|i| i.system_prompt_artifact.clone())
4159        {
4160            let matching = artifacts.iter().find(|a| &a.name == sp_art);
4161            match matching {
4162                None => {
4163                    return Err(RuntimeManifestError::InvalidInferenceConfig {
4164                        field: "inference.system_prompt_artifact".to_string(),
4165                        message: not_declared_message(&artifacts, sp_art),
4166                    });
4167                }
4168                Some(a) if !a.prompt_payload => {
4169                    return Err(RuntimeManifestError::InvalidInferenceConfig {
4170                        field: "inference.system_prompt_artifact".to_string(),
4171                        message: format!(
4172                            "artifact '{sp_art}' (runtime: {}) does not declare \
4173                             'prompt_payload: true', which system_prompt_artifact requires",
4174                            a.runtime.as_str()
4175                        ),
4176                    });
4177                }
4178                Some(_) => {} // valid
4179            }
4180        }
4181        let context = parse_context(raw.context)?;
4182        let observability = parse_observability(raw.observability);
4183        let trace = raw
4184            .trace
4185            .map(|raw_trace| {
4186                let capture = resolve_trace_capture(raw_trace.capture.as_deref())?;
4187                let retain = parse_trace_retain(raw_trace.retain)?;
4188                Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
4189            })
4190            .transpose()?;
4191        let network = raw
4192            .network
4193            .map(|n| {
4194                Ok::<_, RuntimeManifestError>(NetworkConfig {
4195                    internal_port: n.internal_port,
4196                    authentication: parse_network_authentication(n.authentication)?,
4197                })
4198            })
4199            .transpose()?;
4200        refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
4201        let lifecycle = raw
4202            .lifecycle
4203            .map(|raw_lc| {
4204                let defaults = LifecycleConfig::default();
4205                let lifecycle = LifecycleConfig {
4206                    task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
4207                    after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
4208                    queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
4209                    input_timeout_secs: raw_lc.input_timeout_secs,
4210                    conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
4211                    max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
4212                    shell_grace_secs: raw_lc.shell_grace_secs,
4213                    delegation_deadline_secs: raw_lc
4214                        .delegation_deadline_secs
4215                        .unwrap_or(defaults.delegation_deadline_secs),
4216                    deadline_notice_secs: raw_lc.deadline_notice_secs,
4217                    keep_working: raw_lc.keep_working.map(parse_keep_working).transpose()?,
4218                    done_check: raw_lc
4219                        .done_check
4220                        .map(|raw| {
4221                            let shell_allow = capabilities
4222                                .as_ref()
4223                                .and_then(|capabilities| capabilities.shell.as_ref())
4224                                .map_or(&[][..], |shell| shell.allow.as_slice());
4225                            parse_done_check(raw, shell_allow)
4226                        })
4227                        .transpose()?,
4228                };
4229                if lifecycle.queue_has_room() {
4230                    Ok(lifecycle)
4231                } else {
4232                    Err(RuntimeManifestError::QueueWithoutRoom)
4233                }
4234            })
4235            .transpose()?;
4236
4237        let exports = parse_exports(raw.exports)?;
4238        let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
4239        validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
4240
4241        Ok(Self {
4242            name,
4243            version,
4244            artifacts,
4245            capabilities,
4246            inference,
4247            context,
4248            observability,
4249            trace,
4250            network,
4251            lifecycle,
4252            exports,
4253            control,
4254            deploy: raw.deploy.map(|deploy| DeployConfig {
4255                mur_version: deploy.mur_version.filter(|s| !s.trim().is_empty()),
4256            }),
4257            unknown_keys,
4258        })
4259    }
4260}
4261
4262/// Every artifact entry whose `gateway.api_key` is written as `${NAME}`, as `(artifact, NAME)`.
4263fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
4264    artifacts
4265        .iter()
4266        .filter_map(|artifact| {
4267            let name = artifact.name.as_deref()?.trim();
4268            let written = artifact
4269                .gateway
4270                .as_ref()?
4271                .as_ref()?
4272                .api_key
4273                .as_ref()?
4274                .as_ref()?;
4275            let variable = parse_env_reference(written.trim())?;
4276            Some((name.to_string(), variable.to_string()))
4277        })
4278        .collect()
4279}
4280
4281/// Lowers the top-level `control:` block. A block that declares nothing is no block.
4282///
4283/// `gateway_references` is every `(artifact, NAME)` pair an entry's `gateway.api_key: ${NAME}`
4284/// wrote. A secret must back one of them, and must not back the configured `transport: http`
4285/// driver's: that credential is resolved at staging and keys every turn, so a session launched
4286/// without it could make no inference call at all.
4287fn parse_control(
4288    raw: Option<RawControl>,
4289    gateway_references: &[(String, String)],
4290    inference: Option<&InferenceConfig>,
4291) -> Result<Option<ControlConfig>, RuntimeManifestError> {
4292    let Some(raw) = raw else {
4293        return Ok(None);
4294    };
4295    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
4296        field: field.to_string(),
4297        message,
4298    };
4299    let settings_written = control_string_list("control.settings", raw.settings)?;
4300    let secrets_written = control_string_list("control.secrets", raw.secrets)?;
4301    let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
4302    if settings_written.is_empty()
4303        && secrets_written.is_empty()
4304        && agent_settings_written.is_empty()
4305    {
4306        return Ok(None);
4307    }
4308    let Some(inference) = inference else {
4309        return Err(refuse(
4310            "control",
4311            "is declared on a capsule with no inference: block; the control surface is served on \
4312             an agent capsule's A2A listener, and a script capsule opens none"
4313                .to_string(),
4314        ));
4315    };
4316
4317    let mut settings = Vec::new();
4318    for entry in settings_written {
4319        let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
4320            let controllable: Vec<&str> = ControllableSetting::ALL
4321                .iter()
4322                .map(|setting| setting.wire_name())
4323                .collect();
4324            return Err(refuse(
4325                "control.settings",
4326                format!(
4327                    "'{entry}' is not a controllable setting; the controllable settings are: {}",
4328                    controllable.join(", ")
4329                ),
4330            ));
4331        };
4332        if settings.contains(&setting) {
4333            return Err(refuse(
4334                "control.settings",
4335                format!("'{entry}' is listed more than once"),
4336            ));
4337        }
4338        match setting {
4339            ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
4340                return Err(refuse(
4341                    "control.settings",
4342                    format!(
4343                        "'{entry}' is controllable only under inference.transport: http; a \
4344                         transport: {} harness sets its own output cap",
4345                        inference.transport
4346                    ),
4347                ));
4348            }
4349            ControllableSetting::InferenceMaxTokens => {}
4350            ControllableSetting::InferenceDriver => {
4351                check_driver_setting(inference).map_err(|message| {
4352                    refuse("control.settings", format!("'{entry}' {message}"))
4353                })?;
4354            }
4355        }
4356        settings.push(setting);
4357    }
4358
4359    let mut agent_settings = Vec::new();
4360    for entry in agent_settings_written {
4361        let Some(setting) = ControllableSetting::from_wire_name(&entry)
4362            .filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
4363        else {
4364            let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
4365                .iter()
4366                .map(|setting| setting.wire_name())
4367                .collect();
4368            return Err(refuse(
4369                "control.agent_settings",
4370                format!(
4371                    "'{entry}' is not a setting the agent may change; control.agent_settings \
4372                     accepts only: {}",
4373                    settable.join(", ")
4374                ),
4375            ));
4376        };
4377        if agent_settings.contains(&setting) {
4378            return Err(refuse(
4379                "control.agent_settings",
4380                format!("'{entry}' is listed more than once"),
4381            ));
4382        }
4383        check_driver_setting(inference)
4384            .map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
4385        agent_settings.push(setting);
4386    }
4387
4388    let inference_driver = (inference.transport == "http")
4389        .then(|| {
4390            inference
4391                .driver
4392                .as_ref()
4393                .map(|driver| driver.artifact.as_str())
4394        })
4395        .flatten();
4396    let mut secrets: Vec<String> = Vec::new();
4397    for entry in secrets_written {
4398        if !is_valid_env_variable(&entry) {
4399            return Err(refuse(
4400                "control.secrets",
4401                format!(
4402                    "'{entry}' is not a credential name; a name is uppercase letters, digits and \
4403                     underscores, and does not start with a digit"
4404                ),
4405            ));
4406        }
4407        if secrets.contains(&entry) {
4408            return Err(refuse(
4409                "control.secrets",
4410                format!("'{entry}' is listed more than once"),
4411            ));
4412        }
4413        let backing: Vec<&str> = gateway_references
4414            .iter()
4415            .filter(|(_, variable)| *variable == entry)
4416            .map(|(artifact, _)| artifact.as_str())
4417            .collect();
4418        if backing.is_empty() {
4419            return Err(refuse(
4420                "control.secrets",
4421                format!(
4422                    "'{entry}' is not referenced by any artifact's gateway.api_key as \
4423                     ${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
4424                ),
4425            ));
4426        }
4427        if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
4428            return Err(refuse(
4429                "control.secrets",
4430                format!(
4431                    "'{entry}' keys the configured inference driver's gateway \
4432                     (artifacts.{driver}.gateway.api_key); the inference credential is resolved \
4433                     at launch and cannot be supplied by a controller"
4434                ),
4435            ));
4436        }
4437        secrets.push(entry);
4438    }
4439
4440    Ok(Some(ControlConfig {
4441        settings,
4442        secrets,
4443        agent_settings,
4444    }))
4445}
4446
4447/// Why `inference.driver` cannot be listed for `inference`, or `Ok` when it can: only under
4448/// `transport: http`, and only with at least one alternate to select.
4449fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
4450    if inference.transport != "http" {
4451        return Err(format!(
4452            "is controllable only under inference.transport: http; a transport: {} harness owns \
4453             the model it runs",
4454            inference.transport
4455        ));
4456    }
4457    if inference.alternates.is_empty() {
4458        return Err(
4459            "selects among inference.alternates, and this capsule declares none; declare an \
4460             alternate to switch to"
4461                .to_string(),
4462        );
4463    }
4464    Ok(())
4465}
4466
4467/// `value` as a list of strings, trimmed, for `field`. Absent and YAML null are the empty list.
4468fn control_string_list(
4469    field: &str,
4470    value: Option<serde_norway::Value>,
4471) -> Result<Vec<String>, RuntimeManifestError> {
4472    let refuse = || RuntimeManifestError::InvalidControl {
4473        field: field.to_string(),
4474        message: "must be a list of names".to_string(),
4475    };
4476    let items = match value {
4477        None | Some(serde_norway::Value::Null) => return Ok(Vec::new()),
4478        Some(serde_norway::Value::Sequence(items)) => items,
4479        Some(_) => return Err(refuse()),
4480    };
4481    items
4482        .into_iter()
4483        .map(|item| match item {
4484            serde_norway::Value::String(name) => Ok(name.trim().to_string()),
4485            _ => Err(refuse()),
4486        })
4487        .collect()
4488}
4489
4490/// Lowers `network.authentication`. An absent or null block is a public door.
4491fn parse_network_authentication(
4492    raw: Option<RawNetworkAuthentication>,
4493) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
4494    let Some(raw) = raw else {
4495        return Ok(None);
4496    };
4497    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
4498        field: field.to_string(),
4499        message,
4500    };
4501    let schemes: Vec<&str> = AuthenticationScheme::ALL
4502        .iter()
4503        .map(|scheme| scheme.wire_name())
4504        .collect();
4505    let scheme = match raw.scheme.as_deref().map(str::trim) {
4506        None | Some("") => {
4507            return Err(refuse(
4508                "network.authentication.scheme",
4509                format!("is required; the schemes are: {}", schemes.join(", ")),
4510            ))
4511        }
4512        Some(written) => AuthenticationScheme::ALL
4513            .iter()
4514            .copied()
4515            .find(|scheme| scheme.wire_name() == written)
4516            .ok_or_else(|| {
4517                refuse(
4518                    "network.authentication.scheme",
4519                    format!(
4520                        "'{written}' is not a door authentication scheme; the schemes are: {}",
4521                        schemes.join(", ")
4522                    ),
4523                )
4524            })?,
4525    };
4526
4527    let mut credentials = Vec::new();
4528    for (name, credential) in raw.credentials.unwrap_or_default() {
4529        if name == OPERATOR_CREDENTIAL {
4530            return Err(refuse(
4531                "network.authentication.credentials",
4532                format!(
4533                    "'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
4534                     token, which holds every scope"
4535                ),
4536            ));
4537        }
4538        if !is_valid_credential_name(&name) {
4539            return Err(refuse(
4540                "network.authentication.credentials",
4541                format!(
4542                    "'{name}' is not a credential name; a name is a lowercase letter followed by \
4543                     at most {} lowercase letters, digits, '_' or '-'",
4544                    MAX_CREDENTIAL_NAME_LEN - 1
4545                ),
4546            ));
4547        }
4548        let field = format!("network.authentication.credentials.{name}.scopes");
4549        let scopes = door_scope_list(&field, credential.scopes)?;
4550        credentials.push(DoorCredential { name, scopes });
4551    }
4552
4553    Ok(Some(NetworkAuthentication {
4554        scheme,
4555        credentials,
4556    }))
4557}
4558
4559/// `^[a-z][a-z0-9_-]{0,31}$`.
4560fn is_valid_credential_name(name: &str) -> bool {
4561    let mut chars = name.chars();
4562    chars.next().is_some_and(|first| first.is_ascii_lowercase())
4563        && name.len() <= MAX_CREDENTIAL_NAME_LEN
4564        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
4565}
4566
4567/// One credential's `scopes`: required, non-empty, each in [`DOOR_SCOPES`] and listed once.
4568fn door_scope_list(
4569    field: &str,
4570    value: Option<serde_norway::Value>,
4571) -> Result<Vec<String>, RuntimeManifestError> {
4572    let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
4573        field: field.to_string(),
4574        message,
4575    };
4576    let items = match value {
4577        None | Some(serde_norway::Value::Null) => {
4578            return Err(refuse(
4579                "is required: a credential reaches only the scopes it lists".to_string(),
4580            ))
4581        }
4582        Some(serde_norway::Value::Sequence(items)) => items,
4583        Some(_) => return Err(refuse("must be a list of scopes".to_string())),
4584    };
4585    if items.is_empty() {
4586        return Err(refuse(
4587            "lists no scope; a credential must reach at least one".to_string(),
4588        ));
4589    }
4590    let mut scopes: Vec<String> = Vec::new();
4591    for item in items {
4592        let serde_norway::Value::String(scope) = item else {
4593            return Err(refuse("must be a list of scopes".to_string()));
4594        };
4595        let scope = scope.trim().to_string();
4596        if let Some(&(_, replacement)) = RETIRED_DOOR_SCOPES
4597            .iter()
4598            .find(|(retired, _)| *retired == scope)
4599        {
4600            return Err(RuntimeManifestError::RetiredDoorScope {
4601                field: field.to_string(),
4602                scope,
4603                replacement,
4604            });
4605        }
4606        if !DOOR_SCOPES.contains(&scope.as_str()) {
4607            return Err(refuse(format!(
4608                "'{scope}' is not a door scope; the scopes are: {}",
4609                DOOR_SCOPES.join(", ")
4610            )));
4611        }
4612        if scopes.contains(&scope) {
4613            return Err(refuse(format!("'{scope}' is listed more than once")));
4614        }
4615        scopes.push(scope);
4616    }
4617    Ok(scopes)
4618}
4619
4620/// Refuses `network.authentication` on a capsule that declares `capabilities.spawn.allow`.
4621///
4622/// A delegated child posts its completion to its parent's door, and nothing issues a child a token
4623/// for that door, so an authenticated parent would refuse every completion it asked for.
4624fn refuse_authenticated_delegating_parent(
4625    network: Option<&NetworkConfig>,
4626    capabilities: Option<&Capabilities>,
4627) -> Result<(), RuntimeManifestError> {
4628    let authenticated = network.is_some_and(|network| network.authentication.is_some());
4629    let delegates = capabilities
4630        .and_then(|capabilities| capabilities.spawn.as_ref())
4631        .is_some_and(|spawn| !spawn.allow.is_empty());
4632    if authenticated && delegates {
4633        return Err(RuntimeManifestError::InvalidNetworkConfig {
4634            field: "network.authentication".to_string(),
4635            message: "cannot be declared together with capabilities.spawn.allow: a delegated \
4636                      child posts its outcome to this door, and a delegated child cannot yet be \
4637                      issued a token for this door"
4638                .to_string(),
4639        });
4640    }
4641    Ok(())
4642}
4643
4644/// Lowers the top-level `exports:` block, rejecting anything the runtime would otherwise have to
4645/// decide about later.
4646///
4647/// The `root` rules are checked here and never again by a caller: a gateway that "cleans up" a
4648/// path is a gateway that has taken over the containment boundary, so the only legal answer to a
4649/// root naming somewhere outside the workdir is a refusal.
4650fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
4651    let Some(raw_exports) = raw else {
4652        return Ok(None);
4653    };
4654    let files = raw_exports.files.map(parse_file_export).transpose()?;
4655    let peer_files = raw_exports
4656        .peer_files
4657        .map(parse_peer_files_export)
4658        .transpose()?;
4659    let peer_tasks = raw_exports
4660        .peer_tasks
4661        .map(|raw| {
4662            raw.accept
4663                .map(|accept| PeerTasksExport { accept })
4664                .ok_or_else(|| RuntimeManifestError::InvalidExports {
4665                    field: "exports.peer_tasks.accept".to_string(),
4666                    message: "must be set explicitly to true or false — consent to peer tasks is \
4667                              never inferred"
4668                        .to_string(),
4669                })
4670        })
4671        .transpose()?;
4672    Ok(Some(Exports {
4673        files,
4674        peer_files,
4675        peer_tasks,
4676    }))
4677}
4678
4679/// Lowers `exports.peer_files`, on exactly the same terms as [`parse_file_export`]: a root that
4680/// could name somewhere outside the workdir is refused here and never repaired later.
4681///
4682/// `max_ttl` is left as the operator declared it, including absent. Whether absent is legal
4683/// depends on `lifecycle.after_task`, which is a different block of the same manifest and a
4684/// different kind of question — one about what survives teardown rather than about whether a
4685/// value is well-formed. The runtime asks it, at launch, and refuses with `E-CAP-008`.
4686fn parse_peer_files_export(
4687    raw: RawPeerFilesExport,
4688) -> Result<PeerFilesExport, RuntimeManifestError> {
4689    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4690        field: field.to_string(),
4691        message,
4692    };
4693
4694    let root = raw
4695        .root
4696        .map(|root| root.trim().to_string())
4697        .filter(|root| !root.is_empty())
4698        .ok_or_else(|| {
4699            invalid(
4700                "exports.peer_files.root",
4701                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4702            )
4703        })?;
4704    check_export_root_shape("exports.peer_files.root", &root)?;
4705
4706    let max_ttl_secs = match raw.max_ttl {
4707        None => None,
4708        Some(value) => {
4709            let text = scalar_text(&value).ok_or_else(|| {
4710                invalid(
4711                    "exports.peer_files.max_ttl",
4712                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
4713                )
4714            })?;
4715            let parsed = parse_duration_secs(&text)
4716                .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
4717            if parsed == 0 {
4718                return Err(invalid(
4719                    "exports.peer_files.max_ttl",
4720                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
4721                ));
4722            }
4723            Some(parsed)
4724        }
4725    };
4726
4727    let max_bytes = match raw.max_bytes {
4728        None => DEFAULT_PEER_FILES_MAX_BYTES,
4729        Some(value) => {
4730            let text = scalar_text(&value).ok_or_else(|| {
4731                invalid(
4732                    "exports.peer_files.max_bytes",
4733                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4734                )
4735            })?;
4736            let parsed = parse_byte_size(&text)
4737                .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
4738            if parsed == 0 {
4739                return Err(invalid(
4740                    "exports.peer_files.max_bytes",
4741                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4742                ));
4743            }
4744            parsed
4745        }
4746    };
4747
4748    Ok(PeerFilesExport {
4749        root,
4750        max_ttl_secs,
4751        max_bytes,
4752    })
4753}
4754
4755/// A YAML scalar as the text the byte-size and duration parsers expect, or `None` for a mapping
4756/// or a sequence — a shape neither parser could report on without quoting the whole node.
4757/// `Some` for a key that is present, whatever its value — including an explicit null.
4758///
4759/// The plain `Option<serde_norway::Value>` a `#[serde(default)]` field gets collapses `retain:`
4760/// with nothing under it into the same `None` an absent key produces, and those two say opposite
4761/// things: absent means "keep everything", and empty is refused.
4762fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_norway::Value>, D::Error>
4763where
4764    D: serde::Deserializer<'de>,
4765{
4766    serde_norway::Value::deserialize(deserializer).map(Some)
4767}
4768
4769fn scalar_text(value: &serde_norway::Value) -> Option<String> {
4770    match value {
4771        serde_norway::Value::String(text) => Some(text.clone()),
4772        serde_norway::Value::Number(number) => Some(number.to_string()),
4773        _ => None,
4774    }
4775}
4776
4777/// Why a manifest path that must stay inside the work folder is refused.
4778enum RelativePathRefusal {
4779    ParentDir,
4780    Absolute,
4781}
4782
4783/// The shape rule shared by every manifest path resolved beneath the accessible workdir:
4784/// relative, no `..`, no absolute prefix. `.` components are allowed.
4785fn relative_path_refusal(path: &str) -> Option<RelativePathRefusal> {
4786    Path::new(path)
4787        .components()
4788        .find_map(|component| match component {
4789            std::path::Component::Normal(_) | std::path::Component::CurDir => None,
4790            std::path::Component::ParentDir => Some(RelativePathRefusal::ParentDir),
4791            std::path::Component::RootDir | std::path::Component::Prefix(_) => {
4792                Some(RelativePathRefusal::Absolute)
4793            }
4794        })
4795}
4796
4797/// The shared `root` shape rule: relative, no `..`, no absolute prefix. Both export blocks
4798/// resolve their root against the same accessible workdir, so both refuse the same shapes with
4799/// the same sentence.
4800fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
4801    let message = match relative_path_refusal(root) {
4802        None => return Ok(()),
4803        Some(RelativePathRefusal::ParentDir) => {
4804            format!("'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}")
4805        }
4806        Some(RelativePathRefusal::Absolute) => {
4807            format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}")
4808        }
4809    };
4810    Err(RuntimeManifestError::InvalidExports {
4811        field: field.to_string(),
4812        message,
4813    })
4814}
4815
4816const INSTRUCTION_FILE_ACCEPTED_FORM: &str = "must be a relative path inside the work folder";
4817
4818/// Checks every `inference.instruction_files` entry and keeps the list as written, in order. The
4819/// runtime resolves each entry beneath the accessible work folder at staging, so a shape that
4820/// could only name a path outside it is refused here, before anything is staged.
4821fn parse_instruction_files(entries: Vec<String>) -> Result<Vec<String>, RuntimeManifestError> {
4822    for (index, entry) in entries.iter().enumerate() {
4823        let problem = if entry.trim().is_empty() {
4824            Some("is empty")
4825        } else {
4826            match relative_path_refusal(entry) {
4827                None => None,
4828                Some(RelativePathRefusal::ParentDir) => Some("contains a '..' component"),
4829                Some(RelativePathRefusal::Absolute) => Some("is absolute"),
4830            }
4831        };
4832        if let Some(problem) = problem {
4833            return Err(RuntimeManifestError::InvalidInferenceConfig {
4834                field: format!("inference.instruction_files[{index}]"),
4835                message: format!("'{entry}' {problem}; {INSTRUCTION_FILE_ACCEPTED_FORM}"),
4836            });
4837        }
4838    }
4839    Ok(entries)
4840}
4841
4842fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
4843    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4844        field: field.to_string(),
4845        message,
4846    };
4847
4848    let root = raw
4849        .root
4850        .map(|root| root.trim().to_string())
4851        .filter(|root| !root.is_empty())
4852        .ok_or_else(|| {
4853            invalid(
4854                "exports.files.root",
4855                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4856            )
4857        })?;
4858    check_export_root_shape("exports.files.root", &root)?;
4859
4860    let mode = match raw.mode.as_deref().map(str::trim) {
4861        None | Some("") => {
4862            return Err(invalid(
4863                "exports.files.mode",
4864                "is required and must be 'read-only'".to_string(),
4865            ));
4866        }
4867        Some("read-only") => ExportMode::ReadOnly,
4868        Some(other) => {
4869            return Err(invalid(
4870                "exports.files.mode",
4871                format!("'{other}' must be 'read-only'"),
4872            ));
4873        }
4874    };
4875
4876    let max_bytes = match raw.max_bytes {
4877        None => DEFAULT_EXPORT_MAX_BYTES,
4878        Some(value) => {
4879            let Some(text) = scalar_text(&value) else {
4880                return Err(invalid(
4881                    "exports.files.max_bytes",
4882                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4883                ));
4884            };
4885            let parsed = parse_byte_size(&text)
4886                .map_err(|message| invalid("exports.files.max_bytes", message))?;
4887            if parsed == 0 {
4888                return Err(invalid(
4889                    "exports.files.max_bytes",
4890                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4891                ));
4892            }
4893            parsed
4894        }
4895    };
4896
4897    Ok(FileExport {
4898        root,
4899        mode,
4900        max_bytes,
4901    })
4902}
4903
4904/// The accepted spelling of `exports.files.root`, stated once so every rejection says the same
4905/// thing about what a legal root looks like.
4906const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
4907
4908/// The accepted spelling of `capabilities.peer_fetch.allow`, stated once.
4909pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
4910
4911/// Structural check on one network destination: an `http`/`https` URL with no path, query or
4912/// fragment, or a bare `host[:port]`.
4913///
4914/// Deliberately shape-only. The authoritative matcher is the runtime's network-rule parser, which
4915/// this crate cannot see; what this catches is the class of entry that could never become a rule
4916/// at all, at the line that wrote it rather than at launch.
4917fn check_network_destination_shape(entry: &str) -> Result<(), String> {
4918    let trimmed = entry.trim();
4919    if trimmed.is_empty() {
4920        return Err(format!(
4921            "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4922        ));
4923    }
4924
4925    if trimmed.contains("://") {
4926        let url = Url::parse(trimmed).map_err(|error| {
4927            format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
4928        })?;
4929        let scheme = url.scheme().to_ascii_lowercase();
4930        if scheme != "http" && scheme != "https" {
4931            return Err(format!(
4932                "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
4933                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4934            ));
4935        }
4936        if url.host_str().is_none_or(str::is_empty) {
4937            return Err(format!(
4938                "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4939            ));
4940        }
4941        if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
4942            return Err(format!(
4943                "'{entry}' must not include a path, query or fragment; \
4944                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4945            ));
4946        }
4947        return Ok(());
4948    }
4949
4950    if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
4951        return Err(format!(
4952            "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4953        ));
4954    }
4955    // Split from the right so an IPv6 literal's own colons are not mistaken for a port separator.
4956    if let Some((host, port)) = trimmed.rsplit_once(':') {
4957        if !host.ends_with(']') {
4958            if host.is_empty() {
4959                return Err(format!(
4960                    "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4961                ));
4962            }
4963            if port.parse::<u16>().is_err() {
4964                return Err(format!(
4965                    "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4966                ));
4967            }
4968        }
4969    }
4970    Ok(())
4971}
4972
4973/// The `~/.murmur/config.yaml` key that lends contained capsules a host folder, refused wherever a
4974/// manifest writes it.
4975pub const HOST_PATHS_KEY: &str = "host_paths";
4976
4977/// Refuses [`HOST_PATHS_KEY`] at the top level, under `capabilities` and `capabilities.filesystem`,
4978/// and under each artifact entry's `capabilities` and `capabilities.filesystem`. No field claims
4979/// the key, so it is found in each block's unknown-key map before any warning is built from it.
4980fn check_no_host_paths(raw: &RawRuntimeManifest) -> Result<(), RuntimeManifestError> {
4981    fn in_capabilities(capabilities: Option<&RawCapabilities>, prefix: &str) -> Option<String> {
4982        let capabilities = capabilities?;
4983        if capabilities.unknown.contains_key(HOST_PATHS_KEY) {
4984            return Some(format!("{prefix}.{HOST_PATHS_KEY}"));
4985        }
4986        capabilities
4987            .filesystem
4988            .as_ref()
4989            .filter(|filesystem| filesystem.unknown.contains_key(HOST_PATHS_KEY))
4990            .map(|_| format!("{prefix}.filesystem.{HOST_PATHS_KEY}"))
4991    }
4992    let field = if raw.unknown.contains_key(HOST_PATHS_KEY) {
4993        Some(HOST_PATHS_KEY.to_string())
4994    } else {
4995        in_capabilities(raw.capabilities.as_ref(), "capabilities").or_else(|| {
4996            raw.artifacts
4997                .iter()
4998                .enumerate()
4999                .find_map(|(index, artifact)| {
5000                    in_capabilities(
5001                        artifact.capabilities.as_ref(),
5002                        &format!("artifacts[{index}].capabilities"),
5003                    )
5004                })
5005        })
5006    };
5007    match field {
5008        Some(field) => Err(RuntimeManifestError::HostPathsDeclared { field }),
5009        None => Ok(()),
5010    }
5011}
5012
5013fn parse_capabilities(
5014    raw: Option<RawCapabilities>,
5015) -> Result<Option<Capabilities>, RuntimeManifestError> {
5016    let Some(raw_caps) = raw else {
5017        return Ok(None);
5018    };
5019
5020    let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
5021        allow: raw_network.allow,
5022        unix_sockets: raw_network.unix_sockets,
5023    });
5024
5025    let peer_fetch = raw_caps
5026        .peer_fetch
5027        .map(|raw_peer_fetch| {
5028            if raw_peer_fetch.allow.is_empty() {
5029                return Err(RuntimeManifestError::InvalidCapabilities {
5030                    field: "capabilities.peer_fetch.allow".to_string(),
5031                    message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
5032                });
5033            }
5034            for entry in &raw_peer_fetch.allow {
5035                check_network_destination_shape(entry).map_err(|message| {
5036                    RuntimeManifestError::InvalidCapabilities {
5037                        field: "capabilities.peer_fetch.allow".to_string(),
5038                        message,
5039                    }
5040                })?;
5041            }
5042            Ok(PeerFetchCapabilities {
5043                allow: raw_peer_fetch.allow,
5044            })
5045        })
5046        .transpose()?;
5047
5048    let filesystem = raw_caps
5049        .filesystem
5050        .map(|raw_filesystem| FilesystemCapabilities {
5051            scope: raw_filesystem.scope.and_then(|scope| {
5052                let trimmed = scope.trim();
5053                (!trimmed.is_empty()).then(|| trimmed.to_string())
5054            }),
5055            // Copied straight across: a bool has no empty/whitespace case to normalise, and there
5056            // is nothing to validate here — the *consequence* of declaring it (no `scoped`, and a
5057            // refusal when `capabilities.containment: scoped` is also declared) belongs to the
5058            // runtime's containment layer, not to manifest parsing.
5059            workdir_exec: raw_filesystem.workdir_exec,
5060            // Normalised on exactly the terms `scope` above is: trimmed, and an entry left empty
5061            // by that trim is dropped rather than carried as a rule covering nothing. Shape is
5062            // not judged here either — an absolute or escaping entry is the runtime's refusal.
5063            read_only: raw_filesystem
5064                .read_only
5065                .into_iter()
5066                .filter_map(|entry| {
5067                    let trimmed = entry.trim();
5068                    (!trimmed.is_empty()).then(|| trimmed.to_string())
5069                })
5070                .collect(),
5071            // Where it is recognized is decided by the caller, which knows the entry's role.
5072            records: raw_filesystem.records,
5073        });
5074
5075    let shell = raw_caps
5076        .shell
5077        .map(|raw_shell| {
5078            if raw_shell.allow.is_empty() {
5079                return Err(RuntimeManifestError::InvalidCapabilities {
5080                    field: "capabilities.shell.allow".to_string(),
5081                    message: "must contain at least one binary".to_string(),
5082                });
5083            }
5084            for (index, entry) in raw_shell.allow.iter().enumerate() {
5085                if let Some(reason) = shell_allow_entry_error(entry) {
5086                    return Err(RuntimeManifestError::ShellAllowEntry {
5087                        index,
5088                        entry: entry.clone(),
5089                        reason,
5090                    });
5091                }
5092            }
5093
5094            let interpreter_runtime =
5095                parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
5096            // Parsed after `interpreter_runtime` because the mutual-exclusion rule needs the
5097            // already-validated grant list to test against.
5098            let staged_runtime = parse_staged_runtime(
5099                &raw_shell.allow,
5100                &interpreter_runtime,
5101                raw_shell.staged_runtime,
5102            )?;
5103
5104            Ok(ShellCapabilities {
5105                allow: raw_shell.allow,
5106                strip_env: raw_shell.strip_env,
5107                baseline_env: raw_shell.baseline_env,
5108                interpreter_runtime,
5109                staged_runtime,
5110            })
5111        })
5112        .transpose()?;
5113
5114    let spawn = raw_caps
5115        .spawn
5116        .map(|raw_spawn| parse_spawn_allow(raw_spawn.allow, raw_spawn.workdir_snapshot))
5117        .transpose()?;
5118
5119    let env = raw_caps.env.map(|raw_env| EnvCapabilities {
5120        allow: raw_env.allow,
5121    });
5122
5123    let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
5124
5125    let resources = raw_caps
5126        .resources
5127        .map(parse_resource_capabilities)
5128        .transpose()?;
5129
5130    let state = raw_caps.state.map(parse_state_capabilities);
5131
5132    let task_io = raw_caps
5133        .task_io
5134        .map(|raw_task_io| {
5135            raw_task_io
5136                .read
5137                .map(|read| TaskIoCapabilities { read })
5138                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5139                    field: "capabilities.task_io.read".to_string(),
5140                    message: "must be set explicitly to true or false — a capability is never \
5141                              inferred"
5142                        .to_string(),
5143                })
5144        })
5145        .transpose()?;
5146
5147    let conversation = raw_caps
5148        .conversation
5149        .map(|raw_conversation| {
5150            raw_conversation
5151                .read
5152                .map(|read| ConversationCapabilities { read })
5153                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5154                    field: "capabilities.conversation.read".to_string(),
5155                    message: "must be set explicitly to true or false — a capability is never \
5156                              inferred"
5157                        .to_string(),
5158                })
5159        })
5160        .transpose()?;
5161
5162    let plan = raw_caps
5163        .plan
5164        .map(|raw_plan| {
5165            raw_plan
5166                .submit
5167                .map(|submit| PlanCapabilities { submit })
5168                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5169                    field: "capabilities.plan.submit".to_string(),
5170                    message: "must be set explicitly to true or false — a capability is never \
5171                              inferred"
5172                        .to_string(),
5173                })
5174        })
5175        .transpose()?;
5176
5177    let install = raw_caps.install.map(parse_install).transpose()?;
5178    let harness = raw_caps.harness.map(parse_harness).transpose()?;
5179
5180    let containment = raw_caps
5181        .containment
5182        .as_deref()
5183        .map(str::trim)
5184        .filter(|value| !value.is_empty())
5185        .map(|value| {
5186            let class = value.parse::<ContainmentClass>().map_err(|error| {
5187                RuntimeManifestError::InvalidCapabilities {
5188                    field: "capabilities.containment".to_string(),
5189                    message: error.to_string(),
5190                }
5191            })?;
5192            if class == ContainmentClass::Unconfined {
5193                return Err(RuntimeManifestError::InvalidCapabilities {
5194                    field: "capabilities.containment".to_string(),
5195                    message: UNCONFINED_NOT_DECLARABLE.to_string(),
5196                });
5197            }
5198            Ok(class)
5199        })
5200        .transpose()?;
5201
5202    Ok(Some(Capabilities {
5203        network,
5204        peer_fetch,
5205        filesystem,
5206        shell,
5207        spawn,
5208        env,
5209        limits,
5210        resources,
5211        state,
5212        task_io,
5213        conversation,
5214        plan,
5215        install,
5216        harness,
5217        containment,
5218    }))
5219}
5220
5221/// Lower and validate `capabilities.harness`: every entry a `{path, access}` whose `path` is a
5222/// directory below `~/` or below `/` ([`harness_path_shape_error`]) and whose `access` is `read`
5223/// or `read-write`. The lexical fold and the host-folder checks happen at staging; this is the
5224/// parse-time shape check.
5225fn parse_harness(raw: RawHarnessCapabilities) -> Result<HarnessCapabilities, RuntimeManifestError> {
5226    let mut paths = Vec::with_capacity(raw.paths.len());
5227    for (index, entry) in raw.paths.into_iter().enumerate() {
5228        let base = format!("capabilities.harness.paths[{index}]");
5229        let path = entry.path.filter(|p| !p.trim().is_empty()).ok_or_else(|| {
5230            RuntimeManifestError::InvalidCapabilities {
5231                field: format!("{base}.path"),
5232                message: "must name a host directory (`~/…` or an absolute path)".to_string(),
5233            }
5234        })?;
5235        if let Some(reason) = harness_path_shape_error(&path) {
5236            return Err(RuntimeManifestError::InvalidCapabilities {
5237                field: format!("{base}.path"),
5238                message: format!("'{path}' {reason}"),
5239            });
5240        }
5241        let access = match entry.access.as_deref() {
5242            None => {
5243                return Err(RuntimeManifestError::InvalidCapabilities {
5244                    field: format!("{base}.access"),
5245                    message: "access is required: write `access: read` or `access: read-write`"
5246                        .to_string(),
5247                })
5248            }
5249            Some(value) => HarnessPathAccess::parse(value).ok_or_else(|| {
5250                RuntimeManifestError::InvalidCapabilities {
5251                    field: format!("{base}.access"),
5252                    message: format!("access '{value}' is neither `read` nor `read-write`"),
5253                }
5254            })?,
5255        };
5256        paths.push(HarnessPathEntry { path, access });
5257    }
5258    Ok(HarnessCapabilities { paths })
5259}
5260
5261/// Why a harness path has the wrong shape, or `None` for a directory named below `~/` or below
5262/// `/`: no `.` or `..` segment, no empty segment from `//` (a trailing `/` is allowed), and not
5263/// `/`, `~` or `~/` alone. One rule for both sides of a harness grant: a
5264/// `capabilities.harness.paths` entry and a process driver's path need. An empty path is the
5265/// caller's to refuse.
5266#[must_use]
5267pub fn harness_path_shape_error(path: &str) -> Option<&'static str> {
5268    let rest = if path == "~" || path == "~/" {
5269        return Some("names the whole home folder; name the folder the harness needs");
5270    } else if let Some(rest) = path.strip_prefix("~/") {
5271        rest
5272    } else if path == "/" {
5273        return Some("names /, the whole host filesystem; name the folder the harness needs");
5274    } else if let Some(rest) = path.strip_prefix('/') {
5275        rest
5276    } else {
5277        return Some(
5278            "is relative: write an absolute path or ~/… (~user and environment variables are not \
5279             expanded)",
5280        );
5281    };
5282    let rest = rest.strip_suffix('/').unwrap_or(rest);
5283    for segment in rest.split('/') {
5284        match segment {
5285            "" => return Some("has an empty path segment (//)"),
5286            "." | ".." => return Some("has a . or .. segment; write the folder's own path"),
5287            _ => {}
5288        }
5289    }
5290    None
5291}
5292
5293/// Lower `capabilities.spawn.allow`: every entry a full name, no two sharing a bare part —
5294/// `delegate-task` offers the model the bare parts, so each must pick out one capsule — and
5295/// `capabilities.spawn.workdir_snapshot`: every entry a full name, listed once and also in `allow`.
5296fn parse_spawn_allow(
5297    allow: Vec<String>,
5298    workdir_snapshot: Vec<String>,
5299) -> Result<SpawnCapabilities, RuntimeManifestError> {
5300    let mut parsed: Vec<ArtifactName> = Vec::with_capacity(allow.len());
5301    for (index, entry) in allow.iter().enumerate() {
5302        let name = ArtifactName::parse(entry).map_err(|error| {
5303            RuntimeManifestError::InvalidArtifactName {
5304                field: format!("capabilities.spawn.allow[{index}]"),
5305                message: error.to_string(),
5306                hint: error.hint(),
5307            }
5308        })?;
5309        if let Some(earlier) = parsed
5310            .iter()
5311            .find(|earlier| earlier.name() == name.name() && **earlier != name)
5312        {
5313            return Err(RuntimeManifestError::InvalidArtifactName {
5314                field: format!("capabilities.spawn.allow[{index}]"),
5315                message: format!(
5316                    "'{earlier}' and '{name}' share the name '{}', which is how delegate-task \
5317                     offers a capsule to the model",
5318                    name.name()
5319                ),
5320                hint: Some(format!(
5321                    "keep one of '{earlier}' and '{name}' in capabilities.spawn.allow"
5322                )),
5323            });
5324        }
5325        parsed.push(name);
5326    }
5327    let mut snapshotted: Vec<ArtifactName> = Vec::with_capacity(workdir_snapshot.len());
5328    for (index, entry) in workdir_snapshot.iter().enumerate() {
5329        let field = format!("capabilities.spawn.workdir_snapshot[{index}]");
5330        let name = ArtifactName::parse(entry).map_err(|error| {
5331            RuntimeManifestError::InvalidArtifactName {
5332                field: field.clone(),
5333                message: error.to_string(),
5334                hint: error.hint(),
5335            }
5336        })?;
5337        if snapshotted.contains(&name) {
5338            return Err(RuntimeManifestError::InvalidArtifactName {
5339                field,
5340                message: format!("'{name}' is listed more than once"),
5341                hint: Some(format!(
5342                    "keep one '{name}' in capabilities.spawn.workdir_snapshot"
5343                )),
5344            });
5345        }
5346        if !parsed.contains(&name) {
5347            return Err(RuntimeManifestError::InvalidArtifactName {
5348                field,
5349                message: format!(
5350                    "'{name}' is not in capabilities.spawn.allow, so this capsule cannot \
5351                     delegate to it"
5352                ),
5353                hint: Some(format!(
5354                    "add '{name}' to capabilities.spawn.allow, or remove it from \
5355                     capabilities.spawn.workdir_snapshot"
5356                )),
5357            });
5358        }
5359        snapshotted.push(name);
5360    }
5361    Ok(SpawnCapabilities {
5362        allow,
5363        workdir_snapshot,
5364    })
5365}
5366
5367/// Refuse an `artifacts[index].name` of the wrong form: a full name for an entry that resolves,
5368/// a bare name for a `source:` entry, which is never resolved or locked.
5369fn check_entry_name(
5370    index: usize,
5371    name: &str,
5372    has_source: bool,
5373) -> Result<(), RuntimeManifestError> {
5374    let field = format!("artifacts[{index}].name");
5375    if !has_source {
5376        return ArtifactName::parse(name).map(|_| ()).map_err(|error| {
5377            RuntimeManifestError::InvalidArtifactName {
5378                field,
5379                message: error.to_string(),
5380                hint: error.hint(),
5381            }
5382        });
5383    }
5384    if let Some((_, bare)) = name.split_once('/') {
5385        return Err(RuntimeManifestError::InvalidArtifactName {
5386            field,
5387            message: format!(
5388                "'{name}' declares 'source:', so it is named by its bare name; a scope says \
5389                 where a resolved artifact comes from, and a source: entry is never resolved"
5390            ),
5391            hint: Some(format!("drop the scope: name it '{bare}'")),
5392        });
5393    }
5394    match artifact_name_format_error(name) {
5395        Some(reason) => Err(RuntimeManifestError::InvalidArtifactName {
5396            field,
5397            message: format!("'{name}' is not a valid artifact name: it {reason}"),
5398            hint: None,
5399        }),
5400        None => Ok(()),
5401    }
5402}
5403
5404/// The refusal of a reference to an artifact `artifacts:` does not declare. When exactly one
5405/// declared entry has `name` as its bare part, the message names that entry, the reference the
5406/// operator most likely meant.
5407fn not_declared_message(artifacts: &[RuntimeArtifact], name: &str) -> String {
5408    let mut same_bare = artifacts
5409        .iter()
5410        .filter(|artifact| artifact.name != name && artifact.tool_name() == tool_name_of(name));
5411    match (same_bare.next(), same_bare.next()) {
5412        (Some(only), None) => format!(
5413            "artifact '{name}' is not declared in artifacts: (the declared '{}' has that name; \
5414             refer to it as '{}')",
5415            only.name, only.name
5416        ),
5417        _ => format!("artifact '{name}' is not declared in artifacts:"),
5418    }
5419}
5420
5421/// Lower `capabilities.install`, refusing a block that names nothing and any entry outside
5422/// [`crate::install_pattern`]'s grammar.
5423fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
5424    if raw.skill.is_empty() && raw.tool.is_empty() {
5425        return Err(RuntimeManifestError::InvalidCapabilities {
5426            field: "capabilities.install".to_string(),
5427            message: "must name at least one 'skill' or 'tool' entry — a capability is never \
5428                      inferred; omit the block to grant no install at all"
5429                .to_string(),
5430        });
5431    }
5432    for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
5433        for (index, entry) in entries.iter().enumerate() {
5434            crate::install_pattern::validate(entry).map_err(|error| {
5435                RuntimeManifestError::InvalidArtifactName {
5436                    field: format!("capabilities.install.{key}[{index}]"),
5437                    message: format!(
5438                        "entry {entry:?} is invalid ({}); expected {}",
5439                        error.reason,
5440                        crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
5441                    ),
5442                    hint: error.hint,
5443                }
5444            })?;
5445        }
5446    }
5447    let from = match raw.from {
5448        None => InstallFrom::Stores,
5449        Some(list) => parse_install_from(&list)?,
5450    };
5451    Ok(InstallCapabilities {
5452        skill: raw.skill,
5453        tool: raw.tool,
5454        from,
5455    })
5456}
5457
5458/// Refuse `capabilities.install` on a `transport: process` capsule: the pull and remove tools are
5459/// offered through the http agent loop's tool array, which that transport never sends.
5460fn refuse_install_under_process_transport(
5461    capabilities: Option<&Capabilities>,
5462    inference: Option<&InferenceConfig>,
5463) -> Result<(), RuntimeManifestError> {
5464    let installs = capabilities.is_some_and(|capabilities| capabilities.install.is_some());
5465    let process = inference.is_some_and(|inference| inference.transport == "process");
5466    if installs && process {
5467        return Err(RuntimeManifestError::InvalidCapabilities {
5468            field: "capabilities.install".to_string(),
5469            message: "is not valid with transport: process; the harness owns its tool list, so a \
5470                      pulled artifact could never reach it"
5471                .to_string(),
5472        });
5473    }
5474    Ok(())
5475}
5476
5477/// The values `capabilities.install.from` accepts, as its refusals name them.
5478const INSTALL_FROM_ACCEPTED: &str = "[stores] or [stores, sources]";
5479
5480/// Lower `capabilities.install.from`: `stores` is required, `sources` optional, each at most
5481/// once and in either order.
5482fn parse_install_from(list: &[String]) -> Result<InstallFrom, RuntimeManifestError> {
5483    let refuse = |message: String| RuntimeManifestError::InvalidCapabilities {
5484        field: "capabilities.install.from".to_string(),
5485        message: format!("{message}; expected {INSTALL_FROM_ACCEPTED}"),
5486    };
5487    if list.is_empty() {
5488        return Err(refuse("is empty".to_string()));
5489    }
5490    let (mut stores, mut sources) = (false, false);
5491    for entry in list {
5492        let seen = match entry.as_str() {
5493            "stores" => &mut stores,
5494            "sources" => &mut sources,
5495            other => return Err(refuse(format!("names {other:?}, which is not a place"))),
5496        };
5497        if *seen {
5498            return Err(refuse(format!("names {entry:?} twice")));
5499        }
5500        *seen = true;
5501    }
5502    if !stores {
5503        return Err(refuse(
5504            "does not name stores, which every pull is answered from first".to_string(),
5505        ));
5506    }
5507    Ok(if sources {
5508        InstallFrom::StoresAndSources
5509    } else {
5510        InstallFrom::Stores
5511    })
5512}
5513
5514/// Lower `capabilities.state`. Infallible: the only field is a name, and a name's *shape* is a
5515/// runtime question (does it resolve to one directory under `~/.murmur/state/`?) rather than a
5516/// manifest-parse one — `capsule_runtime::state_store::validate_store_name` answers it and refuses
5517/// with `E-CAP-009`.
5518///
5519/// Surrounding whitespace is trimmed but an emptied value is *kept*, unlike
5520/// `capabilities.filesystem.scope`, which normalises empty to absent. `store: ""` is a written
5521/// declaration that names nothing, and silently defaulting it to the capsule name would hand a
5522/// store to someone who asked for a different one; it reaches the runtime and is refused there.
5523fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
5524    StateCapabilities {
5525        store: raw.store.map(|store| store.trim().to_string()),
5526    }
5527}
5528
5529/// Why `entry` is not a `capabilities.shell.allow` entry, which must be one path component: the
5530/// binary's name, looked up on `PATH`, and the name of the tool the model calls. `None` for a
5531/// bare binary name.
5532fn shell_allow_entry_error(entry: &str) -> Option<&'static str> {
5533    if entry.is_empty() {
5534        Some("is empty")
5535    } else if entry.contains('\0') {
5536        Some("contains a NUL byte")
5537    } else if entry.contains('/') {
5538        Some("is a path")
5539    } else if entry == "." || entry == ".." {
5540        Some("names a directory")
5541    } else if entry.chars().any(char::is_whitespace) {
5542        Some("contains whitespace")
5543    } else {
5544        None
5545    }
5546}
5547
5548/// Lower and validate `capabilities.shell.interpreter_runtime`. Every rejection is an
5549/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending value, matching the rest
5550/// of this file's "structurally present but semantically invalid" reporting. Nothing here can
5551/// expand a whole install prefix: each grant names specific directories, and each directory
5552/// carries an explicit `list_dir` the author had to write — enumerability is never inferred.
5553fn parse_interpreter_runtime(
5554    allow: &[String],
5555    raw_grants: Vec<RawInterpreterRuntimeGrant>,
5556) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
5557    let mut grants = Vec::with_capacity(raw_grants.len());
5558    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5559        let base = format!("capabilities.shell.interpreter_runtime[{index}]");
5560
5561        let binary = raw_grant
5562            .binary
5563            .filter(|b| !b.trim().is_empty())
5564            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5565                field: format!("{base}.binary"),
5566                message: "must name a binary".to_string(),
5567            })?;
5568
5569        // The whole point of this mechanism: it narrows filesystem access alongside an exec
5570        // grant that already exists. It can never itself grant exec, so the binary must already
5571        // be allowlisted in this same block's `allow`.
5572        if !allow.contains(&binary) {
5573            return Err(RuntimeManifestError::InvalidCapabilities {
5574                field: format!("{base}.binary"),
5575                message: format!(
5576                    "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
5577                     only narrow filesystem access for an already-allowlisted binary, never \
5578                     grant exec"
5579                ),
5580            });
5581        }
5582
5583        if raw_grant.dirs.is_empty() {
5584            return Err(RuntimeManifestError::InvalidCapabilities {
5585                field: format!("{base}.dirs"),
5586                message: "must name at least one directory".to_string(),
5587            });
5588        }
5589
5590        let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
5591        for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
5592            let dir_base = format!("{base}.dirs[{dir_index}]");
5593
5594            let path = raw_dir
5595                .path
5596                .filter(|p| !p.trim().is_empty())
5597                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5598                    field: format!("{dir_base}.path"),
5599                    message: "must name a host directory".to_string(),
5600                })?;
5601            if !path.starts_with('/') {
5602                return Err(RuntimeManifestError::InvalidCapabilities {
5603                    field: format!("{dir_base}.path"),
5604                    message: format!(
5605                        "'{path}' must be an absolute host path (start with '/') — these are \
5606                         filesystem paths outside the workdir"
5607                    ),
5608                });
5609            }
5610
5611            let list_dir =
5612                raw_dir
5613                    .list_dir
5614                    .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5615                        field: format!("{dir_base}.list_dir"),
5616                        message: format!(
5617                            "'{path}' must set list_dir explicitly to true or false — \
5618                         enumerability is never inferred"
5619                        ),
5620                    })?;
5621
5622            dirs.push(InterpreterRuntimeDir { path, list_dir });
5623        }
5624
5625        grants.push(InterpreterRuntimeGrant { binary, dirs });
5626    }
5627
5628    Ok(grants)
5629}
5630
5631/// Lower and validate `capabilities.shell.staged_runtime`, given this same block's `allow` list
5632/// and its already-validated `interpreter_runtime` grants. Every rejection is a
5633/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending field, matching
5634/// [`parse_interpreter_runtime`] and the rest of this file.
5635///
5636/// Four rules, each of them a thing that would otherwise be silently wrong at launch rather than
5637/// loudly wrong here:
5638///
5639///   1. `binary` must already be in `allow` — staging a runtime tree never itself grants exec.
5640///   2. `binary` must not also carry an `interpreter_runtime` grant — the two are alternatives,
5641///      and declaring both means the author expects a host-widening grant that a composed root
5642///      makes both unnecessary and (once Landlock re-installs inside the root) meaningless.
5643///   3. `source_path` must be absolute — it is a host path outside the workdir, resolved by the
5644///      launch host and not relative to anything the runtime knows.
5645///   4. `pin` must be present and non-empty — it is the value a human compares across two hosts,
5646///      so an absent one defeats the field's only purpose.
5647///
5648/// Nothing here touches the filesystem: whether `source_path` exists on *this* host is a launch
5649/// -time fact, not a manifest-validity one, and a manifest must stay parseable on a machine that
5650/// will never run it (`mur build` on a laptop for a Linux fleet).
5651fn parse_staged_runtime(
5652    allow: &[String],
5653    interpreter_runtime: &[InterpreterRuntimeGrant],
5654    raw_grants: Vec<RawStagedRuntimeGrant>,
5655) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
5656    let mut grants = Vec::with_capacity(raw_grants.len());
5657    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5658        let base = format!("capabilities.shell.staged_runtime[{index}]");
5659
5660        let binary = raw_grant
5661            .binary
5662            .filter(|b| !b.trim().is_empty())
5663            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5664                field: format!("{base}.binary"),
5665                message: "must name a binary".to_string(),
5666            })?;
5667
5668        if !allow.contains(&binary) {
5669            return Err(RuntimeManifestError::InvalidCapabilities {
5670                field: format!("{base}.binary"),
5671                message: format!(
5672                    "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
5673                     stage the runtime tree behind an already-allowlisted binary, never grant exec"
5674                ),
5675            });
5676        }
5677
5678        if interpreter_runtime
5679            .iter()
5680            .any(|grant| grant.binary == binary)
5681        {
5682            return Err(RuntimeManifestError::InvalidCapabilities {
5683                field: format!("{base}.binary"),
5684                message: format!(
5685                    "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
5686                     are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
5687                     tree into the capsule's own root, which is what makes widening the capsule's \
5688                     host filesystem scope unnecessary rather than something to add to it. Remove \
5689                     the interpreter_runtime grant for '{binary}'"
5690                ),
5691            });
5692        }
5693
5694        let source_path = raw_grant
5695            .source_path
5696            .filter(|p| !p.trim().is_empty())
5697            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5698                field: format!("{base}.source_path"),
5699                message: "must name the host directory holding the pinned runtime tree".to_string(),
5700            })?;
5701        if !source_path.starts_with('/') {
5702            return Err(RuntimeManifestError::InvalidCapabilities {
5703                field: format!("{base}.source_path"),
5704                message: format!(
5705                    "'{source_path}' must be an absolute host path (start with '/') — \
5706                     staged-runtime sources are host filesystem paths outside the workdir"
5707                ),
5708            });
5709        }
5710
5711        let pin = raw_grant
5712            .pin
5713            .map(|p| p.trim().to_string())
5714            .filter(|p| !p.is_empty())
5715            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5716                field: format!("{base}.pin"),
5717                message: format!(
5718                    "'{source_path}' must declare a non-empty pin identifying which build this \
5719                     tree is — it is never inferred, because it is the value a human compares \
5720                     across two hosts to confirm the same runtime shipped to both"
5721                ),
5722            })?;
5723
5724        grants.push(StagedRuntimeGrant {
5725            binary,
5726            source_path,
5727            pin,
5728        });
5729    }
5730
5731    Ok(grants)
5732}
5733
5734/// Lower `capabilities.limits`, rejecting values that can only ever produce a guest that
5735/// traps on its first instruction. A zero cap is always a manifest authoring mistake, and
5736/// catching it here means `mur run` fails at parse time with a field name rather than
5737/// surfacing much later as an opaque wasm trap.
5738fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
5739    let reject_zero_usize =
5740        |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
5741            match value {
5742                Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5743                    field: format!("capabilities.limits.{field}"),
5744                    message: "must be greater than zero".to_string(),
5745                }),
5746                _ => Ok(()),
5747            }
5748        };
5749
5750    reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
5751    reject_zero_usize(raw.table_elements, "table_elements")?;
5752    reject_zero_usize(raw.instances, "instances")?;
5753    if raw.deadline_seconds == Some(0) {
5754        return Err(RuntimeManifestError::InvalidCapabilities {
5755            field: "capabilities.limits.deadline_seconds".to_string(),
5756            message: "must be greater than zero".to_string(),
5757        });
5758    }
5759
5760    Ok(ResourceLimits {
5761        memory_bytes: raw.memory_bytes,
5762        table_elements: raw.table_elements,
5763        instances: raw.instances,
5764        deadline_seconds: raw.deadline_seconds,
5765    })
5766}
5767
5768/// Lower and validate `capabilities.resources`. Mirrors [`parse_resource_limits`]: nothing is
5769/// defaulted here (the runtime owns the defaults, so "omitted" must stay distinguishable from
5770/// "declared"), and a declared `0` is rejected outright on every field — a zero ceiling is
5771/// never what an author means, and letting it through would turn a typo into a subprocess that
5772/// cannot fork, open a file, or run for a single CPU-second.
5773fn parse_resource_capabilities(
5774    raw: RawResourceCapabilities,
5775) -> Result<ResourceCapabilities, RuntimeManifestError> {
5776    let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
5777        match value {
5778            Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5779                field: format!("capabilities.resources.{field}"),
5780                message: "must be greater than zero".to_string(),
5781            }),
5782            _ => Ok(()),
5783        }
5784    };
5785
5786    reject_zero(raw.max_processes, "max_processes")?;
5787    reject_zero(raw.max_open_files, "max_open_files")?;
5788    reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
5789    reject_zero(raw.cpu_seconds, "cpu_seconds")?;
5790    reject_zero(raw.memory_bytes, "memory_bytes")?;
5791    reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
5792    reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
5793    reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
5794    reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
5795    reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
5796
5797    Ok(ResourceCapabilities {
5798        max_processes: raw.max_processes,
5799        max_open_files: raw.max_open_files,
5800        max_file_size_bytes: raw.max_file_size_bytes,
5801        cpu_seconds: raw.cpu_seconds,
5802        memory_bytes: raw.memory_bytes,
5803        cgroup_memory_bytes: raw.cgroup_memory_bytes,
5804        cgroup_pids_max: raw.cgroup_pids_max,
5805        cgroup_cpu_percent: raw.cgroup_cpu_percent,
5806        cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
5807        workdir_max_bytes: raw.workdir_max_bytes,
5808    })
5809}
5810
5811/// Why a `transport: process` manifest with no `inference.driver` is refused. Names one driver
5812/// artifact by way of example: this is the one message a reader reaches with nothing to go on, and
5813/// an abstract instruction to "name a process driver" leaves them no way to find one.
5814const PROCESS_DRIVER_REQUIRED: &str =
5815    "missing required field; a transport: process capsule must name the process driver that \
5816     knows how to drive its harness. inference.command no longer selects a harness — it only \
5817     overrides the binary the driver names. Add inference.driver.artifact naming a process \
5818     driver, for example murmur/driver-claude-code";
5819
5820fn parse_inference(
5821    raw: Option<RawInferenceConfig>,
5822) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
5823    let Some(raw) = raw else {
5824        return Ok(None);
5825    };
5826
5827    let transport = raw
5828        .transport
5829        .map(|s| s.trim().to_string())
5830        .filter(|s| !s.is_empty())
5831        .unwrap_or_else(|| "http".to_string());
5832
5833    let compaction = parse_compaction(raw.compaction)?;
5834    let system_prompt = optional_trimmed_string(raw.system_prompt);
5835    let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
5836    let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
5837    let instruction_files = raw
5838        .instruction_files
5839        .map(parse_instruction_files)
5840        .transpose()?;
5841
5842    let prompt_sources_set = [
5843        system_prompt.is_some(),
5844        system_prompt_file.is_some(),
5845        system_prompt_artifact.is_some(),
5846    ]
5847    .iter()
5848    .filter(|&&b| b)
5849    .count();
5850    if prompt_sources_set > 1 {
5851        return Err(RuntimeManifestError::InvalidInferenceConfig {
5852            field: "inference.system_prompt".to_string(),
5853            message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
5854                      may be set"
5855                .to_string(),
5856        });
5857    }
5858
5859    if raw.max_turns == Some(0) {
5860        return Err(RuntimeManifestError::InvalidInferenceConfig {
5861            field: "inference.max_turns".to_string(),
5862            message: "must be greater than 0".to_string(),
5863        });
5864    }
5865    let max_turns = raw.max_turns;
5866
5867    // The reopen budget is a lifecycle knob, read from `lifecycle.max_task_reopens`. Nothing
5868    // here consumes the `inference` spelling, so it would be silently inert — reject it rather
5869    // than let it look effective.
5870    if raw.max_task_reopens.is_some() {
5871        return Err(RuntimeManifestError::InvalidInferenceConfig {
5872            field: "inference.max_task_reopens".to_string(),
5873            message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
5874                .to_string(),
5875        });
5876    }
5877
5878    // Left to the unknown-key pass, either field would only draw a warning and the capsule would
5879    // launch with no upstream or credential bound, so each is refused by name, pointing at the
5880    // driver entry's `gateway:` block. Nothing translates it.
5881    for (present, removed, moved_to) in [
5882        (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
5883        (raw.api_key.is_some(), "api_key", "gateway.api_key"),
5884    ] {
5885        if !present {
5886            continue;
5887        }
5888        let entry = raw
5889            .driver
5890            .as_ref()
5891            .and_then(|driver| driver.artifact.as_deref())
5892            .map(str::trim)
5893            .filter(|name| !name.is_empty())
5894            .map(|name| format!("on the artifacts: entry '{name}'"))
5895            .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
5896        return Err(RuntimeManifestError::InvalidInferenceConfig {
5897            field: format!("inference.{removed}"),
5898            message: format!("was removed; set {moved_to} {entry} instead"),
5899        });
5900    }
5901
5902    match transport.as_str() {
5903        "http" => {
5904            let model = required_inference_field(raw.model, "model")?;
5905
5906            let driver_raw =
5907                raw.driver
5908                    .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5909                        field: "inference.driver".to_string(),
5910                        message: "missing required field".to_string(),
5911                    })?;
5912            let driver = parse_inference_driver(driver_raw)?;
5913
5914            // Advisory only: catch an authoring typo at parse time rather than as a confusing
5915            // provider 400. Large values are deliberately NOT clamped — a ceiling here would
5916            // block a model whose real limit is higher than anything we could hard-code.
5917            if raw.max_tokens == Some(0) {
5918                return Err(RuntimeManifestError::InvalidInferenceConfig {
5919                    field: "inference.max_tokens".to_string(),
5920                    message: "must be greater than 0".to_string(),
5921                });
5922            }
5923            if raw.max_session_tokens == Some(0) {
5924                return Err(RuntimeManifestError::InvalidInferenceConfig {
5925                    field: "inference.max_session_tokens".to_string(),
5926                    message: "must be greater than 0".to_string(),
5927                });
5928            }
5929            let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
5930            let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
5931            let retry = parse_inference_retry(raw.retry)?;
5932
5933            Ok(Some(InferenceConfig {
5934                transport,
5935                model,
5936                driver: Some(driver),
5937                command: None,
5938                compaction,
5939                system_prompt,
5940                system_prompt_file,
5941                system_prompt_artifact,
5942                instruction_files,
5943                max_turns,
5944                max_tokens: raw.max_tokens,
5945                max_session_tokens: raw.max_session_tokens,
5946                tool_refresh,
5947                alternates,
5948                retry,
5949            }))
5950        }
5951        "process" => {
5952            // The CLI subprocess path never builds a driver payload, so this value would be
5953            // silently inert here — reject it rather than let it look effective.
5954            if raw.max_tokens.is_some() {
5955                return Err(RuntimeManifestError::InvalidInferenceConfig {
5956                    field: "inference.max_tokens".to_string(),
5957                    message: "is not valid with transport: process".to_string(),
5958                });
5959            }
5960            // The harness owns its tool list and runs its own compaction, so neither trigger has a
5961            // turn boundary here to act on.
5962            if raw.tool_refresh.is_some() {
5963                return Err(RuntimeManifestError::InvalidInferenceConfig {
5964                    field: "inference.tool_refresh".to_string(),
5965                    message: "is not valid with transport: process".to_string(),
5966                });
5967            }
5968            // The harness runs its own compaction, so no `on-compaction` call is made here for
5969            // either key to shape.
5970            for (present, key) in [
5971                (
5972                    compaction.as_ref().is_some_and(|c| c.max_tokens.is_some()),
5973                    "max_tokens",
5974                ),
5975                (
5976                    compaction
5977                        .as_ref()
5978                        .is_some_and(|c| c.driver_config.is_some()),
5979                    "driver_config",
5980                ),
5981            ] {
5982                if present {
5983                    return Err(RuntimeManifestError::InvalidInferenceConfig {
5984                        field: format!("inference.compaction.{key}"),
5985                        message: "is not valid with transport: process".to_string(),
5986                    });
5987                }
5988            }
5989            // The harness owns the conversation and the model it runs, so there is no agent-loop
5990            // call for a switch to reroute.
5991            if raw.alternates.is_some() {
5992                return Err(RuntimeManifestError::InvalidInferenceConfig {
5993                    field: "inference.alternates".to_string(),
5994                    message: "is not valid with transport: process; the harness owns the \
5995                              conversation and the model it runs"
5996                        .to_string(),
5997                });
5998            }
5999            if raw.retry.is_some() {
6000                return Err(RuntimeManifestError::InvalidInferenceConfig {
6001                    field: "inference.retry".to_string(),
6002                    message: "is not valid with transport: process; the harness retries its own \
6003                              provider calls"
6004                        .to_string(),
6005                });
6006            }
6007
6008            // Valid here: the harness's own reported token counts are what this transport's
6009            // turns are metered against, and a driver that reports none is refused at load
6010            // rather than run for ever beneath a ceiling nothing could reach.
6011            if raw.max_session_tokens == Some(0) {
6012                return Err(RuntimeManifestError::InvalidInferenceConfig {
6013                    field: "inference.max_session_tokens".to_string(),
6014                    message: "must be greater than 0".to_string(),
6015                });
6016            }
6017
6018            let driver = raw.driver.map(parse_inference_driver).transpose()?;
6019            let command = optional_trimmed_string(raw.command);
6020            // The driver is what knows how to drive a harness CLI: its flags, its output format,
6021            // its version probe. `command` only says which executable to run instead of the one
6022            // the driver names, so it selects nothing on its own.
6023            if driver.is_none() {
6024                return Err(RuntimeManifestError::InvalidInferenceConfig {
6025                    field: "inference.driver.artifact".to_string(),
6026                    message: PROCESS_DRIVER_REQUIRED.to_string(),
6027                });
6028            }
6029            // model is OPTIONAL for transport: process — an empty string leaves the choice of
6030            // model to the harness, which has an account default of its own. The driver is handed
6031            // `none` rather than an empty string, so it never passes a model flag with no value.
6032            let model = optional_trimmed_string(raw.model).unwrap_or_default();
6033
6034            Ok(Some(InferenceConfig {
6035                transport,
6036                model,
6037                driver,
6038                command,
6039                compaction,
6040                system_prompt,
6041                system_prompt_file,
6042                system_prompt_artifact,
6043                instruction_files,
6044                max_turns,
6045                max_tokens: None,
6046                max_session_tokens: raw.max_session_tokens,
6047                tool_refresh: ToolRefresh::default(),
6048                alternates: Vec::new(),
6049                retry: InferenceRetry::default(),
6050            }))
6051        }
6052        other => Err(RuntimeManifestError::InvalidInferenceConfig {
6053            field: "inference.transport".to_string(),
6054            message: format!("unknown value '{other}'"),
6055        }),
6056    }
6057}
6058
6059/// Lowers `inference.retry` under `transport: http`: each omitted key takes its default, and each
6060/// value is checked against its bounds. `max_delay_ms` below `initial_delay_ms` is refused on
6061/// `max_delay_ms`.
6062fn parse_inference_retry(
6063    raw: Option<RawInferenceRetry>,
6064) -> Result<InferenceRetry, RuntimeManifestError> {
6065    let defaults = InferenceRetry::default();
6066    let Some(raw) = raw else {
6067        return Ok(defaults);
6068    };
6069    let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
6070        field: format!("inference.retry.{key}"),
6071        message,
6072    };
6073    let retry = InferenceRetry {
6074        max_retries: raw.max_retries.unwrap_or(defaults.max_retries),
6075        initial_delay_ms: raw.initial_delay_ms.unwrap_or(defaults.initial_delay_ms),
6076        max_delay_ms: raw.max_delay_ms.unwrap_or(defaults.max_delay_ms),
6077    };
6078    if retry.max_retries > InferenceRetry::MAX_RETRIES {
6079        return Err(refuse(
6080            "max_retries",
6081            format!("must be at most {}", InferenceRetry::MAX_RETRIES),
6082        ));
6083    }
6084    if !(1..=InferenceRetry::MAX_INITIAL_DELAY_MS).contains(&retry.initial_delay_ms) {
6085        return Err(refuse(
6086            "initial_delay_ms",
6087            format!(
6088                "must be between 1 and {}",
6089                InferenceRetry::MAX_INITIAL_DELAY_MS
6090            ),
6091        ));
6092    }
6093    if !(1..=InferenceRetry::MAX_DELAY_MS).contains(&retry.max_delay_ms) {
6094        return Err(refuse(
6095            "max_delay_ms",
6096            format!("must be between 1 and {}", InferenceRetry::MAX_DELAY_MS),
6097        ));
6098    }
6099    if retry.max_delay_ms < retry.initial_delay_ms {
6100        return Err(refuse(
6101            "max_delay_ms",
6102            format!(
6103                "must be at least inference.retry.initial_delay_ms ({})",
6104                retry.initial_delay_ms
6105            ),
6106        ));
6107    }
6108    Ok(retry)
6109}
6110
6111/// Whether `name` is an alternate's name: a lowercase letter, then up to 62 lowercase letters,
6112/// digits and hyphens.
6113fn is_valid_driver_choice_name(name: &str) -> bool {
6114    let mut chars = name.chars();
6115    chars.next().is_some_and(|first| first.is_ascii_lowercase())
6116        && name.len() <= 63
6117        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
6118}
6119
6120/// Lowers `inference.alternates` under `transport: http`, with the rules one entry and the list
6121/// can be judged on alone. What needs `artifacts:` is [`validate_driver_choices`]'s.
6122///
6123/// `primary_driver` and `primary_model` are the primary choice's pair, which no alternate may
6124/// repeat: two names for one pair would make a switch between them a change of nothing.
6125fn parse_alternates(
6126    raw: Option<Vec<RawInferenceAlternate>>,
6127    primary_driver: &str,
6128    primary_model: &str,
6129) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
6130    let mut alternates: Vec<InferenceAlternate> = Vec::new();
6131    for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
6132        let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
6133            field: if key.is_empty() {
6134                format!("inference.alternates[{index}]")
6135            } else {
6136                format!("inference.alternates[{index}].{key}")
6137            },
6138            message,
6139        };
6140        let name = optional_trimmed_string(entry.name)
6141            .ok_or_else(|| refuse("name", "missing required field".to_string()))?;
6142        if name == PRIMARY_DRIVER_CHOICE {
6143            return Err(refuse(
6144                "name",
6145                format!(
6146                    "'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
6147                     inference.model; name the alternate something else"
6148                ),
6149            ));
6150        }
6151        if !is_valid_driver_choice_name(&name) {
6152            return Err(refuse(
6153                "name",
6154                format!(
6155                    "'{name}' is not a driver choice name; a name is a lowercase letter followed \
6156                     by up to 62 lowercase letters, digits and hyphens"
6157                ),
6158            ));
6159        }
6160        if alternates.iter().any(|alternate| alternate.name == name) {
6161            return Err(refuse(
6162                "name",
6163                format!("'{name}' names more than one alternate"),
6164            ));
6165        }
6166        let model = optional_trimmed_string(entry.model)
6167            .ok_or_else(|| refuse("model", "missing required field".to_string()))?;
6168        let Some(driver) = entry.driver else {
6169            return Err(refuse(
6170                "driver.artifact",
6171                "missing required field".to_string(),
6172            ));
6173        };
6174        if driver
6175            .config
6176            .as_ref()
6177            .is_some_and(|config| !matches!(config, serde_norway::Value::Null))
6178        {
6179            return Err(refuse(
6180                "driver.config",
6181                "is not valid on an alternate; inference.driver.config applies to every \
6182                 declared driver, and per-driver settings belong in that driver's \
6183                 artifacts[].config"
6184                    .to_string(),
6185            ));
6186        }
6187        let driver = optional_trimmed_string(driver.artifact)
6188            .ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
6189        if driver == primary_driver && model == primary_model {
6190            return Err(refuse(
6191                "",
6192                format!(
6193                    "'{name}' is driver '{driver}' with model '{model}', which is the primary \
6194                     choice; an alternate names a different pair"
6195                ),
6196            ));
6197        }
6198        if let Some(twin) = alternates
6199            .iter()
6200            .find(|alternate| alternate.driver == driver && alternate.model == model)
6201        {
6202            return Err(refuse(
6203                "",
6204                format!(
6205                    "'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
6206                     already is; an alternate names a different pair",
6207                    twin.name
6208                ),
6209            ));
6210        }
6211        alternates.push(InferenceAlternate {
6212            name,
6213            model,
6214            driver,
6215        });
6216    }
6217    Ok(alternates)
6218}
6219
6220/// `inference.tool_refresh` under `transport: http`: absent is [`ToolRefresh::Compaction`].
6221fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
6222    let Some(value) = raw else {
6223        return Ok(ToolRefresh::default());
6224    };
6225    ToolRefresh::ALL
6226        .into_iter()
6227        .find(|refresh| refresh.wire_name() == value)
6228        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6229            field: "inference.tool_refresh".to_string(),
6230            message: format!(
6231                "must be one of: {}; got '{value}'",
6232                ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
6233            ),
6234        })
6235}
6236
6237fn parse_context(
6238    raw: Option<RawContextConfig>,
6239) -> Result<Option<ContextConfig>, RuntimeManifestError> {
6240    let Some(raw) = raw else {
6241        return Ok(None);
6242    };
6243
6244    if let Some(max_tokens) = raw.max_tokens {
6245        if max_tokens == 0 {
6246            return Err(RuntimeManifestError::InvalidInferenceConfig {
6247                field: "context.max_tokens".to_string(),
6248                message: "must be greater than 0".to_string(),
6249            });
6250        }
6251    }
6252
6253    let fraction = |field: &str, value: Option<f32>, default: f32| match value {
6254        None => Ok(default),
6255        Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
6256        Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
6257            field: field.to_string(),
6258            message: "must be between 0.0 and 1.0".to_string(),
6259        }),
6260    };
6261
6262    let record = match raw.record.as_deref().map(str::trim) {
6263        None => true,
6264        Some("on") => true,
6265        Some("off") => false,
6266        Some(other) => {
6267            return Err(RuntimeManifestError::InvalidInferenceConfig {
6268                field: "context.record".to_string(),
6269                message: format!("unknown value '{other}'; expected: on, off"),
6270            })
6271        }
6272    };
6273
6274    Ok(Some(ContextConfig {
6275        max_tokens: raw.max_tokens,
6276        record,
6277        record_store: raw.record_store.map(|store| store.trim().to_string()),
6278        seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
6279        seed_overflow_margin: fraction(
6280            "context.seed_overflow_margin",
6281            raw.seed_overflow_margin,
6282            DEFAULT_SEED_OVERFLOW_MARGIN,
6283        )?,
6284        retain: parse_context_retain(raw.retain)?,
6285        images: parse_context_images(raw.images)?,
6286    }))
6287}
6288
6289/// The `context.images` block, each absent key at its default.
6290fn parse_context_images(
6291    raw: Option<RawContextImages>,
6292) -> Result<ContextImagesConfig, RuntimeManifestError> {
6293    let defaults = ContextImagesConfig::default();
6294    let Some(raw) = raw else {
6295        return Ok(defaults);
6296    };
6297    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
6298        field: field.to_string(),
6299        message,
6300    };
6301    let count = |field: &str, value: Option<serde_norway::Value>, default: u32| match value {
6302        None => Ok(default),
6303        Some(value) => value
6304            .as_u64()
6305            .and_then(|n| u32::try_from(n).ok())
6306            .filter(|n| *n >= 1)
6307            .ok_or_else(|| {
6308                let message = match scalar_text(&value) {
6309                    Some(text) => format!("must be a whole number of at least 1, got '{text}'"),
6310                    None => "must be a whole number of at least 1".to_string(),
6311                };
6312                invalid(field, message)
6313            }),
6314    };
6315    let max_bytes = match raw.max_bytes {
6316        None => defaults.max_bytes,
6317        Some(value) => {
6318            let field = "context.images.max_bytes";
6319            let text = scalar_text(&value)
6320                .ok_or_else(|| invalid(field, format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}")))?;
6321            let parsed = parse_byte_size(&text).map_err(|message| invalid(field, message))?;
6322            if parsed == 0 {
6323                return Err(invalid(
6324                    field,
6325                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
6326                ));
6327            }
6328            parsed
6329        }
6330    };
6331    Ok(ContextImagesConfig {
6332        keep: count("context.images.keep", raw.keep, defaults.keep)?,
6333        max_bytes,
6334        max_per_turn: count(
6335            "context.images.max_per_turn",
6336            raw.max_per_turn,
6337            defaults.max_per_turn,
6338        )?,
6339    })
6340}
6341
6342/// The keys `trace.retain` accepts, in the order an error lists them.
6343const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
6344
6345/// The keys `context.retain` accepts, in the order an error lists them.
6346const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
6347
6348/// What both `retain:` blocks say when they carry nothing. Stated once so the two errors, which
6349/// travel through different `RuntimeManifestError` variants, cannot drift apart.
6350const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
6351                                  keep everything, which is what no policy means";
6352
6353/// One `retain:` block, reduced to its two recognized keys.
6354///
6355/// Shared by `trace.retain` and `context.retain`: they differ only in the name of the count key,
6356/// so the shape check, the unknown-key refusal and the empty-block refusal are written once.
6357/// `count_key` is `max_sessions` or `max_messages`; `block` is the dotted path an error names.
6358fn parse_retain_block(
6359    block: &str,
6360    count_key: &str,
6361    accepted: &[&str],
6362    raw: serde_norway::Value,
6363    invalid: &dyn Fn(String, String) -> RuntimeManifestError,
6364) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
6365    let mapping = match raw {
6366        serde_norway::Value::Mapping(mapping) => mapping,
6367        // `retain:` with nothing under it parses as null and means exactly `retain: {}`.
6368        serde_norway::Value::Null => {
6369            return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
6370        }
6371        other => {
6372            return Err(invalid(
6373                block.to_string(),
6374                format!(
6375                    "must be a block of keys ({}), got '{other:?}'",
6376                    accepted.join(", ")
6377                ),
6378            ))
6379        }
6380    };
6381    if mapping.is_empty() {
6382        return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
6383    }
6384
6385    let mut count: Option<u32> = None;
6386    let mut max_age_secs: Option<u64> = None;
6387    for (key, value) in &mapping {
6388        let key = key.as_str().unwrap_or_default();
6389        let field = format!("{block}.{key}");
6390        if key == count_key {
6391            let parsed = value
6392                .as_u64()
6393                .and_then(|n| u32::try_from(n).ok())
6394                .ok_or_else(|| {
6395                    invalid(
6396                        field.clone(),
6397                        "must be a whole number of at least 1".to_string(),
6398                    )
6399                })?;
6400            if parsed == 0 {
6401                return Err(invalid(
6402                    field,
6403                    "must be at least 1 — a limit of zero would delete what retention exists \
6404                     to keep; remove the key to leave it unbounded"
6405                        .to_string(),
6406                ));
6407            }
6408            count = Some(parsed);
6409        } else if key == "max_age" {
6410            let text = scalar_text(value).ok_or_else(|| {
6411                invalid(
6412                    field.clone(),
6413                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
6414                )
6415            })?;
6416            let parsed =
6417                parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
6418            if parsed == 0 {
6419                return Err(invalid(
6420                    field,
6421                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
6422                ));
6423            }
6424            max_age_secs = Some(parsed);
6425        } else {
6426            return Err(invalid(
6427                block.to_string(),
6428                format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
6429            ));
6430        }
6431    }
6432    Ok((count, max_age_secs))
6433}
6434
6435fn parse_trace_retain(
6436    raw: Option<serde_norway::Value>,
6437) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
6438    let Some(raw) = raw else {
6439        return Ok(None);
6440    };
6441    let (max_sessions, max_age_secs) = parse_retain_block(
6442        "trace.retain",
6443        "max_sessions",
6444        &TRACE_RETAIN_KEYS,
6445        raw,
6446        &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
6447    )?;
6448    Ok(Some(TraceRetainConfig {
6449        max_sessions,
6450        max_age_secs,
6451    }))
6452}
6453
6454fn parse_context_retain(
6455    raw: Option<serde_norway::Value>,
6456) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
6457    let Some(raw) = raw else {
6458        return Ok(None);
6459    };
6460    let (max_messages, max_age_secs) = parse_retain_block(
6461        "context.retain",
6462        "max_messages",
6463        &CONTEXT_RETAIN_KEYS,
6464        raw,
6465        &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
6466    )?;
6467    Ok(Some(ContextRetainConfig {
6468        max_messages,
6469        max_age_secs,
6470    }))
6471}
6472
6473fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
6474    raw.map(|raw| ObservabilityConfig {
6475        otel_endpoint: raw
6476            .otel_endpoint
6477            .map(|value| value.trim().to_string())
6478            .filter(|value| !value.is_empty()),
6479        eval: raw.eval.map(parse_eval_config),
6480    })
6481}
6482
6483fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
6484    let scorers = raw
6485        .scorers
6486        .into_iter()
6487        .filter_map(|raw| {
6488            let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
6489            match raw.scorer_type.as_str() {
6490                "exit_ok" => Some(ScorerConfig::ExitOk { name }),
6491                "max_turns" => Some(ScorerConfig::MaxTurns {
6492                    name,
6493                    max: raw.max.unwrap_or(10) as u32,
6494                }),
6495                "max_tokens" => Some(ScorerConfig::MaxTokens {
6496                    name,
6497                    max: raw.max.unwrap_or(100_000),
6498                }),
6499                "tool_sequence" => Some(ScorerConfig::ToolSequence {
6500                    name,
6501                    expected: raw.expected.unwrap_or_default(),
6502                }),
6503                "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
6504                other => {
6505                    crate::warn_line(format_args!(
6506                        "[murmur-artifact] unknown scorer type '{other}' — skipping"
6507                    ));
6508                    None
6509                }
6510            }
6511        })
6512        .collect();
6513
6514    EvalConfig {
6515        dataset_id: raw.dataset_id,
6516        scorers,
6517    }
6518}
6519
6520fn parse_compaction(
6521    raw: Option<RawCompactionConfig>,
6522) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
6523    let Some(raw) = raw else {
6524        return Ok(None);
6525    };
6526
6527    if let Some(threshold) = raw.threshold {
6528        if threshold <= 0.0 || threshold > 1.0 {
6529            return Err(RuntimeManifestError::InvalidInferenceConfig {
6530                field: "inference.compaction.threshold".to_string(),
6531                message: format!("must be in (0.0, 1.0] but got {threshold}"),
6532            });
6533        }
6534    }
6535
6536    if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
6537        return Err(RuntimeManifestError::InvalidInferenceConfig {
6538            field: "inference.compaction.system_prompt".to_string(),
6539            message: "at most one of inference.compaction.system_prompt, \
6540                      inference.compaction.system_prompt_file may be set"
6541                .to_string(),
6542        });
6543    }
6544
6545    if raw.max_tokens == Some(0) {
6546        return Err(RuntimeManifestError::InvalidInferenceConfig {
6547            field: "inference.compaction.max_tokens".to_string(),
6548            message: "must be greater than 0".to_string(),
6549        });
6550    }
6551    let driver_config =
6552        driver_config_json(raw.driver_config, "inference.compaction.driver_config")?;
6553
6554    Ok(Some(CompactionConfig {
6555        threshold: raw.threshold,
6556        model: raw.model,
6557        system_prompt: raw.system_prompt,
6558        system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
6559        // A plain optional boolean: nothing to validate, and deliberately independent of the
6560        // threshold-range and prompt-source checks above.
6561        dump_summaries: raw.dump_summaries,
6562        max_tokens: raw.max_tokens,
6563        driver_config,
6564    }))
6565}
6566
6567fn optional_trimmed_string(value: Option<String>) -> Option<String> {
6568    value
6569        .map(|s| s.trim().to_string())
6570        .filter(|s| !s.is_empty())
6571}
6572
6573fn required_inference_field(
6574    value: Option<String>,
6575    key: &str,
6576) -> Result<String, RuntimeManifestError> {
6577    value
6578        .map(|value| value.trim().to_string())
6579        .filter(|value| !value.is_empty())
6580        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6581            field: format!("inference.{key}"),
6582            message: "missing required field".to_string(),
6583        })
6584}
6585
6586/// One artifact entry's `gateway:` block, checked and with its key reference kept unresolved.
6587///
6588/// The credential rule reads what the manifest wrote, never a resolved value, so it decides the
6589/// same under [`SecretResolution::Resolve`] and [`SecretResolution::Skip`]: a non-blank `api_key`
6590/// is keyed, `keyless: true` with no `api_key` key is keyless, both together is
6591/// [`RuntimeManifestError::InvalidArtifact`], and anything else is
6592/// [`RuntimeManifestError::GatewayWithoutCredential`]. The endpoint's address plays no part.
6593fn parse_artifact_gateway(
6594    index: usize,
6595    name: &str,
6596    raw: Option<RawArtifactGateway>,
6597    secrets: SecretResolution,
6598) -> Result<ArtifactGateway, RuntimeManifestError> {
6599    let (endpoint, api_key, keyless) = match raw {
6600        Some(raw) => (
6601            optional_trimmed_string(raw.endpoint),
6602            raw.api_key,
6603            raw.keyless,
6604        ),
6605        None => (None, None, None),
6606    };
6607    let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
6608        index,
6609        message: format!(
6610            "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
6611             the upstream URL the runtime sends this artifact's keyed requests to"
6612        ),
6613    })?;
6614    validate_gateway_endpoint(name, &endpoint)
6615        .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
6616
6617    let keyless = match keyless {
6618        None | Some(serde_norway::Value::Null) | Some(serde_norway::Value::Bool(false)) => false,
6619        Some(serde_norway::Value::Bool(true)) => true,
6620        Some(_) => {
6621            return Err(RuntimeManifestError::InvalidArtifact {
6622                index,
6623                message: format!(
6624                    "artifact '{name}' declares gateway.keyless with a value that is not a \
6625                     boolean; write keyless: true for an upstream that takes no key"
6626                ),
6627            })
6628        }
6629    };
6630    if keyless && api_key.is_some() {
6631        return Err(RuntimeManifestError::InvalidArtifact {
6632            index,
6633            message: format!(
6634                "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
6635                 gateway either presents a key or is keyless"
6636            ),
6637        });
6638    }
6639    if keyless {
6640        return Ok(ArtifactGateway {
6641            endpoint,
6642            api_key: None,
6643            keyless: true,
6644        });
6645    }
6646
6647    let written = api_key
6648        .flatten()
6649        .map(|value| value.trim().to_string())
6650        .filter(|value| !value.is_empty());
6651    let Some(written) = written else {
6652        return Err(RuntimeManifestError::GatewayWithoutCredential {
6653            index,
6654            name: name.to_string(),
6655            endpoint,
6656        });
6657    };
6658    let api_key = match secrets {
6659        SecretResolution::Skip => None,
6660        SecretResolution::Resolve => Some(match parse_env_reference(&written) {
6661            Some(variable) => ApiKeyReference::Environment(variable.to_string()),
6662            None => ApiKeyReference::Literal(written),
6663        }),
6664    };
6665
6666    Ok(ArtifactGateway {
6667        endpoint,
6668        api_key,
6669        keyless: false,
6670    })
6671}
6672
6673/// Checks a `gateway.endpoint`, the only control on where an artifact's key is sent: a
6674/// gateway-addressed request is readdressed before any `network.allow` rule is consulted, so
6675/// nothing else confines the destination.
6676///
6677/// Accepted: an `https://` URL with a host, or an `http://` URL whose host is `localhost` or an
6678/// IP literal for which [`IpAddr::is_loopback`] returns true (`127.0.0.0/8`, `::1`). An optional
6679/// port and path are kept.
6680///
6681/// Refused:
6682/// - `${` anywhere, checked before parsing: the field is never interpolated, and the URL parser
6683///   accepts `$`, `{` and `}` in a host, so `https://${HOST}` would otherwise parse as a literal
6684///   host.
6685/// - A malformed URL, a scheme other than `http`/`https`, or no host.
6686/// - Userinfo (`user@`, `user:pass@`): `https://api.example.com@evil.example` addresses
6687///   `evil.example`, and the gateway never sends userinfo, so a password there is a second,
6688///   unmanaged credential. The message names the host the URL addresses.
6689/// - A query or a fragment: the gateway readdresses each request by its own path and query, so
6690///   either would be silently dropped.
6691/// - `http://` to any host that is not loopback, including `0.0.0.0` and `::ffff:127.0.0.1`.
6692///
6693/// The endpoint confines the origin only. The guest chooses the whole path and query of every
6694/// gateway request.
6695///
6696/// The error is the whole message, naming `gateway.endpoint` and the artifact entry `artifact`.
6697/// It quotes the endpoint with any userinfo masked, so a password written into the URL is never
6698/// echoed.
6699fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
6700    let shown = mask_endpoint_userinfo(endpoint);
6701    let refuse =
6702        |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
6703    if endpoint.contains("${") {
6704        return Err(refuse(
6705            "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
6706             literally"
6707                .to_string(),
6708        ));
6709    }
6710    let url = Url::parse(endpoint)
6711        .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
6712    let host = match url.scheme() {
6713        "http" | "https" => url
6714            .host_str()
6715            .filter(|host| !host.is_empty())
6716            .ok_or_else(|| refuse("which has no host".to_string()))?,
6717        other => {
6718            return Err(refuse(format!(
6719                "whose scheme '{other}' is unsupported — expected http or https"
6720            )))
6721        }
6722    };
6723    if !url.username().is_empty() || url.password().is_some() {
6724        return Err(refuse(format!(
6725            "which carries userinfo before '@' — the gateway never sends it, and this URL \
6726             addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
6727             the key through gateway.api_key"
6728        )));
6729    }
6730    if url.query().is_some() {
6731        return Err(refuse(
6732            "which carries a query — the gateway keeps each request's own query, so write the \
6733             upstream without one"
6734                .to_string(),
6735        ));
6736    }
6737    if url.fragment().is_some() {
6738        return Err(refuse(
6739            "which carries a fragment — write the upstream without one".to_string(),
6740        ));
6741    }
6742    if url.scheme() == "http" {
6743        let bare = host.trim_start_matches('[').trim_end_matches(']');
6744        let is_loopback = bare == "localhost"
6745            || bare
6746                .parse::<IpAddr>()
6747                .map(|ip| ip.is_loopback())
6748                .unwrap_or(false);
6749        if !is_loopback {
6750            return Err(refuse(format!(
6751                "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
6752                 remote upstream, or http://localhost or a loopback IP literal for a local one"
6753            )));
6754        }
6755    }
6756    Ok(())
6757}
6758
6759/// `endpoint` with everything before the last `@` of its authority replaced by `***`.
6760///
6761/// Textual rather than parsed, so it also masks an endpoint the URL parser rejects.
6762fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
6763    let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
6764    let rest = &endpoint[authority_start..];
6765    let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
6766    match rest[..authority_len].rfind('@') {
6767        Some(at) => format!(
6768            "{}***{}",
6769            &endpoint[..authority_start],
6770            &endpoint[authority_start + at..]
6771        )
6772        .into(),
6773        None => endpoint.into(),
6774    }
6775}
6776
6777/// The cross-entry rules for a `gateway:` on a driver, which need the parsed `inference:` block.
6778///
6779/// A driver's gateway is a metered inference gateway only when that driver is the configured
6780/// `transport: http` driver or an `inference.alternates` entry's driver. On any other driver entry
6781/// it could be spent only through tool dispatch, where nothing meters it, so it is refused. Under
6782/// `transport: http` the configured driver's entry must exist and must carry the upstream.
6783fn validate_driver_gateways(
6784    artifacts: &[RuntimeArtifact],
6785    inference: Option<&InferenceConfig>,
6786) -> Result<(), RuntimeManifestError> {
6787    let configured_driver = inference
6788        .filter(|inference| inference.transport == "http")
6789        .and_then(|inference| inference.driver.as_ref())
6790        .map(|driver| driver.artifact.as_str());
6791    let alternate_drivers: Vec<&str> = inference
6792        .map(|inference| {
6793            inference
6794                .alternates
6795                .iter()
6796                .map(|alternate| alternate.driver.as_str())
6797                .collect()
6798        })
6799        .unwrap_or_default();
6800    let under_process = inference.is_some_and(|inference| inference.transport == "process");
6801
6802    // A reference to nothing is reported as such before any entry is judged against it: a
6803    // misspelt driver name would otherwise read as a gateway on the wrong driver.
6804    if let Some(driver) = configured_driver {
6805        if !artifacts.iter().any(|artifact| artifact.name == driver) {
6806            return Err(RuntimeManifestError::InvalidInferenceConfig {
6807                field: "inference.driver.artifact".to_string(),
6808                message: not_declared_message(artifacts, driver),
6809            });
6810        }
6811    }
6812
6813    for (index, artifact) in artifacts.iter().enumerate() {
6814        if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
6815            continue;
6816        }
6817        if under_process {
6818            return Err(RuntimeManifestError::InvalidArtifact {
6819                index,
6820                message: format!(
6821                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
6822                     valid with transport: process — the CLI subprocess holds its own \
6823                     credentials and a process driver is granted nothing",
6824                    artifact.name
6825                ),
6826            });
6827        }
6828        if configured_driver != Some(artifact.name.as_str())
6829            && !alternate_drivers.contains(&artifact.name.as_str())
6830        {
6831            return Err(RuntimeManifestError::InvalidArtifact {
6832                index,
6833                message: format!(
6834                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
6835                     the configured inference driver; a driver's gateway is accepted only on the \
6836                     entry inference.driver.artifact names under transport: http, or on one an \
6837                     inference.alternates entry names",
6838                    artifact.name
6839                ),
6840            });
6841        }
6842    }
6843
6844    if let Some(driver) = configured_driver {
6845        let Some((index, entry)) = artifacts
6846            .iter()
6847            .enumerate()
6848            .find(|(_, artifact)| artifact.name == driver)
6849        else {
6850            return Err(RuntimeManifestError::InvalidInferenceConfig {
6851                field: "inference.driver.artifact".to_string(),
6852                message: not_declared_message(artifacts, driver),
6853            });
6854        };
6855        if entry.gateway.is_none() {
6856            return Err(RuntimeManifestError::InvalidArtifact {
6857                index,
6858                message: format!(
6859                    "artifact '{driver}' is the transport: http inference driver but declares no \
6860                     gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
6861                     on its entry"
6862                ),
6863            });
6864        }
6865    }
6866    Ok(())
6867}
6868
6869/// The cross-entry rules for `inference.alternates`, which need `artifacts:`.
6870///
6871/// Each alternate's `driver.artifact` must be an `artifacts:` entry with `runtime: driver`, and
6872/// that entry must carry its own `gateway:` — the credential a switch attaches — unless it is the
6873/// primary's own driver, whose gateway [`validate_driver_gateways`] already requires.
6874fn validate_driver_choices(
6875    artifacts: &[RuntimeArtifact],
6876    inference: Option<&InferenceConfig>,
6877) -> Result<(), RuntimeManifestError> {
6878    let Some(inference) = inference else {
6879        return Ok(());
6880    };
6881    let primary_driver = inference
6882        .driver
6883        .as_ref()
6884        .map(|driver| driver.artifact.as_str());
6885    for (index, alternate) in inference.alternates.iter().enumerate() {
6886        let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
6887            field: format!("inference.alternates[{index}].driver.artifact"),
6888            message,
6889        };
6890        let driver = alternate.driver.as_str();
6891        let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
6892            return Err(refuse(not_declared_message(artifacts, driver)));
6893        };
6894        if entry.runtime != ArtifactRuntime::Driver {
6895            return Err(refuse(format!(
6896                "artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
6897                 runtime: driver entry",
6898                entry.runtime.as_str()
6899            )));
6900        }
6901        if entry.gateway.is_none() && primary_driver != Some(driver) {
6902            return Err(refuse(format!(
6903                "artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
6904                 its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
6905                 URL>, api_key: ... }}' on its entry"
6906            )));
6907        }
6908    }
6909    Ok(())
6910}
6911
6912/// Refuses alternates nothing could ever select: `inference.driver` is in neither
6913/// `control.settings` nor `control.agent_settings`.
6914fn validate_alternates_selectable(
6915    inference: Option<&InferenceConfig>,
6916    control: Option<&ControlConfig>,
6917) -> Result<(), RuntimeManifestError> {
6918    if inference.is_none_or(|inference| inference.alternates.is_empty()) {
6919        return Ok(());
6920    }
6921    let selectable = control.is_some_and(|control| {
6922        control.controller_may_set(ControllableSetting::InferenceDriver)
6923            || control.agent_may_set(ControllableSetting::InferenceDriver)
6924    });
6925    if selectable {
6926        return Ok(());
6927    }
6928    Err(RuntimeManifestError::InvalidInferenceConfig {
6929        field: "inference.alternates".to_string(),
6930        message: "declares alternates that nothing could ever select; list inference.driver in \
6931                  control.settings for a controller to switch, or in control.agent_settings for \
6932                  the agent to"
6933            .to_string(),
6934    })
6935}
6936
6937/// The cross-entry rules for the `transport: process` driver, which need the parsed
6938/// `inference:` block.
6939///
6940/// The artifact `inference.driver.artifact` names must be declared in `artifacts:` with
6941/// `runtime: driver`. A process driver is granted nothing, so its entry may carry neither
6942/// `capabilities:` nor `config:`; its settings travel in `inference.driver.config`. A `gateway:`
6943/// on it is refused by [`validate_driver_gateways`].
6944fn validate_process_driver_entry(
6945    artifacts: &[RuntimeArtifact],
6946    inference: Option<&InferenceConfig>,
6947) -> Result<(), RuntimeManifestError> {
6948    let Some(driver) = inference
6949        .filter(|inference| inference.transport == "process")
6950        .and_then(|inference| inference.driver.as_ref())
6951        .map(|driver| driver.artifact.as_str())
6952    else {
6953        return Ok(());
6954    };
6955    let Some((index, entry)) = artifacts
6956        .iter()
6957        .enumerate()
6958        .find(|(_, artifact)| artifact.name == driver)
6959    else {
6960        return Err(RuntimeManifestError::InvalidInferenceConfig {
6961            field: "inference.driver.artifact".to_string(),
6962            message: not_declared_message(artifacts, driver),
6963        });
6964    };
6965    if entry.runtime != ArtifactRuntime::Driver {
6966        return Err(RuntimeManifestError::InvalidArtifact {
6967            index,
6968            message: format!(
6969                "artifact '{driver}' is the transport: process driver and must be declared with \
6970                 runtime: driver"
6971            ),
6972        });
6973    }
6974    if entry.capabilities.is_some() || entry.config.is_some() {
6975        return Err(RuntimeManifestError::InvalidArtifact {
6976            index,
6977            message: format!(
6978                "artifact '{driver}' is the transport: process driver, which is granted nothing; \
6979                 remove capabilities:/config: from its entry — driver settings go in \
6980                 inference.driver.config"
6981            ),
6982        });
6983    }
6984    Ok(())
6985}
6986
6987fn parse_inference_driver(
6988    raw: RawInferenceDriver,
6989) -> Result<InferenceDriver, RuntimeManifestError> {
6990    let artifact = raw
6991        .artifact
6992        .map(|value| value.trim().to_string())
6993        .filter(|value| !value.is_empty())
6994        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6995            field: "inference.driver.artifact".to_string(),
6996            message: "missing required field".to_string(),
6997        })?;
6998
6999    let config = driver_config_json(raw.config, "inference.driver.config")?;
7000
7001    Ok(InferenceDriver { artifact, config })
7002}
7003
7004/// Lowers a driver config mapping to compact JSON, or `None` when absent or null. `field` names
7005/// the key in the refusal of anything but a mapping.
7006fn driver_config_json(
7007    raw: Option<serde_norway::Value>,
7008    field: &str,
7009) -> Result<Option<String>, RuntimeManifestError> {
7010    let Some(raw) = raw else {
7011        return Ok(None);
7012    };
7013
7014    if matches!(raw, serde_norway::Value::Null) {
7015        return Ok(None);
7016    }
7017
7018    if !matches!(raw, serde_norway::Value::Mapping(_)) {
7019        return Err(RuntimeManifestError::InvalidInferenceConfig {
7020            field: field.to_string(),
7021            message: "expected mapping/object".to_string(),
7022        });
7023    }
7024
7025    let value =
7026        serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
7027            field: field.to_string(),
7028            message: format!("failed to convert to JSON: {err}"),
7029        })?;
7030
7031    serde_json::to_string(&value).map(Some).map_err(|err| {
7032        RuntimeManifestError::InvalidInferenceConfig {
7033            field: field.to_string(),
7034            message: format!("failed to encode JSON: {err}"),
7035        }
7036    })
7037}
7038
7039fn parse_env_reference(value: &str) -> Option<&str> {
7040    if !value.starts_with("${") || !value.ends_with('}') {
7041        return None;
7042    }
7043
7044    let variable = &value[2..value.len() - 1];
7045    is_valid_env_variable(variable).then_some(variable)
7046}
7047
7048fn is_valid_env_variable(value: &str) -> bool {
7049    let mut chars = value.chars();
7050    let Some(first) = chars.next() else {
7051        return false;
7052    };
7053
7054    if !(first == '_' || first.is_ascii_uppercase()) {
7055        return false;
7056    }
7057
7058    chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
7059}
7060
7061#[cfg(test)]
7062mod tests {
7063    use super::*;
7064    use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
7065
7066    fn manifest_with_trace(block: &str) -> String {
7067        format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
7068    }
7069
7070    fn lifecycle(task_acceptance: TaskAcceptance, queue_depth: usize) -> LifecycleConfig {
7071        LifecycleConfig {
7072            task_acceptance,
7073            queue_depth,
7074            ..Default::default()
7075        }
7076    }
7077
7078    #[test]
7079    fn tasks_held_at_once_under_queue_is_one_running_plus_queue_depth_waiting() {
7080        assert_eq!(lifecycle(TaskAcceptance::Queue, 1).tasks_held_at_once(), 2);
7081        assert_eq!(lifecycle(TaskAcceptance::Queue, 9).tasks_held_at_once(), 10);
7082    }
7083
7084    #[test]
7085    fn queue_has_room_is_false_only_for_a_queue_at_depth_zero() {
7086        assert!(!lifecycle(TaskAcceptance::Queue, 0).queue_has_room());
7087        assert!(lifecycle(TaskAcceptance::Queue, 1).queue_has_room());
7088        assert!(lifecycle(TaskAcceptance::Single, 0).queue_has_room());
7089        assert!(lifecycle(TaskAcceptance::None, 0).queue_has_room());
7090        assert!(lifecycle(TaskAcceptance::Single, 3).queue_has_room());
7091    }
7092
7093    fn parse_lifecycle(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
7094        RuntimeManifest::from_yaml_str(&format!(
7095            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n{block}"
7096        ))
7097    }
7098
7099    #[test]
7100    fn queue_depth_zero_under_queue_is_refused_at_load() {
7101        let error = parse_lifecycle("  task_acceptance: queue\n  queue_depth: 0\n").unwrap_err();
7102        assert!(
7103            matches!(error, RuntimeManifestError::QueueWithoutRoom),
7104            "{error:?}"
7105        );
7106        assert_eq!(
7107            error.to_string(),
7108            "murmur.yaml: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves \
7109             task_acceptance: queue no room for a waiting task"
7110        );
7111    }
7112
7113    /// `queue_depth` means nothing outside `queue`, so `0` there parses.
7114    #[test]
7115    fn queue_depth_zero_outside_queue_still_parses() {
7116        for block in [
7117            "  task_acceptance: single\n  queue_depth: 0\n",
7118            "  task_acceptance: none\n  queue_depth: 0\n",
7119            "  queue_depth: 0\n",
7120        ] {
7121            let lifecycle = parse_lifecycle(block)
7122                .unwrap_or_else(|error| panic!("{block:?}: {error}"))
7123                .effective_lifecycle();
7124            assert_eq!(lifecycle.queue_depth, 0, "{block:?}");
7125        }
7126        let queue = parse_lifecycle("  task_acceptance: queue\n  queue_depth: 1\n")
7127            .unwrap()
7128            .effective_lifecycle();
7129        assert_eq!(queue.task_acceptance, TaskAcceptance::Queue);
7130        assert_eq!(queue.queue_depth, 1);
7131    }
7132
7133    #[test]
7134    fn tasks_held_at_once_under_single_is_one_whatever_the_depth() {
7135        assert_eq!(lifecycle(TaskAcceptance::Single, 1).tasks_held_at_once(), 1);
7136        assert_eq!(lifecycle(TaskAcceptance::Single, 5).tasks_held_at_once(), 1);
7137    }
7138
7139    #[test]
7140    fn tasks_held_at_once_under_none_is_zero_whatever_the_depth() {
7141        assert_eq!(lifecycle(TaskAcceptance::None, 1).tasks_held_at_once(), 0);
7142        assert_eq!(lifecycle(TaskAcceptance::None, 5).tasks_held_at_once(), 0);
7143    }
7144
7145    /// The default is `single` at `queue_depth: 1`, which holds one task; switching only
7146    /// `task_acceptance` to `queue` keeps the default depth and holds two.
7147    #[test]
7148    fn tasks_held_at_once_of_the_default_lifecycle_and_default_depth() {
7149        assert_eq!(LifecycleConfig::default().tasks_held_at_once(), 1);
7150        let queue = RuntimeManifest::from_yaml_str(
7151            "name: cap\nversion: 0.0.1\nlifecycle:\n  task_acceptance: queue\n",
7152        )
7153        .unwrap()
7154        .effective_lifecycle();
7155        assert_eq!(queue.queue_depth, 1);
7156        assert_eq!(queue.tasks_held_at_once(), 2);
7157    }
7158
7159    /// No `trace:` block at all leaves the config absent; the runtime substitutes
7160    /// [`TraceCapture::default`] rather than the manifest carrying a synthesized one.
7161    #[test]
7162    fn absent_trace_block_stays_absent_and_defaults_to_meta() {
7163        let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
7164        assert_eq!(manifest.trace, None);
7165        assert_eq!(TraceCapture::default(), TraceCapture::Meta);
7166    }
7167
7168    #[test]
7169    fn each_capture_mode_parses_from_the_trace_block() {
7170        for (yaml, expected) in [
7171            ("none", TraceCapture::None),
7172            ("meta", TraceCapture::Meta),
7173            ("content", TraceCapture::Content),
7174        ] {
7175            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
7176                "  capture: {yaml}\n"
7177            )))
7178            .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
7179            assert_eq!(
7180                manifest.trace,
7181                Some(TraceConfig {
7182                    capture: expected,
7183                    retain: None
7184                })
7185            );
7186        }
7187    }
7188
7189    // ── retain ───────────────────────────────────────────────────────────────
7190
7191    /// A `trace:` block with only `capture` and a `context:` block with only `record` declare no
7192    /// retention at all: an absent `retain:` is the only way to say "keep everything", and it is
7193    /// what a capsule that never mentions retention gets.
7194    #[test]
7195    fn no_retain_block_anywhere_parses_as_no_policy() {
7196        let manifest = RuntimeManifest::from_yaml_str(
7197            "name: cap\nversion: 0.0.1\ntrace:\n  capture: content\ncontext:\n  record: on\n",
7198        )
7199        .unwrap();
7200        assert_eq!(manifest.trace.unwrap().retain, None);
7201        assert_eq!(manifest.context.unwrap().retain, None);
7202    }
7203
7204    #[test]
7205    fn trace_retain_parses_both_keys_and_the_day_suffix() {
7206        let manifest = RuntimeManifest::from_yaml_str(
7207            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 50\n    max_age: 14d\n",
7208        )
7209        .unwrap();
7210        assert_eq!(
7211            manifest.trace.unwrap().retain,
7212            Some(TraceRetainConfig {
7213                max_sessions: Some(50),
7214                max_age_secs: Some(14 * 86_400),
7215            })
7216        );
7217    }
7218
7219    fn context_images_of(block: &str) -> Result<ContextImagesConfig, RuntimeManifestError> {
7220        RuntimeManifest::from_yaml_str(&format!(
7221            "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n{block}"
7222        ))
7223        .map(|manifest| manifest.context.unwrap().images)
7224    }
7225
7226    #[test]
7227    fn context_images_defaults_apply_without_the_block_and_to_each_absent_key() {
7228        let defaults = ContextImagesConfig {
7229            keep: 5,
7230            max_bytes: 5 * 1024 * 1024,
7231            max_per_turn: 5,
7232        };
7233        assert_eq!(ContextImagesConfig::default(), defaults);
7234        assert_eq!(context_images_of("").unwrap(), defaults);
7235        assert_eq!(
7236            context_images_of("  images:\n    keep: 2\n").unwrap(),
7237            ContextImagesConfig {
7238                keep: 2,
7239                ..defaults
7240            }
7241        );
7242    }
7243
7244    #[test]
7245    fn context_images_parses_every_key() {
7246        assert_eq!(
7247            context_images_of("  images:\n    keep: 1\n    max_bytes: 1Ki\n    max_per_turn: 9\n")
7248                .unwrap(),
7249            ContextImagesConfig {
7250                keep: 1,
7251                max_bytes: 1024,
7252                max_per_turn: 9,
7253            }
7254        );
7255        assert_eq!(
7256            context_images_of("  images:\n    max_bytes: 4096\n")
7257                .unwrap()
7258                .max_bytes,
7259            4096
7260        );
7261    }
7262
7263    #[test]
7264    fn context_images_refuses_zero_and_a_decimal_byte_suffix() {
7265        for (block, field) in [
7266            ("  images:\n    keep: 0\n", "context.images.keep"),
7267            (
7268                "  images:\n    max_per_turn: 0\n",
7269                "context.images.max_per_turn",
7270            ),
7271            ("  images:\n    max_bytes: 0\n", "context.images.max_bytes"),
7272            (
7273                "  images:\n    max_bytes: 10MB\n",
7274                "context.images.max_bytes",
7275            ),
7276            ("  images:\n    keep: many\n", "context.images.keep"),
7277        ] {
7278            match context_images_of(block) {
7279                Err(RuntimeManifestError::InvalidInferenceConfig { field: got, .. }) => {
7280                    assert_eq!(got, field, "{block}")
7281                }
7282                other => panic!("{block}: expected a refusal naming {field}, got {other:?}"),
7283            }
7284        }
7285        let error = context_images_of("  images:\n    keep: 0\n").unwrap_err();
7286        assert!(
7287            error.to_string().contains("got '0'"),
7288            "the value is named as written: {error}"
7289        );
7290    }
7291
7292    #[test]
7293    fn context_images_unknown_key_is_suggested() {
7294        assert_eq!(
7295            unknown_keys_of("name: cap\nversion: 0.1.0\ncontext:\n  images:\n    kep: 3\n"),
7296            vec![(
7297                "kep".to_string(),
7298                "context.images".to_string(),
7299                Some("keep".to_string())
7300            )]
7301        );
7302    }
7303
7304    #[test]
7305    fn context_retain_parses_both_keys_and_either_alone() {
7306        let both = RuntimeManifest::from_yaml_str(
7307            "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n    max_messages: 2000\n    max_age: 90d\n",
7308        )
7309        .unwrap();
7310        assert_eq!(
7311            both.context.unwrap().retain,
7312            Some(ContextRetainConfig {
7313                max_messages: Some(2000),
7314                max_age_secs: Some(90 * 86_400),
7315            })
7316        );
7317
7318        let count_only = RuntimeManifest::from_yaml_str(
7319            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 10\n",
7320        )
7321        .unwrap();
7322        assert_eq!(
7323            count_only.context.unwrap().retain,
7324            Some(ContextRetainConfig {
7325                max_messages: Some(10),
7326                max_age_secs: None,
7327            })
7328        );
7329    }
7330
7331    /// An empty block is not a way to declare no policy — omitting the block is. Both spellings
7332    /// of empty are refused, naming the block an operator has to go and change.
7333    #[test]
7334    fn an_empty_retain_block_is_refused_naming_the_block() {
7335        for (yaml, expect_trace) in [
7336            (
7337                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain: {}\n",
7338                true,
7339            ),
7340            (
7341                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n",
7342                true,
7343            ),
7344            (
7345                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain: {}\n",
7346                false,
7347            ),
7348            (
7349                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n",
7350                false,
7351            ),
7352        ] {
7353            let err = RuntimeManifest::from_yaml_str(yaml)
7354                .expect_err("an empty retain block must be refused");
7355            let (field, message) = match (&err, expect_trace) {
7356                (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
7357                    (field, message)
7358                }
7359                (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
7360                    (field, message)
7361                }
7362                _ => panic!("wrong variant for {yaml:?}: {err:?}"),
7363            };
7364            assert_eq!(
7365                field,
7366                if expect_trace {
7367                    "trace.retain"
7368                } else {
7369                    "context.retain"
7370                }
7371            );
7372            assert!(message.contains("at least one key"), "{message}");
7373        }
7374    }
7375
7376    /// A limit of zero would delete what retention exists to keep, so it is
7377    /// refused naming the key rather than accepted as "keep nothing".
7378    #[test]
7379    fn a_zero_retain_limit_is_refused_naming_the_key() {
7380        let trace = RuntimeManifest::from_yaml_str(
7381            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 0\n",
7382        )
7383        .expect_err("max_sessions: 0 must be refused");
7384        assert!(
7385            matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
7386                     if field == "trace.retain.max_sessions"),
7387            "{trace:?}"
7388        );
7389
7390        let context = RuntimeManifest::from_yaml_str(
7391            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 0\n",
7392        )
7393        .expect_err("max_messages: 0 must be refused");
7394        assert!(
7395            matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
7396                     if field == "context.retain.max_messages"),
7397            "{context:?}"
7398        );
7399
7400        let age = RuntimeManifest::from_yaml_str(
7401            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_age: 0\n",
7402        )
7403        .expect_err("max_age: 0 must be refused");
7404        assert!(
7405            matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
7406                     if field == "trace.retain.max_age"),
7407            "{age:?}"
7408        );
7409    }
7410
7411    #[test]
7412    fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
7413        let err = RuntimeManifest::from_yaml_str(
7414            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_traces: 3\n",
7415        )
7416        .expect_err("an unknown key must be refused");
7417        match err {
7418            RuntimeManifestError::InvalidTraceConfig { field, message } => {
7419                assert_eq!(field, "trace.retain");
7420                assert!(message.contains("max_traces"), "{message}");
7421                assert!(message.contains("max_sessions"), "{message}");
7422            }
7423            other => panic!("expected InvalidTraceConfig, got {other:?}"),
7424        }
7425    }
7426
7427    #[test]
7428    fn an_unparseable_retain_duration_reports_the_accepted_form() {
7429        let err = RuntimeManifest::from_yaml_str(
7430            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_age: 5 weeks\n",
7431        )
7432        .expect_err("'5 weeks' must be refused");
7433        match err {
7434            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
7435                assert_eq!(field, "context.retain.max_age");
7436                assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
7437            }
7438            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
7439        }
7440    }
7441
7442    #[test]
7443    fn unparseable_capture_value_names_the_field_and_accepted_values() {
7444        let err = RuntimeManifest::from_yaml_str(&manifest_with_trace("  capture: verbose\n"))
7445            .expect_err("'verbose' is not a capture mode");
7446        let rendered = err.to_string();
7447        assert!(rendered.contains("trace.capture"), "{rendered}");
7448        for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
7449            assert!(rendered.contains(accepted), "{rendered}");
7450        }
7451    }
7452
7453    #[test]
7454    fn resources_block_is_optional_and_absent_stays_absent() {
7455        let manifest = RuntimeManifest::from_yaml_str(
7456            r#"
7457name: cap
7458version: 0.0.1
7459capabilities:
7460  shell:
7461    allow: [bash]
7462"#,
7463        )
7464        .unwrap();
7465
7466        // `None` here is "the manifest said nothing", not "unlimited" — the runtime substitutes
7467        // its own defaults (see `capsule_runtime::resources::HostResourceLimits::resolve`), and
7468        // keeping the two states distinct is what lets it own them in one place.
7469        let caps = manifest.capabilities.expect("shell block must parse");
7470        assert_eq!(caps.resources, None);
7471    }
7472
7473    #[test]
7474    fn resources_fields_parse_and_each_stays_independently_optional() {
7475        let manifest = RuntimeManifest::from_yaml_str(
7476            r#"
7477name: cap
7478version: 0.0.1
7479capabilities:
7480  resources:
7481    max_processes: 32
7482    max_open_files: 16
7483    max_file_size_bytes: 1048576
7484    cpu_seconds: 30
7485    memory_bytes: 268435456
7486    cgroup_memory_bytes: 536870912
7487    cgroup_pids_max: 64
7488    cgroup_cpu_percent: 50
7489    cgroup_io_bytes_per_sec: 1048576
7490    workdir_max_bytes: 2097152
7491"#,
7492        )
7493        .unwrap();
7494
7495        let resources = manifest
7496            .capabilities
7497            .and_then(|caps| caps.resources)
7498            .expect("resources block must parse");
7499        assert_eq!(resources.max_processes, Some(32));
7500        assert_eq!(resources.max_open_files, Some(16));
7501        assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
7502        assert_eq!(resources.cpu_seconds, Some(30));
7503        assert_eq!(resources.memory_bytes, Some(268_435_456));
7504        assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
7505        assert_eq!(resources.cgroup_pids_max, Some(64));
7506        assert_eq!(resources.cgroup_cpu_percent, Some(50));
7507        assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
7508        assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
7509    }
7510
7511    #[test]
7512    fn state_block_is_optional_and_absent_stays_absent() {
7513        let manifest = RuntimeManifest::from_yaml_str(
7514            r#"
7515name: cap
7516version: 0.0.1
7517artifacts:
7518  - name: local/notes-tool
7519    version: 1.0.0
7520    runtime: tool
7521    capabilities:
7522      filesystem:
7523        scope: cache
7524"#,
7525        )
7526        .unwrap();
7527
7528        // Absent is deny: no store name to default, and nothing downstream creates a directory.
7529        let caps = manifest.artifacts[0]
7530            .capabilities
7531            .as_ref()
7532            .expect("filesystem block must parse");
7533        assert_eq!(caps.state, None);
7534    }
7535
7536    /// A declared-but-empty `state:` block is a real grant — it means "give me a store, named
7537    /// after the capsule" — so it must lower to `Some(..)` with no name, distinguishable from
7538    /// the absent block above.
7539    #[test]
7540    fn state_block_declared_empty_grants_the_capsule_named_store() {
7541        let manifest = RuntimeManifest::from_yaml_str(
7542            r#"
7543name: cap
7544version: 0.0.1
7545artifacts:
7546  - name: local/notes-tool
7547    version: 1.0.0
7548    runtime: tool
7549    capabilities:
7550      state: {}
7551"#,
7552        )
7553        .unwrap();
7554
7555        let state = manifest.artifacts[0]
7556            .capabilities
7557            .as_ref()
7558            .and_then(|caps| caps.state.as_ref())
7559            .expect("state block must parse");
7560        assert_eq!(state.store, None);
7561    }
7562
7563    #[test]
7564    fn state_store_name_parses_and_stays_independently_optional() {
7565        let manifest = RuntimeManifest::from_yaml_str(
7566            r#"
7567name: cap
7568version: 0.0.1
7569artifacts:
7570  - name: local/notes-tool
7571    version: 1.0.0
7572    runtime: tool
7573    capabilities:
7574      state:
7575        store: "  shey  "
7576      network:
7577        allow: [https://api.example.com]
7578"#,
7579        )
7580        .unwrap();
7581
7582        let caps = manifest.artifacts[0]
7583            .capabilities
7584            .as_ref()
7585            .expect("capabilities must parse");
7586        // Trimmed, but otherwise verbatim: whether the name is a usable directory segment is
7587        // decided by the runtime (`E-CAP-009`), not here.
7588        assert_eq!(
7589            caps.state.as_ref().and_then(|state| state.store.as_deref()),
7590            Some("shey")
7591        );
7592        // Declaring a store never touches any sibling sub-block.
7593        assert_eq!(
7594            caps.network
7595                .as_ref()
7596                .map(|network| network.allow.as_slice()),
7597            Some(["https://api.example.com".to_string()].as_slice())
7598        );
7599        assert_eq!(caps.filesystem, None);
7600    }
7601
7602    /// An explicitly empty name is kept rather than normalised to "default to the capsule name":
7603    /// the operator wrote a name, and handing them a different store than the one they wrote
7604    /// would be worse than the refusal the runtime raises for it.
7605    #[test]
7606    fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
7607        let manifest = RuntimeManifest::from_yaml_str(
7608            r#"
7609name: cap
7610version: 0.0.1
7611artifacts:
7612  - name: local/notes-tool
7613    version: 1.0.0
7614    runtime: tool
7615    capabilities:
7616      state:
7617        store: ""
7618"#,
7619        )
7620        .unwrap();
7621
7622        assert_eq!(
7623            manifest.artifacts[0]
7624                .capabilities
7625                .as_ref()
7626                .and_then(|caps| caps.state.as_ref())
7627                .and_then(|state| state.store.as_deref()),
7628            Some("")
7629        );
7630    }
7631
7632    #[test]
7633    fn resources_partial_block_leaves_undeclared_fields_none() {
7634        let manifest = RuntimeManifest::from_yaml_str(
7635            r#"
7636name: cap
7637version: 0.0.1
7638capabilities:
7639  resources:
7640    max_open_files: 16
7641"#,
7642        )
7643        .unwrap();
7644
7645        let resources = manifest
7646            .capabilities
7647            .and_then(|caps| caps.resources)
7648            .expect("resources block must parse");
7649        assert_eq!(resources.max_open_files, Some(16));
7650        assert_eq!(resources.max_processes, None);
7651        assert_eq!(resources.workdir_max_bytes, None);
7652    }
7653
7654    /// Every field rejects `0` at parse time, naming its own path. A zero ceiling is never what
7655    /// an author means, and accepting one would turn a typo into a subprocess that cannot fork,
7656    /// open a file, or run for a single CPU-second — discovered at runtime instead of at parse.
7657    #[test]
7658    fn resources_zero_is_rejected_on_every_field() {
7659        for field in [
7660            "max_processes",
7661            "max_open_files",
7662            "max_file_size_bytes",
7663            "cpu_seconds",
7664            "memory_bytes",
7665            "cgroup_memory_bytes",
7666            "cgroup_pids_max",
7667            "cgroup_cpu_percent",
7668            "cgroup_io_bytes_per_sec",
7669            "workdir_max_bytes",
7670        ] {
7671            let yaml =
7672                format!("name: cap\nversion: 0.0.1\ncapabilities:\n  resources:\n    {field}: 0\n");
7673            let error =
7674                RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
7675
7676            match error {
7677                RuntimeManifestError::InvalidCapabilities {
7678                    field: reported,
7679                    message,
7680                } => {
7681                    assert_eq!(reported, format!("capabilities.resources.{field}"));
7682                    assert_eq!(message, "must be greater than zero");
7683                }
7684                other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
7685            }
7686        }
7687    }
7688
7689    #[test]
7690    fn artifact_runtime_defaults_to_tool() {
7691        let manifest = RuntimeManifest::from_yaml_str(
7692            r#"
7693name: cap
7694version: 0.0.1
7695artifacts:
7696  - name: local/echo
7697    version: 1.2.3
7698"#,
7699        )
7700        .unwrap();
7701
7702        assert_eq!(manifest.artifacts.len(), 1);
7703        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7704    }
7705
7706    #[test]
7707    fn artifact_runtime_tool_explicit() {
7708        let manifest = RuntimeManifest::from_yaml_str(
7709            r#"
7710name: cap
7711version: 0.0.1
7712artifacts:
7713  - name: local/echo
7714    version: 1.2.3
7715    runtime: tool
7716"#,
7717        )
7718        .unwrap();
7719
7720        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7721    }
7722
7723    #[test]
7724    fn artifact_runtime_wasm_is_error() {
7725        let err = RuntimeManifest::from_yaml_str(
7726            r#"
7727name: cap
7728version: 0.0.1
7729artifacts:
7730  - name: local/echo
7731    version: 1.2.3
7732    runtime: wasm
7733"#,
7734        )
7735        .unwrap_err();
7736
7737        let msg = err.to_string();
7738        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7739        assert!(msg.contains("wasm"), "error was: {msg}");
7740    }
7741
7742    #[test]
7743    fn artifact_runtime_native_is_error() {
7744        let err = RuntimeManifest::from_yaml_str(
7745            r#"
7746name: cap
7747version: 0.0.1
7748artifacts:
7749  - name: local/echo
7750    version: 1.2.3
7751    runtime: native
7752"#,
7753        )
7754        .unwrap_err();
7755
7756        let msg = err.to_string();
7757        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7758        assert!(msg.contains("native"), "error was: {msg}");
7759    }
7760
7761    #[test]
7762    fn invalid_artifact_reports_index() {
7763        let err = RuntimeManifest::from_yaml_str(
7764            r#"
7765name: cap
7766version: 0.0.1
7767artifacts:
7768  - name: ""
7769    version: 1.2.3
7770"#,
7771        )
7772        .unwrap_err();
7773
7774        let msg = err.to_string();
7775        assert!(msg.contains("index 0"));
7776        assert!(msg.contains("missing required field 'name'"));
7777    }
7778
7779    #[test]
7780    fn capabilities_parse_when_all_fields_present() {
7781        let manifest = RuntimeManifest::from_yaml_str(
7782            r#"
7783name: cap
7784version: 0.0.1
7785artifacts:
7786  - name: local/echo
7787    version: 1.2.3
7788capabilities:
7789  network:
7790    allow:
7791      - https://api.anthropic.com
7792      - http://127.0.0.1:8080
7793  filesystem:
7794    scope: ./sandbox
7795"#,
7796        )
7797        .unwrap();
7798
7799        let capabilities = manifest.capabilities.expect("capabilities should exist");
7800        assert_eq!(
7801            capabilities.network.unwrap().allow,
7802            vec![
7803                "https://api.anthropic.com".to_string(),
7804                "http://127.0.0.1:8080".to_string()
7805            ]
7806        );
7807        assert_eq!(
7808            capabilities.filesystem.unwrap().scope,
7809            Some("./sandbox".to_string())
7810        );
7811    }
7812
7813    #[test]
7814    fn env_capabilities_parse_allowlist() {
7815        let manifest = RuntimeManifest::from_yaml_str(
7816            r#"
7817name: cap
7818version: 0.0.1
7819artifacts:
7820  - name: local/echo
7821    version: 1.2.3
7822capabilities:
7823  env:
7824    allow:
7825      - MY_APP_REGION
7826      - GITHUB_TOKEN
7827"#,
7828        )
7829        .unwrap();
7830
7831        let capabilities = manifest.capabilities.expect("capabilities should exist");
7832        assert_eq!(
7833            capabilities.env.unwrap().allow,
7834            vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
7835        );
7836    }
7837
7838    #[test]
7839    fn env_capabilities_absent_when_block_omitted() {
7840        let manifest = RuntimeManifest::from_yaml_str(
7841            r#"
7842name: cap
7843version: 0.0.1
7844artifacts:
7845  - name: local/echo
7846    version: 1.2.3
7847capabilities:
7848  network:
7849    allow:
7850      - https://api.anthropic.com
7851"#,
7852        )
7853        .unwrap();
7854
7855        assert!(manifest.capabilities.unwrap().env.is_none());
7856    }
7857
7858    /// Unlike `capabilities.shell.allow`, an empty `env.allow` is a legitimate no-op rather
7859    /// than a manifest error.
7860    #[test]
7861    fn env_capabilities_allow_empty_list_is_accepted() {
7862        let manifest = RuntimeManifest::from_yaml_str(
7863            r#"
7864name: cap
7865version: 0.0.1
7866artifacts:
7867  - name: local/echo
7868    version: 1.2.3
7869capabilities:
7870  env: {}
7871"#,
7872        )
7873        .unwrap();
7874
7875        assert_eq!(
7876            manifest.capabilities.unwrap().env,
7877            Some(EnvCapabilities { allow: vec![] })
7878        );
7879    }
7880
7881    #[test]
7882    fn missing_capabilities_block_is_backward_compatible() {
7883        let manifest = RuntimeManifest::from_yaml_str(
7884            r#"
7885name: cap
7886version: 0.0.1
7887artifacts:
7888  - name: local/echo
7889    version: 1.2.3
7890"#,
7891        )
7892        .unwrap();
7893
7894        assert!(manifest.capabilities.is_none());
7895    }
7896
7897    #[test]
7898    fn network_only_capabilities_parse() {
7899        let manifest = RuntimeManifest::from_yaml_str(
7900            r#"
7901name: cap
7902version: 0.0.1
7903artifacts:
7904  - name: local/echo
7905    version: 1.2.3
7906capabilities:
7907  network:
7908    allow:
7909      - https://allowed.example.com
7910"#,
7911        )
7912        .unwrap();
7913
7914        let capabilities = manifest.capabilities.unwrap();
7915        assert_eq!(
7916            capabilities.network.unwrap().allow,
7917            vec!["https://allowed.example.com".to_string()]
7918        );
7919        assert!(capabilities.filesystem.is_none());
7920    }
7921
7922    #[test]
7923    fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
7924        let manifest = RuntimeManifest::from_yaml_str(
7925            r#"
7926name: cap
7927version: 0.0.1
7928artifacts:
7929  - name: local/echo
7930    version: 1.2.3
7931capabilities:
7932  network: {}
7933"#,
7934        )
7935        .unwrap();
7936
7937        let capabilities = manifest.capabilities.unwrap();
7938        assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
7939    }
7940
7941    /// `unix_sockets` is optional and denies by default. A capsule that never heard of the key
7942    /// must not silently get `AF_UNIX` sockets — that is the whole `/var/run/docker.sock` escape.
7943    #[test]
7944    fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
7945        let manifest = RuntimeManifest::from_yaml_str(
7946            r#"
7947name: cap
7948version: 0.0.1
7949artifacts:
7950  - name: local/echo
7951    version: 1.2.3
7952capabilities:
7953  network:
7954    allow:
7955      - https://api.anthropic.com
7956"#,
7957        )
7958        .unwrap();
7959
7960        let network = manifest.capabilities.unwrap().network.unwrap();
7961        assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
7962        assert!(!network.unix_sockets);
7963    }
7964
7965    #[test]
7966    fn network_unix_sockets_parses_explicit_false() {
7967        let manifest = RuntimeManifest::from_yaml_str(
7968            r#"
7969name: cap
7970version: 0.0.1
7971artifacts:
7972  - name: local/echo
7973    version: 1.2.3
7974capabilities:
7975  network:
7976    unix_sockets: false
7977"#,
7978        )
7979        .unwrap();
7980
7981        assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
7982    }
7983
7984    #[test]
7985    fn network_unix_sockets_parses_explicit_true() {
7986        let manifest = RuntimeManifest::from_yaml_str(
7987            r#"
7988name: cap
7989version: 0.0.1
7990artifacts:
7991  - name: local/echo
7992    version: 1.2.3
7993capabilities:
7994  network:
7995    unix_sockets: true
7996"#,
7997        )
7998        .unwrap();
7999
8000        let network = manifest.capabilities.unwrap().network.unwrap();
8001        assert!(network.unix_sockets);
8002        // The opt-in is orthogonal to the IP allowlist — declaring it does not imply any host.
8003        assert_eq!(network.allow, Vec::<String>::new());
8004    }
8005
8006    /// `read_only` is normalised on exactly the terms `scope` is — trimmed, with an entry left
8007    /// empty by that trim dropped rather than carried as a rule covering the whole workdir — and
8008    /// path shape is deliberately not judged here.
8009    #[test]
8010    fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
8011        let manifest = RuntimeManifest::from_yaml_str(
8012            r#"
8013name: cap
8014version: 0.0.1
8015capabilities:
8016  filesystem:
8017    read_only:
8018      - tests
8019      - "  bench/fixtures  "
8020      - ""
8021      - "   "
8022"#,
8023        )
8024        .unwrap();
8025
8026        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
8027        assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
8028    }
8029
8030    /// An absent key and an empty list are one declaration: nothing is protected.
8031    #[test]
8032    fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
8033        let manifest = RuntimeManifest::from_yaml_str(
8034            r#"
8035name: cap
8036version: 0.0.1
8037capabilities:
8038  filesystem:
8039    scope: workdir
8040"#,
8041        )
8042        .unwrap();
8043
8044        assert!(manifest
8045            .capabilities
8046            .unwrap()
8047            .filesystem
8048            .unwrap()
8049            .read_only
8050            .is_empty());
8051    }
8052
8053    /// Path shape is the runtime's refusal, not the parser's: an absolute or escaping entry parses
8054    /// here exactly as an absolute `scope` does, and fails at launch with `E-CAP-012`.
8055    #[test]
8056    fn filesystem_read_only_does_not_judge_path_shape() {
8057        let manifest = RuntimeManifest::from_yaml_str(
8058            r#"
8059name: cap
8060version: 0.0.1
8061capabilities:
8062  filesystem:
8063    read_only:
8064      - /etc
8065      - ../outside
8066"#,
8067        )
8068        .expect("shape is judged by the runtime, not the parser");
8069
8070        assert_eq!(
8071            manifest.capabilities.unwrap().filesystem.unwrap().read_only,
8072            vec!["/etc", "../outside"]
8073        );
8074    }
8075
8076    /// The default every pre-existing manifest gets: a `filesystem:` block that has never heard of
8077    /// `workdir_exec` must not silently keep the workdir's `Execute` right, because that right is
8078    /// exactly what makes `capabilities.shell.allow` bypassable from inside the workdir.
8079    #[test]
8080    fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
8081        let manifest = RuntimeManifest::from_yaml_str(
8082            r#"
8083name: cap
8084version: 0.0.1
8085artifacts:
8086  - name: local/echo
8087    version: 1.2.3
8088capabilities:
8089  filesystem:
8090    scope: workdir
8091"#,
8092        )
8093        .unwrap();
8094
8095        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
8096        assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
8097        assert!(!filesystem.workdir_exec);
8098    }
8099
8100    /// The other absent case: no `filesystem:` block at all. There is nothing to default here —
8101    /// the whole sub-block stays `None` — but a capsule with no filesystem declaration must still
8102    /// read as "workdir exec denied" downstream, which `capability_policy_from_runtime_manifest`
8103    /// covers with its own test.
8104    #[test]
8105    fn filesystem_block_stays_absent_when_undeclared() {
8106        let manifest = RuntimeManifest::from_yaml_str(
8107            r#"
8108name: cap
8109version: 0.0.1
8110artifacts:
8111  - name: local/echo
8112    version: 1.2.3
8113capabilities:
8114  network:
8115    allow:
8116      - https://api.anthropic.com
8117"#,
8118        )
8119        .unwrap();
8120
8121        assert!(manifest.capabilities.unwrap().filesystem.is_none());
8122    }
8123
8124    #[test]
8125    fn filesystem_workdir_exec_parses_explicit_false() {
8126        let manifest = RuntimeManifest::from_yaml_str(
8127            r#"
8128name: cap
8129version: 0.0.1
8130artifacts:
8131  - name: local/echo
8132    version: 1.2.3
8133capabilities:
8134  filesystem:
8135    workdir_exec: false
8136"#,
8137        )
8138        .unwrap();
8139
8140        assert!(
8141            !manifest
8142                .capabilities
8143                .unwrap()
8144                .filesystem
8145                .unwrap()
8146                .workdir_exec
8147        );
8148    }
8149
8150    #[test]
8151    fn filesystem_workdir_exec_parses_explicit_true() {
8152        let manifest = RuntimeManifest::from_yaml_str(
8153            r#"
8154name: cap
8155version: 0.0.1
8156artifacts:
8157  - name: local/echo
8158    version: 1.2.3
8159capabilities:
8160  filesystem:
8161    workdir_exec: true
8162"#,
8163        )
8164        .unwrap();
8165
8166        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
8167        assert!(filesystem.workdir_exec);
8168        // Orthogonal to `scope`, exactly as `unix_sockets` is orthogonal to `network.allow`:
8169        // declaring one says nothing about the other.
8170        assert_eq!(filesystem.scope, None);
8171    }
8172
8173    #[test]
8174    fn network_internal_port_parses() {
8175        let manifest = RuntimeManifest::from_yaml_str(
8176            r#"
8177name: worker
8178version: 0.1.0
8179artifacts: []
8180
8181network:
8182  internal_port: 58172
8183"#,
8184        )
8185        .unwrap();
8186
8187        assert_eq!(
8188            manifest.network.unwrap().internal_port,
8189            Some(58172),
8190            "network.internal_port should parse to Some(58172)"
8191        );
8192    }
8193
8194    #[test]
8195    fn network_internal_port_absent_when_omitted() {
8196        let manifest = RuntimeManifest::from_yaml_str(
8197            r#"
8198name: worker
8199version: 0.1.0
8200artifacts: []
8201"#,
8202        )
8203        .unwrap();
8204
8205        assert!(manifest.network.is_none());
8206    }
8207
8208    #[test]
8209    fn shell_capabilities_parse_with_allow_and_env_fields() {
8210        let manifest = RuntimeManifest::from_yaml_str(
8211            r#"
8212name: cap
8213version: 0.0.1
8214artifacts: []
8215capabilities:
8216  shell:
8217    allow:
8218      - bash
8219      - git
8220    strip_env:
8221      - "*_TOKEN"
8222      - SECRET_KEY
8223    baseline_env:
8224      - SSH_AUTH_SOCK
8225"#,
8226        )
8227        .unwrap();
8228
8229        let shell = manifest
8230            .capabilities
8231            .unwrap()
8232            .shell
8233            .expect("shell capabilities should be present");
8234        assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
8235        assert_eq!(
8236            shell.strip_env,
8237            Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
8238        );
8239        assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
8240    }
8241
8242    #[test]
8243    fn shell_capabilities_require_non_empty_allowlist() {
8244        let err = RuntimeManifest::from_yaml_str(
8245            r#"
8246name: cap
8247version: 0.0.1
8248artifacts: []
8249capabilities:
8250  shell:
8251    allow: []
8252"#,
8253        )
8254        .unwrap_err();
8255
8256        let msg = err.to_string();
8257        assert!(msg.contains("capabilities.shell.allow"));
8258        assert!(msg.contains("at least one"));
8259    }
8260
8261    fn shell_allow_error(entry: &str) -> String {
8262        let yaml = format!(
8263            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  shell:\n    allow: [bash, {}]\n",
8264            serde_json::to_string(entry).unwrap()
8265        );
8266        RuntimeManifest::from_yaml_str(&yaml)
8267            .expect_err(&format!("{entry:?} must be refused"))
8268            .to_string()
8269    }
8270
8271    #[test]
8272    fn shell_allow_refuses_an_entry_that_is_not_a_bare_name() {
8273        for (entry, reason) in [
8274            ("/tmp/uv-tool/bin/uv", "is a path"),
8275            ("bin/uv", "is a path"),
8276            ("..", "names a directory"),
8277            (".", "names a directory"),
8278            ("", "is empty"),
8279            ("a b", "contains whitespace"),
8280            ("uv\t", "contains whitespace"),
8281            ("uv\0", "contains a NUL byte"),
8282        ] {
8283            let message = shell_allow_error(entry);
8284            assert!(
8285                message.contains("'capabilities.shell.allow[1]'"),
8286                "{entry:?}: {message}"
8287            );
8288            assert!(
8289                message.contains(&format!(
8290                    "'{}' {reason}; an entry is a bare binary name",
8291                    entry.escape_debug()
8292                )),
8293                "{entry:?}: {message}"
8294            );
8295        }
8296    }
8297
8298    #[test]
8299    fn shell_allow_accepts_bare_names() {
8300        let manifest = RuntimeManifest::from_yaml_str(
8301            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  shell:\n    allow: [bash, python3.12, git-lfs, \"c++\"]\n",
8302        )
8303        .unwrap();
8304        assert_eq!(
8305            manifest.capabilities.unwrap().shell.unwrap().allow,
8306            ["bash", "python3.12", "git-lfs", "c++"]
8307        );
8308    }
8309
8310    #[test]
8311    fn interpreter_runtime_parses_accepted_shape() {
8312        let manifest = RuntimeManifest::from_yaml_str(
8313            r#"
8314name: cap
8315version: 0.0.1
8316artifacts: []
8317capabilities:
8318  shell:
8319    allow:
8320      - python3
8321    interpreter_runtime:
8322      - binary: python3
8323        dirs:
8324          - path: /usr/lib/python3.11
8325            list_dir: true
8326          - path: /usr/lib/python3.11/lib-dynload
8327            list_dir: false
8328"#,
8329        )
8330        .unwrap();
8331
8332        let shell = manifest.capabilities.unwrap().shell.unwrap();
8333        assert_eq!(
8334            shell.interpreter_runtime,
8335            vec![InterpreterRuntimeGrant {
8336                binary: "python3".to_string(),
8337                dirs: vec![
8338                    InterpreterRuntimeDir {
8339                        path: "/usr/lib/python3.11".to_string(),
8340                        list_dir: true,
8341                    },
8342                    InterpreterRuntimeDir {
8343                        path: "/usr/lib/python3.11/lib-dynload".to_string(),
8344                        list_dir: false,
8345                    },
8346                ],
8347            }]
8348        );
8349    }
8350
8351    #[test]
8352    fn interpreter_runtime_defaults_to_empty_when_absent() {
8353        let manifest = RuntimeManifest::from_yaml_str(
8354            r#"
8355name: cap
8356version: 0.0.1
8357artifacts: []
8358capabilities:
8359  shell:
8360    allow:
8361      - python3
8362"#,
8363        )
8364        .unwrap();
8365
8366        let shell = manifest.capabilities.unwrap().shell.unwrap();
8367        assert!(shell.interpreter_runtime.is_empty());
8368    }
8369
8370    /// Shared helper: parse a manifest whose sole `interpreter_runtime` grant is `grant_yaml`
8371    /// (indented to sit under `interpreter_runtime:`), returning the error message.
8372    fn interpreter_runtime_reject(grant_yaml: &str) -> String {
8373        let yaml = format!(
8374            r#"
8375name: cap
8376version: 0.0.1
8377artifacts: []
8378capabilities:
8379  shell:
8380    allow:
8381      - python3
8382    interpreter_runtime:
8383{grant_yaml}
8384"#,
8385        );
8386        RuntimeManifest::from_yaml_str(&yaml)
8387            .expect_err("grant should be rejected")
8388            .to_string()
8389    }
8390
8391    #[test]
8392    fn interpreter_runtime_rejects_binary_not_in_allow() {
8393        let msg = interpreter_runtime_reject(
8394            "      - binary: ruby\n        dirs:\n          - path: /usr/lib/ruby\n            list_dir: true\n",
8395        );
8396        assert!(
8397            msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
8398            "{msg}"
8399        );
8400        assert!(msg.contains("ruby"), "{msg}");
8401        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
8402    }
8403
8404    #[test]
8405    fn interpreter_runtime_rejects_relative_path() {
8406        let msg = interpreter_runtime_reject(
8407            "      - binary: python3\n        dirs:\n          - path: usr/lib/python3.11\n            list_dir: true\n",
8408        );
8409        assert!(
8410            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
8411            "{msg}"
8412        );
8413        assert!(msg.contains("absolute"), "{msg}");
8414    }
8415
8416    // ── capabilities.plan (capsule-wide) ─────────────────────────────────────
8417
8418    /// A capsule manifest whose capsule-wide capabilities carry `plan: <block>`.
8419    fn manifest_with_plan(block: &str) -> String {
8420        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n{block}")
8421    }
8422
8423    #[test]
8424    fn plan_submit_true_grants_the_capsule() {
8425        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n"))
8426            .expect("a plan block parses");
8427        assert_eq!(
8428            manifest.capabilities.unwrap().plan,
8429            Some(PlanCapabilities { submit: true })
8430        );
8431    }
8432
8433    /// `submit: false` and an absent `plan:` block both leave the capsule ungranted; the
8434    /// difference between them is only whether the operator wrote the denial down.
8435    #[test]
8436    fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
8437        let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: false\n"))
8438            .expect("an explicit denial parses");
8439        assert_eq!(
8440            explicit.capabilities.unwrap().plan,
8441            Some(PlanCapabilities { submit: false })
8442        );
8443
8444        let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
8445            .expect("no capabilities block parses");
8446        assert!(absent.capabilities.is_none());
8447    }
8448
8449    /// The capability is never inferred: a `plan:` block that omits `submit:` is a parse error
8450    /// naming the key, exactly as `task_io:` without `read:` is.
8451    #[test]
8452    fn plan_without_submit_is_rejected() {
8453        let err = RuntimeManifest::from_yaml_str(&manifest_with_plan("    {}\n"))
8454            .expect_err("an empty plan block is refused");
8455        let msg = err.to_string();
8456        assert!(msg.contains("capabilities.plan.submit"), "{msg}");
8457        assert!(msg.contains("never"), "{msg}");
8458    }
8459
8460    /// An unknown key inside the block is reported by path, like every other capability block.
8461    #[test]
8462    fn an_unknown_plan_key_is_reported_by_path() {
8463        let manifest =
8464            RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n    depth: 3\n"))
8465                .expect("an unknown key is a warning, not a parse error");
8466        assert!(
8467            manifest
8468                .unknown_keys
8469                .iter()
8470                .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
8471            "{:?}",
8472            manifest.unknown_keys
8473        );
8474    }
8475
8476    // ── capabilities.install (capsule-wide) ──────────────────────────────────
8477
8478    /// A capsule manifest whose capsule-wide capabilities carry `install: <block>`.
8479    fn manifest_with_install(block: &str) -> String {
8480        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  install:\n{block}")
8481    }
8482
8483    #[test]
8484    fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
8485        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8486            "    skill: [ana/code-review, \"ana/style-*\"]\n    tool: [\"local/b-*\", local/a, \"murmur/*\", \"*\"]\n",
8487        ))
8488        .expect("an install block parses");
8489        assert_eq!(
8490            manifest.capabilities.unwrap().install,
8491            Some(InstallCapabilities {
8492                skill: vec!["ana/code-review".into(), "ana/style-*".into()],
8493                tool: vec![
8494                    "local/b-*".into(),
8495                    "local/a".into(),
8496                    "murmur/*".into(),
8497                    "*".into()
8498                ],
8499                from: InstallFrom::Stores,
8500            })
8501        );
8502    }
8503
8504    /// Either list alone is a grant; the other defaults to empty, which admits nothing of its
8505    /// kind.
8506    #[test]
8507    fn capabilities_install_accepts_either_list_alone() {
8508        let skill_only =
8509            RuntimeManifest::from_yaml_str(&manifest_with_install("    skill: [local/a]\n"))
8510                .expect("skill alone parses");
8511        assert_eq!(
8512            skill_only.capabilities.unwrap().install,
8513            Some(InstallCapabilities {
8514                skill: vec!["local/a".into()],
8515                tool: vec![],
8516                from: InstallFrom::Stores,
8517            })
8518        );
8519        let tool_only =
8520            RuntimeManifest::from_yaml_str(&manifest_with_install("    tool: [\"local/b-*\"]\n"))
8521                .expect("tool alone parses");
8522        assert_eq!(
8523            tool_only.capabilities.unwrap().install,
8524            Some(InstallCapabilities {
8525                skill: vec![],
8526                tool: vec!["local/b-*".into()],
8527                from: InstallFrom::Stores,
8528            })
8529        );
8530    }
8531
8532    fn manifest_with_harness_paths(entries: &str) -> String {
8533        format!(
8534            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  harness:\n    paths:\n{entries}"
8535        )
8536    }
8537
8538    #[test]
8539    fn capabilities_harness_paths_parse_in_declaration_order() {
8540        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_harness_paths(
8541            "      - { path: ~/.claude, access: read-write }\n      - { path: /opt/harness/, access: read }\n",
8542        ))
8543        .expect("a harness block parses");
8544        assert_eq!(
8545            manifest.capabilities.unwrap().harness,
8546            Some(HarnessCapabilities {
8547                paths: vec![
8548                    HarnessPathEntry {
8549                        path: "~/.claude".into(),
8550                        access: HarnessPathAccess::ReadWrite,
8551                    },
8552                    HarnessPathEntry {
8553                        path: "/opt/harness/".into(),
8554                        access: HarnessPathAccess::Read,
8555                    },
8556                ],
8557            })
8558        );
8559    }
8560
8561    /// The shape a process driver's path need must have: a folder below `~/` or `/`. The whole
8562    /// home, the whole filesystem, a relative path and a `.`/`..`/`//` segment are refused at
8563    /// parse, naming the entry.
8564    #[test]
8565    fn capabilities_harness_paths_refuse_every_wrong_shape_naming_the_entry() {
8566        for (path, expected) in [
8567            ("/", "names /, the whole host filesystem"),
8568            ("\"~\"", "names the whole home folder"),
8569            ("~/", "names the whole home folder"),
8570            ("relative/dir", "is relative"),
8571            ("./dir", "is relative"),
8572            ("~user/dir", "is relative"),
8573            ("$HOME/dir", "is relative"),
8574            ("/opt/../etc", "has a . or .. segment"),
8575            ("~/a/./b", "has a . or .. segment"),
8576            ("/opt//x", "has an empty path segment"),
8577        ] {
8578            let err = RuntimeManifest::from_yaml_str(&manifest_with_harness_paths(&format!(
8579                "      - {{ path: {path}, access: read-write }}\n"
8580            )))
8581            .expect_err("a wrongly shaped harness path is refused");
8582            match &err {
8583                RuntimeManifestError::InvalidCapabilities { field, message } => {
8584                    assert_eq!(field, "capabilities.harness.paths[0].path", "{path}");
8585                    assert!(message.contains(expected), "{path}: {message}");
8586                }
8587                other => panic!("{path}: expected InvalidCapabilities, got {other:?}"),
8588            }
8589        }
8590    }
8591
8592    #[test]
8593    fn capabilities_harness_paths_require_a_known_access() {
8594        for (entry, expected) in [
8595            ("{ path: ~/x }", "access is required"),
8596            ("{ path: ~/x, access: write }", "access 'write' is neither"),
8597            ("{ access: read }", "must name a host directory"),
8598        ] {
8599            let err = RuntimeManifest::from_yaml_str(&manifest_with_harness_paths(&format!(
8600                "      - {entry}\n"
8601            )))
8602            .expect_err("an incomplete entry is refused");
8603            assert!(err.to_string().contains(expected), "{entry}: {err}");
8604        }
8605    }
8606
8607    #[test]
8608    fn capabilities_harness_paths_report_an_unknown_key_inside_an_entry() {
8609        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_harness_paths(
8610            "      - { path: ~/x, access: read, acess: read }\n",
8611        ))
8612        .expect("an unknown key is reported, not refused");
8613        assert!(
8614            manifest
8615                .unknown_keys
8616                .iter()
8617                .any(|key| key.block_path == "capabilities.harness.paths[0]" && key.key == "acess"),
8618            "{:?}",
8619            manifest.unknown_keys
8620        );
8621    }
8622
8623    #[test]
8624    fn capabilities_install_absent_is_none() {
8625        let manifest = RuntimeManifest::from_yaml_str(
8626            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n    submit: true\n",
8627        )
8628        .expect("a capabilities block without install parses");
8629        assert_eq!(manifest.capabilities.unwrap().install, None);
8630    }
8631
8632    fn install_from(list: &str) -> Result<InstallFrom, RuntimeManifestError> {
8633        RuntimeManifest::from_yaml_str(&manifest_with_install(&format!(
8634            "    tool: [\"murmur/tool-*\"]\n    from: {list}\n"
8635        )))
8636        .map(|manifest| manifest.capabilities.unwrap().install.unwrap().from)
8637    }
8638
8639    #[test]
8640    fn install_from_absent_is_stores() {
8641        let manifest =
8642            RuntimeManifest::from_yaml_str(&manifest_with_install("    tool: [local/a]\n"))
8643                .unwrap();
8644        assert_eq!(
8645            manifest.capabilities.unwrap().install.unwrap().from,
8646            InstallFrom::Stores
8647        );
8648    }
8649
8650    #[test]
8651    fn install_from_accepts_stores_alone_and_with_sources_in_either_order() {
8652        assert_eq!(install_from("[stores]").unwrap(), InstallFrom::Stores);
8653        assert_eq!(
8654            install_from("[stores, sources]").unwrap(),
8655            InstallFrom::StoresAndSources
8656        );
8657        assert_eq!(
8658            install_from("[sources, stores]").unwrap(),
8659            InstallFrom::StoresAndSources
8660        );
8661        assert_eq!(InstallFrom::StoresAndSources.as_list(), "stores, sources");
8662        assert!(InstallFrom::StoresAndSources.sources());
8663        assert!(!InstallFrom::Stores.sources());
8664    }
8665
8666    #[test]
8667    fn install_from_refuses_empty_sourceless_duplicate_and_unknown_lists() {
8668        for (list, why) in [
8669            ("[]", "is empty"),
8670            ("[sources]", "does not name stores"),
8671            ("[stores, stores]", "names \"stores\" twice"),
8672            ("[github]", "names \"github\", which is not a place"),
8673        ] {
8674            match install_from(list) {
8675                Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8676                    assert_eq!(field, "capabilities.install.from", "{list}");
8677                    assert!(message.contains(why), "{list}: {message}");
8678                    assert!(
8679                        message.contains("expected [stores] or [stores, sources]"),
8680                        "{list}: {message}"
8681                    );
8682                }
8683                other => panic!("{list}: expected InvalidCapabilities, got {other:?}"),
8684            }
8685        }
8686    }
8687
8688    #[test]
8689    fn install_from_is_no_unknown_key() {
8690        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8691            "    tool: [local/a]\n    from: [stores]\n",
8692        ))
8693        .unwrap();
8694        assert!(
8695            manifest
8696                .unknown_keys
8697                .iter()
8698                .all(|key| key.block_path != "capabilities.install"),
8699            "{:?}",
8700            manifest.unknown_keys
8701        );
8702    }
8703
8704    #[test]
8705    fn install_from_capabilities_install_is_refused_under_transport_process() {
8706        let yaml = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  install:\n    \
8707                    tool: [\"murmur/tool-*\"]\ninference:\n  transport: process\n  \
8708                    command: claude\n  model: claude-haiku-4-5-20251001\n  driver:\n    \
8709                    artifact: murmur/driver-claude-code\n";
8710        match RuntimeManifest::from_yaml_str(yaml) {
8711            Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8712                assert_eq!(field, "capabilities.install");
8713                assert_eq!(
8714                    message,
8715                    "is not valid with transport: process; the harness owns its tool list, so \
8716                     a pulled artifact could never reach it"
8717                );
8718            }
8719            other => panic!("expected InvalidCapabilities, got {other:?}"),
8720        }
8721    }
8722
8723    /// A block that names nothing is refused rather than read as "grant nothing": omitting the
8724    /// block is how a capsule grants nothing.
8725    #[test]
8726    fn capabilities_install_that_names_nothing_is_refused() {
8727        for block in [
8728            "    {}\n",
8729            "    skill: []\n",
8730            "    skill: []\n    tool: []\n",
8731            "    driver: [x]\n",
8732        ] {
8733            let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
8734                .expect_err("an install block naming nothing is refused");
8735            match &err {
8736                RuntimeManifestError::InvalidCapabilities { field, message } => {
8737                    assert_eq!(field, "capabilities.install", "{block:?}");
8738                    assert!(message.contains("at least one"), "{message}");
8739                    assert!(message.contains("omit the block"), "{message}");
8740                }
8741                other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
8742            }
8743        }
8744    }
8745
8746    #[test]
8747    fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
8748        for entry in [
8749            "a*b", "*x", "**", "Foo", "-a", "a/b/c", " ", "*/x", "a/*x", "a/x*y", "A/x",
8750        ] {
8751            for key in ["skill", "tool"] {
8752                let block = format!("    {key}: [{entry:?}]\n");
8753                let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8754                    .expect_err("a bad entry is refused");
8755                match &err {
8756                    RuntimeManifestError::InvalidArtifactName {
8757                        field,
8758                        message,
8759                        hint,
8760                    } => {
8761                        assert_eq!(field, &format!("capabilities.install.{key}[0]"));
8762                        assert!(message.contains(&format!("{entry:?}")), "{message}");
8763                        assert!(
8764                            message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
8765                            "{message}"
8766                        );
8767                        assert_eq!(hint, &None, "{entry:?}");
8768                    }
8769                    other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8770                }
8771            }
8772        }
8773    }
8774
8775    /// An entry with no scope is refused at its index with the hint naming its scoped forms.
8776    #[test]
8777    fn capabilities_install_refuses_an_unscoped_entry_with_the_scoped_hint() {
8778        for (entry, scoped) in [
8779            ("tool-*", "local/tool-*"),
8780            ("mur*", "local/mur*"),
8781            ("tool-x", "local/tool-x"),
8782            ("murmur-tool-editor", "murmur/tool-editor"),
8783        ] {
8784            let block = format!("    tool: [\"local/ok\", {entry:?}]\n");
8785            let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8786                .expect_err("an unscoped entry is refused");
8787            match &err {
8788                RuntimeManifestError::InvalidArtifactName {
8789                    field,
8790                    message,
8791                    hint,
8792                } => {
8793                    assert_eq!(field, "capabilities.install.tool[1]");
8794                    assert_eq!(
8795                        message,
8796                        &format!(
8797                            "entry {entry:?} is invalid ('{entry}' names no scope); expected {}",
8798                            crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
8799                        )
8800                    );
8801                    let hint = hint.as_deref().expect("an unscoped entry carries a hint");
8802                    assert!(hint.contains(scoped), "{entry:?}: {hint}");
8803                }
8804                other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8805            }
8806            assert!(
8807                err.to_string().contains("'capabilities.install.tool[1]'"),
8808                "{err}"
8809            );
8810        }
8811    }
8812
8813    /// The grant is held by the session, so a per-artifact block is refused on every role.
8814    #[test]
8815    fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
8816        for runtime in ["tool", "hook", "driver", "skill"] {
8817            let yaml = format!(
8818                "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/a\n    version: 0.1.0\n    \
8819                 runtime: {runtime}\n    capabilities:\n      install:\n        skill: [x]\n"
8820            );
8821            let err = RuntimeManifest::from_yaml_str(&yaml)
8822                .expect_err("a per-artifact install block is refused");
8823            match &err {
8824                RuntimeManifestError::InvalidArtifact { index, message } => {
8825                    assert_eq!(*index, 0);
8826                    assert!(message.contains("capabilities.install"), "{message}");
8827                    assert!(message.contains("capsule-wide"), "{message}");
8828                }
8829                other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
8830            }
8831        }
8832    }
8833
8834    /// An unknown key inside the block is reported by path and grants nothing.
8835    #[test]
8836    fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
8837        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8838            "    skill: [local/a]\n    driver: [x]\n",
8839        ))
8840        .expect("an unknown key beside a real entry is a warning, not a parse error");
8841        assert!(
8842            manifest
8843                .unknown_keys
8844                .iter()
8845                .any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
8846            "{:?}",
8847            manifest.unknown_keys
8848        );
8849        assert_eq!(
8850            manifest.capabilities.unwrap().install,
8851            Some(InstallCapabilities {
8852                skill: vec!["local/a".into()],
8853                tool: vec![],
8854                from: InstallFrom::Stores,
8855            })
8856        );
8857    }
8858
8859    // ── capabilities.task_io (per-hook, honored only on runtime: hook) ────────
8860
8861    /// A capsule manifest with one artifact entry carrying `capabilities: task_io: <block>`.
8862    fn manifest_with_task_io(runtime: &str, block: &str) -> String {
8863        format!(
8864            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/gate\n    version: 1.2.3\n    \
8865             runtime: {runtime}\n    capabilities:\n      task_io:\n{block}"
8866        )
8867    }
8868
8869    #[test]
8870    fn task_io_read_true_grants_the_hook() {
8871        let manifest =
8872            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: true\n"))
8873                .expect("task_io on a hook entry parses");
8874        assert_eq!(
8875            manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
8876            Some(TaskIoCapabilities { read: true })
8877        );
8878    }
8879
8880    /// `read: false` and an absent `task_io:` block both leave the hook ungranted; the
8881    /// difference between them is only whether the operator wrote the denial down.
8882    #[test]
8883    fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
8884        let explicit =
8885            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: false\n"))
8886                .expect("an explicit denial parses");
8887        assert_eq!(
8888            explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
8889            Some(TaskIoCapabilities { read: false })
8890        );
8891
8892        let absent = RuntimeManifest::from_yaml_str(
8893            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/gate\n    version: 1.2.3\n    \
8894             runtime: hook\n",
8895        )
8896        .expect("no capabilities block parses");
8897        assert!(absent.artifacts[0].capabilities.is_none());
8898    }
8899
8900    /// The capability is never inferred: a `task_io:` block that omits `read:` is a parse
8901    /// error naming the key, following `interpreter_runtime[].dirs[].list_dir`.
8902    #[test]
8903    fn task_io_without_read_is_rejected() {
8904        let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        {}\n"))
8905            .unwrap_err();
8906        let msg = err.to_string();
8907        assert!(
8908            msg.contains("capabilities.task_io.read"),
8909            "error was: {msg}"
8910        );
8911        assert!(msg.contains("explicitly"), "error was: {msg}");
8912    }
8913
8914    /// Nothing outside a hook can be handed the import, so declaring the key there is a parse
8915    /// error rather than a silently inert grant.
8916    #[test]
8917    fn task_io_outside_a_hook_entry_is_rejected() {
8918        for runtime in ["tool", "driver", "skill"] {
8919            let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
8920                runtime,
8921                "        read: true\n",
8922            ))
8923            .unwrap_err();
8924            let msg = err.to_string();
8925            assert!(
8926                msg.contains("capabilities") && msg.contains("runtime: hook"),
8927                "{runtime} entry: error was: {msg}"
8928            );
8929        }
8930    }
8931
8932    // ── capabilities.filesystem.records (per-hook, honored only on runtime: hook) ──
8933
8934    /// A capsule manifest with one artifact entry carrying `capabilities: filesystem: <block>`.
8935    fn manifest_with_filesystem(runtime: &str, block: &str) -> String {
8936        format!(
8937            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/thing\n    version: 0.1.0\n    \
8938             runtime: {runtime}\n    capabilities:\n      filesystem:\n{block}"
8939        )
8940    }
8941
8942    /// `records: true` on a hook entry grants it; absent, it is `false`.
8943    #[test]
8944    fn filesystem_records_on_a_hook_entry_parses() {
8945        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8946            "hook",
8947            "        records: true\n",
8948        ))
8949        .expect("records on a hook entry parses");
8950        let filesystem = manifest.artifacts[0]
8951            .capabilities
8952            .as_ref()
8953            .unwrap()
8954            .filesystem
8955            .as_ref()
8956            .unwrap();
8957        assert!(filesystem.records);
8958        assert_eq!(filesystem.scope, None);
8959
8960        let scoped =
8961            RuntimeManifest::from_yaml_str(&manifest_with_filesystem("hook", "        scope: .\n"))
8962                .unwrap();
8963        let filesystem = scoped.artifacts[0]
8964            .capabilities
8965            .as_ref()
8966            .unwrap()
8967            .filesystem
8968            .as_ref()
8969            .unwrap();
8970        assert!(!filesystem.records);
8971        assert!(scoped.unknown_keys.is_empty(), "{:?}", scoped.unknown_keys);
8972    }
8973
8974    /// Beside a `scope`, on any other role and in the capsule-wide block, `records: true` is
8975    /// refused, naming the field.
8976    #[test]
8977    fn filesystem_records_is_refused_beside_a_scope_off_a_hook_and_capsule_wide() {
8978        let beside = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8979            "hook",
8980            "        records: true\n        scope: .\n",
8981        ))
8982        .unwrap_err()
8983        .to_string();
8984        assert!(
8985            beside.contains("capabilities.filesystem.records"),
8986            "{beside}"
8987        );
8988        assert!(beside.contains("capabilities.filesystem.scope"), "{beside}");
8989        for runtime in ["tool", "driver"] {
8990            let off_hook = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8991                runtime,
8992                "        records: true\n",
8993            ))
8994            .unwrap_err()
8995            .to_string();
8996            assert!(
8997                off_hook.contains("capabilities.filesystem.records"),
8998                "{off_hook}"
8999            );
9000            assert!(off_hook.contains("runtime: hook"), "{off_hook}");
9001        }
9002        let capsule_wide = RuntimeManifest::from_yaml_str(
9003            "name: cap\nversion: 0.0.1\ncapabilities:\n  filesystem:\n    records: true\n",
9004        )
9005        .unwrap_err()
9006        .to_string();
9007        assert!(
9008            capsule_wide.contains("capabilities.filesystem.records"),
9009            "{capsule_wide}"
9010        );
9011    }
9012
9013    /// The capsule-wide block reaches capsule, tool and driver components, none of which can
9014    /// receive the import — so it is rejected there too, naming the key.
9015    #[test]
9016    fn task_io_in_the_capsule_wide_block_is_rejected() {
9017        let err = RuntimeManifest::from_yaml_str(
9018            "name: cap\nversion: 0.0.1\ncapabilities:\n  task_io:\n    read: true\n",
9019        )
9020        .unwrap_err();
9021        let msg = err.to_string();
9022        assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
9023        assert!(msg.contains("runtime: hook"), "error was: {msg}");
9024    }
9025
9026    // ── capabilities.conversation (per-hook, honored only on runtime: hook) ───
9027
9028    /// A capsule manifest with one artifact entry carrying `capabilities: conversation: <block>`.
9029    fn manifest_with_conversation(runtime: &str, block: &str) -> String {
9030        format!(
9031            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/memory\n    version: 1.2.3\n    \
9032             runtime: {runtime}\n    capabilities:\n      conversation:\n{block}"
9033        )
9034    }
9035
9036    #[test]
9037    fn conversation_read_lowers_onto_the_hook_entry() {
9038        for (declared, expected) in [("true", true), ("false", false)] {
9039            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
9040                "hook",
9041                &format!("        read: {declared}\n"),
9042            ))
9043            .expect("conversation on a hook entry parses");
9044            assert_eq!(
9045                manifest.artifacts[0]
9046                    .capabilities
9047                    .as_ref()
9048                    .unwrap()
9049                    .conversation,
9050                Some(ConversationCapabilities { read: expected })
9051            );
9052        }
9053    }
9054
9055    /// The capability is never inferred: a block that omits `read:` is a parse error naming the
9056    /// key, exactly as `capabilities.task_io.read` is.
9057    #[test]
9058    fn conversation_without_read_is_rejected() {
9059        let err =
9060            RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", "        {}\n"))
9061                .unwrap_err();
9062        let msg = err.to_string();
9063        assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
9064        assert!(msg.contains("explicitly"), "was: {msg}");
9065    }
9066
9067    /// Only a hook's world can import the interface, so declaring the grant on any other role is
9068    /// a parse error rather than a silently inert block.
9069    #[test]
9070    fn conversation_outside_a_hook_entry_is_rejected() {
9071        for runtime in ["tool", "driver", "skill"] {
9072            let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
9073                runtime,
9074                "        read: true\n",
9075            ))
9076            .unwrap_err();
9077            let msg = err.to_string();
9078            assert!(
9079                msg.contains("capabilities") && msg.contains("runtime: hook"),
9080                "{runtime} entry: error was: {msg}"
9081            );
9082        }
9083    }
9084
9085    /// Unlike `task_io`, the capsule-wide block parses: it is inert, and the runtime says so once
9086    /// as `W-SEC-016` rather than refusing a manifest an operator can fix at leisure.
9087    #[test]
9088    fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
9089        let manifest = RuntimeManifest::from_yaml_str(
9090            "name: cap\nversion: 0.0.1\ncapabilities:\n  conversation:\n    read: true\n",
9091        )
9092        .expect("a capsule-wide block is accepted");
9093        assert_eq!(
9094            manifest.capabilities.unwrap().conversation,
9095            Some(ConversationCapabilities { read: true })
9096        );
9097    }
9098
9099    // ── context.record / context.record_store ────────────────────────────────
9100
9101    /// The record is on unless the manifest says otherwise, and `record_store` defaults to
9102    /// nothing — the runtime substitutes the capsule name.
9103    #[test]
9104    fn a_context_block_records_by_default() {
9105        let context = context_of("context:\n  max_tokens: 1000\n").unwrap();
9106        assert!(context.record);
9107        assert_eq!(context.record_store, None);
9108    }
9109
9110    #[test]
9111    fn record_off_and_on_are_the_two_accepted_values() {
9112        assert!(!context_of("context:\n  record: off\n").unwrap().record);
9113        assert!(context_of("context:\n  record: on\n").unwrap().record);
9114
9115        let err = RuntimeManifest::from_yaml_str(
9116            "name: cap\nversion: 0.0.1\ncontext:\n  record: sometimes\n",
9117        )
9118        .unwrap_err();
9119        let msg = err.to_string();
9120        assert!(msg.contains("context.record"), "was: {msg}");
9121        assert!(msg.contains("sometimes"), "was: {msg}");
9122    }
9123
9124    /// `record_store` is a name the runtime turns into a directory, so it is kept verbatim apart
9125    /// from surrounding whitespace; its *shape* is refused at launch, where the path is built.
9126    #[test]
9127    fn record_store_is_kept_verbatim_and_trimmed() {
9128        assert_eq!(
9129            context_of("context:\n  record_store: '  shey  '\n")
9130                .unwrap()
9131                .record_store,
9132            Some("shey".to_string())
9133        );
9134        assert_eq!(
9135            context_of("context:\n  record: off\n  record_store: shey\n")
9136                .unwrap()
9137                .record_store,
9138            Some("shey".to_string()),
9139            "a store declared beside record: off is kept and simply never used"
9140        );
9141    }
9142
9143    #[test]
9144    fn interpreter_runtime_rejects_missing_list_dir() {
9145        let msg = interpreter_runtime_reject(
9146            "      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.11\n",
9147        );
9148        assert!(
9149            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
9150            "{msg}"
9151        );
9152        assert!(msg.contains("explicitly"), "{msg}");
9153    }
9154
9155    #[test]
9156    fn interpreter_runtime_rejects_empty_dirs() {
9157        let msg = interpreter_runtime_reject("      - binary: python3\n        dirs: []\n");
9158        assert!(
9159            msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
9160            "{msg}"
9161        );
9162        assert!(msg.contains("at least one"), "{msg}");
9163    }
9164
9165    #[test]
9166    fn staged_runtime_parses_accepted_shape() {
9167        let manifest = RuntimeManifest::from_yaml_str(
9168            r#"
9169name: cap
9170version: 0.0.1
9171artifacts: []
9172capabilities:
9173  containment: sealed
9174  shell:
9175    allow:
9176      - python3
9177      - bash
9178    staged_runtime:
9179      - binary: python3
9180        source_path: /opt/testbed/conda/envs/django__django
9181        pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
9182"#,
9183        )
9184        .unwrap();
9185
9186        let shell = manifest.capabilities.unwrap().shell.unwrap();
9187        assert_eq!(
9188            shell.staged_runtime,
9189            vec![StagedRuntimeGrant {
9190                binary: "python3".to_string(),
9191                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
9192                pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
9193            }]
9194        );
9195        // The two mechanisms are alternatives, so an accepted `staged_runtime` grant leaves the
9196        // interpreter_runtime list untouched rather than populating it as a side effect.
9197        assert!(shell.interpreter_runtime.is_empty());
9198    }
9199
9200    #[test]
9201    fn staged_runtime_defaults_to_empty_when_absent() {
9202        let manifest = RuntimeManifest::from_yaml_str(
9203            r#"
9204name: cap
9205version: 0.0.1
9206artifacts: []
9207capabilities:
9208  shell:
9209    allow:
9210      - python3
9211"#,
9212        )
9213        .unwrap();
9214
9215        let shell = manifest.capabilities.unwrap().shell.unwrap();
9216        assert!(shell.staged_runtime.is_empty());
9217    }
9218
9219    /// Shared helper: parse a manifest whose sole `staged_runtime` grant is `grant_yaml` (indented
9220    /// to sit under `staged_runtime:`), returning the error message. `extra` is spliced into the
9221    /// same `shell` block, which is how the mutual-exclusion case adds an `interpreter_runtime`.
9222    fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
9223        let yaml = format!(
9224            r#"
9225name: cap
9226version: 0.0.1
9227artifacts: []
9228capabilities:
9229  shell:
9230    allow:
9231      - python3
9232{extra}    staged_runtime:
9233{grant_yaml}
9234"#,
9235        );
9236        RuntimeManifest::from_yaml_str(&yaml)
9237            .expect_err("grant should be rejected")
9238            .to_string()
9239    }
9240
9241    #[test]
9242    fn staged_runtime_rejects_binary_not_in_allow() {
9243        let msg = staged_runtime_reject(
9244            "      - binary: ruby\n        source_path: /opt/ruby\n        pin: ruby-3.2.2\n",
9245            "",
9246        );
9247        assert!(
9248            msg.contains("capabilities.shell.staged_runtime[0].binary"),
9249            "{msg}"
9250        );
9251        assert!(msg.contains("ruby"), "{msg}");
9252        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
9253    }
9254
9255    #[test]
9256    fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
9257        let msg = staged_runtime_reject(
9258            "      - binary: python3\n        source_path: /opt/py\n        pin: cpython-3.9.19\n",
9259            "    interpreter_runtime:\n      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.9\n            list_dir: true\n",
9260        );
9261        assert!(
9262            msg.contains("capabilities.shell.staged_runtime[0].binary"),
9263            "{msg}"
9264        );
9265        assert!(msg.contains("python3"), "{msg}");
9266        assert!(msg.contains("interpreter_runtime"), "{msg}");
9267        assert!(msg.contains("mutually exclusive"), "{msg}");
9268    }
9269
9270    #[test]
9271    fn staged_runtime_rejects_relative_source_path() {
9272        let msg = staged_runtime_reject(
9273            "      - binary: python3\n        source_path: opt/testbed/conda\n        pin: cpython-3.9.19\n",
9274            "",
9275        );
9276        assert!(
9277            msg.contains("capabilities.shell.staged_runtime[0].source_path"),
9278            "{msg}"
9279        );
9280        assert!(msg.contains("absolute"), "{msg}");
9281    }
9282
9283    #[test]
9284    fn staged_runtime_rejects_missing_or_empty_pin() {
9285        // Omitted entirely, and present-but-blank: both are "unpinned", and the field exists
9286        // precisely so that state is unrepresentable.
9287        for grant in [
9288            "      - binary: python3\n        source_path: /opt/testbed/conda\n",
9289            "      - binary: python3\n        source_path: /opt/testbed/conda\n        pin: \"   \"\n",
9290        ] {
9291            let msg = staged_runtime_reject(grant, "");
9292            assert!(
9293                msg.contains("capabilities.shell.staged_runtime[0].pin"),
9294                "{msg}"
9295            );
9296            assert!(msg.contains("never inferred"), "{msg}");
9297        }
9298    }
9299
9300    #[test]
9301    fn parses_inference_block() {
9302        let manifest = RuntimeManifest::from_yaml_str(
9303            r#"
9304name: cap
9305version: 0.0.1
9306artifacts:
9307  - name: murmur/driver-anthropic
9308    version: 0.1.0
9309    runtime: driver
9310    gateway:
9311      endpoint: http://127.0.0.1:8080
9312      api_key: literal-key
9313inference:
9314  transport: http
9315  model: test-model
9316  driver:
9317    artifact: murmur/driver-anthropic
9318    config:
9319      temperature: 0.2
9320      headers:
9321        x-test: true
9322"#,
9323        )
9324        .unwrap();
9325
9326        assert_eq!(
9327            manifest.artifacts[0].gateway,
9328            Some(ArtifactGateway {
9329                endpoint: "http://127.0.0.1:8080".to_string(),
9330                api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
9331                keyless: false,
9332            })
9333        );
9334        let inference = manifest.inference.expect("inference should exist");
9335        assert_eq!(inference.transport, "http");
9336        assert_eq!(inference.model, "test-model");
9337        let driver = inference.driver.as_ref().expect("driver should be present");
9338        assert_eq!(driver.artifact, "murmur/driver-anthropic");
9339        assert_eq!(
9340            driver.config,
9341            Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
9342        );
9343        assert!(inference.system_prompt.is_none());
9344        assert!(inference.system_prompt_file.is_none());
9345    }
9346
9347    /// A capsule whose one tool entry declares `gateway:` with `block` as its body, indented
9348    /// under the key.
9349    fn tool_gateway_manifest(block: &str) -> String {
9350        format!(
9351            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/web-search\n    version: 0.1.0\n    runtime: tool\n    gateway:{block}\n"
9352        )
9353    }
9354
9355    fn gateway_endpoint_error(endpoint: &str) -> String {
9356        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
9357            "\n      endpoint: \"{endpoint}\""
9358        )))
9359        .unwrap_err()
9360        .to_string()
9361    }
9362
9363    #[test]
9364    fn gateway_endpoint_https_is_accepted() {
9365        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9366            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
9367        ))
9368        .unwrap();
9369        assert_eq!(
9370            manifest.artifacts[0].gateway,
9371            Some(ArtifactGateway {
9372                endpoint: "https://api.tavily.com".to_string(),
9373                api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
9374                keyless: false,
9375            })
9376        );
9377    }
9378
9379    #[test]
9380    fn gateway_endpoint_http_localhost_is_accepted() {
9381        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9382            "\n      endpoint: http://localhost:11434\n      keyless: true",
9383        ))
9384        .unwrap();
9385        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
9386        assert_eq!(gateway.endpoint, "http://localhost:11434");
9387        assert_eq!(gateway.api_key, None);
9388        assert!(gateway.keyless);
9389    }
9390
9391    #[test]
9392    fn gateway_endpoint_http_loopback_ip_is_accepted() {
9393        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9394            "\n      endpoint: http://127.0.0.1:8080\n      api_key: ${LOCAL_KEY}",
9395        ))
9396        .unwrap();
9397        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
9398        assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
9399        assert_eq!(
9400            gateway.api_key,
9401            Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
9402        );
9403    }
9404
9405    #[test]
9406    fn gateway_endpoint_without_scheme_is_refused() {
9407        let msg = gateway_endpoint_error("api.tavily.com");
9408        assert!(msg.contains("gateway.endpoint"), "{msg}");
9409        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9410        assert!(msg.contains("does not parse as a URL"), "{msg}");
9411    }
9412
9413    #[test]
9414    fn gateway_endpoint_ftp_scheme_is_refused() {
9415        let msg = gateway_endpoint_error("ftp://example.com");
9416        assert!(msg.contains("gateway.endpoint"), "{msg}");
9417        assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
9418    }
9419
9420    #[test]
9421    fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
9422        let msg = gateway_endpoint_error("http://api.attacker.example.com");
9423        assert!(msg.contains("gateway.endpoint"), "{msg}");
9424        assert!(msg.contains("api.attacker.example.com"), "{msg}");
9425        assert!(msg.contains("non-loopback"), "{msg}");
9426    }
9427
9428    #[test]
9429    fn gateway_endpoint_with_a_query_is_refused() {
9430        let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
9431        assert!(msg.contains("gateway.endpoint"), "{msg}");
9432        assert!(msg.contains("carries a query"), "{msg}");
9433    }
9434
9435    #[test]
9436    fn gateway_endpoint_with_a_fragment_is_refused() {
9437        let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
9438        assert!(msg.contains("gateway.endpoint"), "{msg}");
9439        assert!(msg.contains("carries a fragment"), "{msg}");
9440    }
9441
9442    #[test]
9443    fn gateway_endpoint_with_userinfo_is_refused() {
9444        let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
9445        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9446        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
9447        assert!(msg.contains("host 'api.example.com'"), "{msg}");
9448        assert!(msg.contains("gateway.api_key"), "{msg}");
9449        assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
9450        assert!(
9451            !msg.contains("sk-in-url"),
9452            "a password in the URL is never echoed: {msg}"
9453        );
9454    }
9455
9456    #[test]
9457    fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
9458        let msg = gateway_endpoint_error("https://user:sk-in-url@");
9459        assert!(msg.contains("does not parse as a URL"), "{msg}");
9460        assert!(
9461            !msg.contains("sk-in-url"),
9462            "a password in the URL is never echoed: {msg}"
9463        );
9464    }
9465
9466    #[test]
9467    fn gateway_endpoint_with_a_masquerading_host_is_refused() {
9468        let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
9469        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
9470        assert!(
9471            msg.contains("addresses host '127.0.0.1'"),
9472            "the message names the host the URL really addresses: {msg}"
9473        );
9474    }
9475
9476    #[test]
9477    fn gateway_endpoint_with_interpolation_is_refused() {
9478        let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
9479        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9480        assert!(msg.contains("contains '${'"), "{msg}");
9481        assert!(msg.contains("not interpolated"), "{msg}");
9482    }
9483
9484    #[test]
9485    fn gateway_endpoint_http_unspecified_address_is_refused() {
9486        let msg = gateway_endpoint_error("http://0.0.0.0:8080");
9487        assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
9488    }
9489
9490    #[test]
9491    fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
9492        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9493            "\n      endpoint: http://[::1]:8080\n      api_key: ${LOCAL_KEY}",
9494        ))
9495        .unwrap();
9496        assert_eq!(
9497            manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
9498            "http://[::1]:8080"
9499        );
9500    }
9501
9502    /// The error a tool entry whose `gateway:` body is `block` draws, parsed with secrets resolved.
9503    fn gateway_credential_error(block: &str) -> RuntimeManifestError {
9504        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
9505    }
9506
9507    fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
9508        match &error {
9509            RuntimeManifestError::GatewayWithoutCredential {
9510                index,
9511                name,
9512                endpoint: refused,
9513            } => {
9514                assert_eq!(*index, 0);
9515                assert_eq!(name, "local/web-search");
9516                assert_eq!(refused, endpoint);
9517            }
9518            other => panic!("expected GatewayWithoutCredential, got {other:?}"),
9519        }
9520        let msg = error.to_string();
9521        assert!(
9522            msg.contains(&format!(
9523                "artifact 'local/web-search' declares gateway.endpoint '{endpoint}' but binds no \
9524                 credential"
9525            )),
9526            "{msg}"
9527        );
9528    }
9529
9530    #[test]
9531    fn gateway_credential_absent_is_refused() {
9532        assert_without_credential(
9533            gateway_credential_error("\n      endpoint: https://api.tavily.com"),
9534            "https://api.tavily.com",
9535        );
9536    }
9537
9538    #[test]
9539    fn gateway_credential_blank_is_refused() {
9540        for key in ["\"\"", "\"   \"", "''"] {
9541            assert_without_credential(
9542                gateway_credential_error(&format!(
9543                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
9544                )),
9545                "https://api.tavily.com",
9546            );
9547        }
9548    }
9549
9550    #[test]
9551    fn gateway_credential_null_is_refused() {
9552        for key in ["~", "null", ""] {
9553            assert_without_credential(
9554                gateway_credential_error(&format!(
9555                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
9556                )),
9557                "https://api.tavily.com",
9558            );
9559        }
9560    }
9561
9562    #[test]
9563    fn gateway_credential_keyless_false_is_refused() {
9564        assert_without_credential(
9565            gateway_credential_error(
9566                "\n      endpoint: https://api.tavily.com\n      keyless: false",
9567            ),
9568            "https://api.tavily.com",
9569        );
9570    }
9571
9572    #[test]
9573    fn gateway_credential_keyless_is_accepted() {
9574        for (parse, mode) in [
9575            (
9576                RuntimeManifest::from_yaml_str as fn(&str) -> _,
9577                "with secrets",
9578            ),
9579            (
9580                RuntimeManifest::from_yaml_str_without_secrets,
9581                "without secrets",
9582            ),
9583        ] {
9584            let manifest = parse(&tool_gateway_manifest(
9585                "\n      endpoint: https://api.example.com\n      keyless: true",
9586            ))
9587            .unwrap();
9588            assert_eq!(
9589                manifest.artifacts[0].gateway,
9590                Some(ArtifactGateway {
9591                    endpoint: "https://api.example.com".to_string(),
9592                    api_key: None,
9593                    keyless: true,
9594                }),
9595                "{mode}"
9596            );
9597        }
9598    }
9599
9600    #[test]
9601    fn gateway_credential_keyless_not_a_boolean_is_refused() {
9602        for value in ["\"true\"", "yes please", "1"] {
9603            let error = gateway_credential_error(&format!(
9604                "\n      endpoint: https://api.tavily.com\n      keyless: {value}"
9605            ));
9606            assert!(
9607                matches!(
9608                    error,
9609                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
9610                ),
9611                "{value}: {error:?}"
9612            );
9613            assert!(
9614                error
9615                    .to_string()
9616                    .contains("gateway.keyless with a value that is not a boolean"),
9617                "{error}"
9618            );
9619        }
9620    }
9621
9622    #[test]
9623    fn gateway_credential_and_keyless_together_are_refused() {
9624        for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
9625            let error = gateway_credential_error(&format!(
9626                "\n      endpoint: https://api.tavily.com\n      api_key: {key}\n      keyless: true"
9627            ));
9628            assert!(
9629                matches!(
9630                    error,
9631                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
9632                ),
9633                "{error:?}"
9634            );
9635            assert!(
9636                error.to_string().contains(
9637                    "artifact 'local/web-search' declares both gateway.api_key and gateway.keyless: \
9638                     true; a gateway either presents a key or is keyless"
9639                ),
9640                "{error}"
9641            );
9642        }
9643    }
9644
9645    #[test]
9646    fn gateway_credential_absent_is_refused_without_secrets() {
9647        for block in [
9648            "\n      endpoint: https://api.tavily.com",
9649            "\n      endpoint: https://api.tavily.com\n      api_key: \"\"",
9650            "\n      endpoint: https://api.tavily.com\n      keyless: false",
9651        ] {
9652            assert_without_credential(
9653                RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
9654                    .unwrap_err(),
9655                "https://api.tavily.com",
9656            );
9657        }
9658        let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
9659            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
9660        ))
9661        .unwrap();
9662        let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
9663        assert_eq!(gateway.api_key, None, "the key reference is left unread");
9664        assert!(!gateway.keyless, "a keyed gateway is never keyless");
9665    }
9666
9667    /// The refusal reads what the manifest wrote, never the address: a loopback or private
9668    /// endpoint with no credential is refused exactly as a public one is.
9669    #[test]
9670    fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
9671        for endpoint in [
9672            "http://127.0.0.1:11434",
9673            "http://localhost:11434",
9674            "http://[::1]:11434",
9675            "https://10.0.0.5",
9676            "https://192.168.1.10:8443",
9677        ] {
9678            assert_without_credential(
9679                gateway_credential_error(&format!("\n      endpoint: {endpoint}")),
9680                endpoint,
9681            );
9682        }
9683    }
9684
9685    #[test]
9686    fn gateway_endpoint_missing_from_the_block_is_refused() {
9687        for block in [
9688            "\n      api_key: ${TAVILY_API_KEY}",
9689            "",
9690            " {}",
9691            "\n      endpoint: \"  \"",
9692        ] {
9693            let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
9694                .unwrap_err()
9695                .to_string();
9696            assert!(
9697                msg.contains("without 'gateway.endpoint'"),
9698                "{block:?}: {msg}"
9699            );
9700            assert!(
9701                msg.contains("artifact 'local/web-search'"),
9702                "{block:?}: {msg}"
9703            );
9704        }
9705    }
9706
9707    #[test]
9708    fn gateway_block_unknown_key_is_reported() {
9709        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9710            "\n      endpoint: https://api.tavily.com\n      keyless: true\n      api_kee: x",
9711        ))
9712        .unwrap();
9713        let unknown = manifest.unknown_keys;
9714        assert_eq!(unknown.len(), 1, "{unknown:?}");
9715        assert_eq!(unknown[0].key, "api_kee");
9716        assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
9717    }
9718
9719    #[test]
9720    fn gateway_on_skill_entry_is_refused() {
9721        let msg = RuntimeManifest::from_yaml_str(
9722            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/notes\n    version: 0.1.0\n    runtime: skill\n    gateway:\n      endpoint: https://api.tavily.com\n      api_key: test-key\n",
9723        )
9724        .unwrap_err()
9725        .to_string();
9726        assert!(
9727            msg.contains("artifact 'local/notes' declares 'gateway:'"),
9728            "{msg}"
9729        );
9730        assert!(msg.contains("runtime: skill"), "{msg}");
9731    }
9732
9733    #[test]
9734    fn gateway_on_hook_entry_is_accepted() {
9735        let manifest = RuntimeManifest::from_yaml_str(
9736            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/grafana\n    version: 0.1.0\n    runtime: hook\n    gateway:\n      endpoint: https://grafana.example.com/otlp\n      api_key: glc_literal\n",
9737        )
9738        .unwrap();
9739        assert!(manifest.artifacts[0].gateway.is_some());
9740    }
9741
9742    /// An `http` driver capsule. `driver_entry` is the YAML of the driver's artifact entry, or
9743    /// empty for none.
9744    fn http_driver_manifest(artifacts: &str) -> String {
9745        format!(
9746            "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n  transport: http\n  model: test-model\n  driver:\n    artifact: murmur/driver-anthropic\n"
9747        )
9748    }
9749
9750    #[test]
9751    fn http_driver_entry_without_gateway_endpoint_is_refused() {
9752        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9753            "\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver",
9754        ))
9755        .unwrap_err()
9756        .to_string();
9757        assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9758        assert!(msg.contains("gateway.endpoint"), "{msg}");
9759    }
9760
9761    #[test]
9762    fn http_driver_absent_from_artifacts_is_refused() {
9763        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
9764            .unwrap_err()
9765            .to_string();
9766        assert!(msg.contains("inference.driver.artifact"), "{msg}");
9767        assert!(msg.contains("not declared in artifacts:"), "{msg}");
9768    }
9769
9770    #[test]
9771    fn gateway_on_unconfigured_driver_entry_is_refused() {
9772        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9773            "\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.anthropic.com\n      api_key: test-key\n  - name: murmur/driver-openai\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.openai.com\n      api_key: test-key",
9774        ))
9775        .unwrap_err()
9776        .to_string();
9777        assert!(msg.contains("'murmur/driver-openai'"), "{msg}");
9778        assert!(
9779            msg.contains("accepted only on the entry inference.driver.artifact names"),
9780            "{msg}"
9781        );
9782
9783        let no_inference = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-openai\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.openai.com\n      api_key: test-key\n";
9784        let msg = RuntimeManifest::from_yaml_str(no_inference)
9785            .unwrap_err()
9786            .to_string();
9787        assert!(msg.contains("not the configured inference driver"), "{msg}");
9788    }
9789
9790    #[test]
9791    fn gateway_on_driver_entry_under_process_transport_is_refused() {
9792        let msg = RuntimeManifest::from_yaml_str(
9793            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.anthropic.com\n      api_key: test-key\ninference:\n  transport: process\n  driver:\n    artifact: murmur/driver-anthropic\n",
9794        )
9795        .unwrap_err()
9796        .to_string();
9797        assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9798        assert!(msg.contains("transport: process"), "{msg}");
9799    }
9800
9801    #[test]
9802    fn inference_api_key_is_refused_naming_gateway_api_key() {
9803        let entry = "\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.anthropic.com\n      api_key: test-key";
9804        for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
9805            let manifest = http_driver_manifest(entry)
9806                .replace("  model:", &format!("  api_key: {value}\n  model:"));
9807            let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
9808            let msg = err.to_string();
9809            assert!(msg.contains("'inference.api_key'"), "{msg}");
9810            assert!(msg.contains("was removed"), "{msg}");
9811            assert!(
9812                msg.contains(
9813                    "set gateway.api_key on the artifacts: entry 'murmur/driver-anthropic'"
9814                ),
9815                "{msg}"
9816            );
9817            assert!(!msg.contains("sk-literal"), "{msg}");
9818        }
9819
9820        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  api_key: ${KEY}\n";
9821        let msg = RuntimeManifest::from_yaml_str(process)
9822            .unwrap_err()
9823            .to_string();
9824        assert!(msg.contains("'inference.api_key'"), "{msg}");
9825        assert!(
9826            msg.contains("on the artifacts: entry inference.driver.artifact names"),
9827            "{msg}"
9828        );
9829        assert!(
9830            RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
9831            "the refusal does not depend on resolving secrets"
9832        );
9833    }
9834
9835    #[test]
9836    fn inference_endpoint_is_refused_naming_gateway_endpoint() {
9837        let manifest = http_driver_manifest(
9838            "\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.anthropic.com\n      api_key: test-key",
9839        )
9840        .replace("  model:", "  endpoint: https://api.anthropic.com\n  model:");
9841        let msg = RuntimeManifest::from_yaml_str(&manifest)
9842            .unwrap_err()
9843            .to_string();
9844        assert!(msg.contains("'inference.endpoint'"), "{msg}");
9845        assert!(
9846            msg.contains("set gateway.endpoint on the artifacts: entry 'murmur/driver-anthropic'"),
9847            "{msg}"
9848        );
9849
9850        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  endpoint: http://localhost:8080\n";
9851        let msg = RuntimeManifest::from_yaml_str(process)
9852            .unwrap_err()
9853            .to_string();
9854        assert!(msg.contains("'inference.endpoint'"), "{msg}");
9855        assert!(msg.contains("was removed"), "{msg}");
9856    }
9857
9858    #[test]
9859    fn without_secrets_leaves_gateway_api_key_unread() {
9860        let manifest = tool_gateway_manifest(
9861            "\n      endpoint: https://api.tavily.com\n      api_key: tvly-literal",
9862        );
9863        let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
9864        assert_eq!(
9865            resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
9866            Some(ApiKeyReference::Literal("tvly-literal".to_string()))
9867        );
9868        let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
9869        let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
9870        assert_eq!(gateway.endpoint, "https://api.tavily.com");
9871        assert_eq!(gateway.api_key, None);
9872    }
9873
9874    #[test]
9875    fn referenced_env_variables_reads_gateway_api_key() {
9876        let manifest = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/web-search\n    version: 0.1.0\n    gateway:\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.anthropic.com\n      api_key: ${ANTHROPIC_API_KEY}\n  - name: local/literal\n    version: 0.1.0\n    gateway:\n      endpoint: https://example.com\n      api_key: not-a-reference\ninference:\n  model: m\n  driver:\n    artifact: murmur/driver-anthropic\n";
9877        let referenced = referenced_env_variables(manifest);
9878        assert_eq!(
9879            referenced,
9880            vec![
9881                ReferencedEnvVariable {
9882                    field: "artifacts.murmur/driver-anthropic.gateway.api_key".to_string(),
9883                    variable: "ANTHROPIC_API_KEY".to_string(),
9884                },
9885                ReferencedEnvVariable {
9886                    field: "artifacts.local/web-search.gateway.api_key".to_string(),
9887                    variable: "TAVILY_API_KEY".to_string(),
9888                },
9889            ]
9890        );
9891        assert!(referenced
9892            .iter()
9893            .all(ReferencedEnvVariable::is_gateway_credential));
9894    }
9895
9896    #[test]
9897    fn parses_inline_system_prompt() {
9898        let manifest = RuntimeManifest::from_yaml_str(
9899            r#"
9900name: cap
9901version: 0.0.1
9902artifacts:
9903  - name: murmur/driver-anthropic
9904    version: 0.1.0
9905    runtime: driver
9906    gateway:
9907      endpoint: http://127.0.0.1:8080
9908      keyless: true
9909inference:
9910  model: test-model
9911  system_prompt: "Always begin with CONFIRMED:"
9912  driver:
9913    artifact: murmur/driver-anthropic
9914"#,
9915        )
9916        .unwrap();
9917
9918        let inference = manifest.inference.unwrap();
9919        assert_eq!(
9920            inference.system_prompt,
9921            Some("Always begin with CONFIRMED:".to_string())
9922        );
9923        assert!(inference.system_prompt_file.is_none());
9924    }
9925
9926    #[test]
9927    fn parses_compaction_system_prompt() {
9928        let manifest = RuntimeManifest::from_yaml_str(
9929            r#"
9930name: cap
9931version: 0.0.1
9932artifacts:
9933  - name: murmur/driver-anthropic
9934    version: 0.1.0
9935    runtime: driver
9936    gateway:
9937      endpoint: http://127.0.0.1:8080
9938      keyless: true
9939inference:
9940  model: test-model
9941  compaction:
9942    model: compaction-model
9943    system_prompt: "task = X, currently editing Y, already tried Z."
9944  driver:
9945    artifact: murmur/driver-anthropic
9946"#,
9947        )
9948        .unwrap();
9949
9950        let compaction = manifest.inference.unwrap().compaction.unwrap();
9951        assert_eq!(
9952            compaction.system_prompt,
9953            Some("task = X, currently editing Y, already tried Z.".to_string())
9954        );
9955        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9956    }
9957
9958    /// The two override fields resolve independently: `model` set, `system_prompt`
9959    /// absent leaves the latter `None` with no default string substituted.
9960    #[test]
9961    fn compaction_system_prompt_absent_stays_none() {
9962        let manifest = RuntimeManifest::from_yaml_str(
9963            r#"
9964name: cap
9965version: 0.0.1
9966artifacts:
9967  - name: murmur/driver-anthropic
9968    version: 0.1.0
9969    runtime: driver
9970    gateway:
9971      endpoint: http://127.0.0.1:8080
9972      keyless: true
9973inference:
9974  model: test-model
9975  compaction:
9976    model: compaction-model
9977  driver:
9978    artifact: murmur/driver-anthropic
9979"#,
9980        )
9981        .unwrap();
9982
9983        let compaction = manifest.inference.unwrap().compaction.unwrap();
9984        assert!(compaction.system_prompt.is_none());
9985        assert!(compaction.system_prompt_file.is_none());
9986        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9987    }
9988
9989    /// `system_prompt_file` parses as the second, independent prompt source: the path is
9990    /// carried through verbatim (resolution against the manifest dir happens in the
9991    /// runtime), the inline field stays `None`, and `model` is unaffected.
9992    #[test]
9993    fn parses_compaction_system_prompt_file() {
9994        let manifest = RuntimeManifest::from_yaml_str(
9995            r#"
9996name: cap
9997version: 0.0.1
9998artifacts:
9999  - name: murmur/driver-anthropic
10000    version: 0.1.0
10001    runtime: driver
10002    gateway:
10003      endpoint: http://127.0.0.1:8080
10004      keyless: true
10005inference:
10006  model: test-model
10007  compaction:
10008    model: compaction-model
10009    system_prompt_file: "compaction-instructions.md"
10010  driver:
10011    artifact: murmur/driver-anthropic
10012"#,
10013        )
10014        .unwrap();
10015
10016        let compaction = manifest.inference.unwrap().compaction.unwrap();
10017        assert_eq!(
10018            compaction.system_prompt_file,
10019            Some("compaction-instructions.md".to_string())
10020        );
10021        assert!(compaction.system_prompt.is_none());
10022        assert_eq!(compaction.model, Some("compaction-model".to_string()));
10023    }
10024
10025    /// `system_prompt_file` alone, with no `model`, still parses — the two fields do not
10026    /// depend on each other in either direction.
10027    #[test]
10028    fn compaction_system_prompt_file_independent_of_model() {
10029        let manifest = RuntimeManifest::from_yaml_str(
10030            r#"
10031name: cap
10032version: 0.0.1
10033artifacts:
10034  - name: murmur/driver-anthropic
10035    version: 0.1.0
10036    runtime: driver
10037    gateway:
10038      endpoint: http://127.0.0.1:8080
10039      keyless: true
10040inference:
10041  model: test-model
10042  compaction:
10043    threshold: 0.5
10044    system_prompt_file: "compaction-instructions.md"
10045  driver:
10046    artifact: murmur/driver-anthropic
10047"#,
10048        )
10049        .unwrap();
10050
10051        let compaction = manifest.inference.unwrap().compaction.unwrap();
10052        assert_eq!(
10053            compaction.system_prompt_file,
10054            Some("compaction-instructions.md".to_string())
10055        );
10056        assert!(compaction.model.is_none());
10057        assert_eq!(compaction.threshold, Some(0.5));
10058    }
10059
10060    /// `dump_summaries` is a plain optional boolean: present-true, present-false and absent
10061    /// are three distinguishable states, so the runtime can tell "explicitly off" from
10062    /// "never configured" even though both resolve to the same behavior.
10063    #[test]
10064    fn parses_compaction_dump_summaries() {
10065        let parse = |line: &str| {
10066            RuntimeManifest::from_yaml_str(&format!(
10067                r#"
10068name: cap
10069version: 0.0.1
10070artifacts:
10071  - name: murmur/driver-anthropic
10072    version: 0.1.0
10073    runtime: driver
10074    gateway:
10075      endpoint: http://127.0.0.1:8080
10076      keyless: true
10077inference:
10078  model: test-model
10079  compaction:
10080    model: compaction-model
10081{line}  driver:
10082    artifact: murmur/driver-anthropic
10083"#
10084            ))
10085            .unwrap()
10086            .inference
10087            .unwrap()
10088            .compaction
10089            .unwrap()
10090        };
10091
10092        assert_eq!(
10093            parse("    dump_summaries: true\n").dump_summaries,
10094            Some(true)
10095        );
10096        assert_eq!(
10097            parse("    dump_summaries: false\n").dump_summaries,
10098            Some(false)
10099        );
10100        assert_eq!(parse("").dump_summaries, None);
10101    }
10102
10103    /// `dump_summaries` is orthogonal to every other compaction field: it neither trips the
10104    /// prompt-source exclusivity check nor the threshold range check, and does not disturb
10105    /// the values those fields parse to.
10106    #[test]
10107    fn compaction_dump_summaries_does_not_interact_with_other_fields() {
10108        let compaction = RuntimeManifest::from_yaml_str(
10109            r#"
10110name: cap
10111version: 0.0.1
10112artifacts:
10113  - name: murmur/driver-anthropic
10114    version: 0.1.0
10115    runtime: driver
10116    gateway:
10117      endpoint: http://127.0.0.1:8080
10118      keyless: true
10119inference:
10120  model: test-model
10121  compaction:
10122    threshold: 0.5
10123    model: compaction-model
10124    system_prompt_file: "compaction-instructions.md"
10125    dump_summaries: true
10126  driver:
10127    artifact: murmur/driver-anthropic
10128"#,
10129        )
10130        .unwrap()
10131        .inference
10132        .unwrap()
10133        .compaction
10134        .unwrap();
10135
10136        assert_eq!(compaction.dump_summaries, Some(true));
10137        assert_eq!(compaction.threshold, Some(0.5));
10138        assert_eq!(compaction.model, Some("compaction-model".to_string()));
10139        assert_eq!(
10140            compaction.system_prompt_file,
10141            Some("compaction-instructions.md".to_string())
10142        );
10143        assert!(compaction.system_prompt.is_none());
10144
10145        // Still mutually exclusive with dump_summaries in the mix...
10146        let err = RuntimeManifest::from_yaml_str(
10147            r#"
10148name: cap
10149version: 0.0.1
10150artifacts:
10151  - name: murmur/driver-anthropic
10152    version: 0.1.0
10153    runtime: driver
10154    gateway:
10155      endpoint: http://127.0.0.1:8080
10156      keyless: true
10157inference:
10158  model: test-model
10159  compaction:
10160    dump_summaries: true
10161    system_prompt: "inline"
10162    system_prompt_file: "compaction-instructions.md"
10163  driver:
10164    artifact: murmur/driver-anthropic
10165"#,
10166        )
10167        .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
10168        assert!(matches!(
10169            err,
10170            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
10171                if field == "inference.compaction.system_prompt"
10172        ));
10173
10174        // ...and the threshold range is still enforced.
10175        let err = RuntimeManifest::from_yaml_str(
10176            r#"
10177name: cap
10178version: 0.0.1
10179artifacts:
10180  - name: murmur/driver-anthropic
10181    version: 0.1.0
10182    runtime: driver
10183    gateway:
10184      endpoint: http://127.0.0.1:8080
10185      keyless: true
10186inference:
10187  model: test-model
10188  compaction:
10189    dump_summaries: true
10190    threshold: 1.5
10191  driver:
10192    artifact: murmur/driver-anthropic
10193"#,
10194        )
10195        .expect_err("dump_summaries must not suppress the threshold range check");
10196        assert!(matches!(
10197            err,
10198            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
10199                if field == "inference.compaction.threshold"
10200        ));
10201    }
10202
10203    fn compaction_call_manifest(lines: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
10204        RuntimeManifest::from_yaml_str(&format!(
10205            r#"
10206name: cap
10207version: 0.0.1
10208artifacts:
10209  - name: murmur/driver-anthropic
10210    version: 0.1.0
10211    runtime: driver
10212    gateway:
10213      endpoint: http://127.0.0.1:8080
10214      keyless: true
10215inference:
10216  model: test-model
10217  driver:
10218    artifact: murmur/driver-anthropic
10219    config:
10220      reasoning_effort: max
10221  compaction:
10222{lines}"#
10223        ))
10224    }
10225
10226    #[test]
10227    fn parses_compaction_max_tokens_and_driver_config() {
10228        let manifest = compaction_call_manifest(
10229            "    max_tokens: 8000\n    driver_config:\n      reasoning_effort: low\n      extra: [1, 2]\n",
10230        )
10231        .unwrap();
10232        assert!(
10233            manifest.unknown_keys.is_empty(),
10234            "neither key is unknown: {:?}",
10235            manifest.unknown_keys
10236        );
10237        let compaction = manifest.inference.unwrap().compaction.unwrap();
10238        assert_eq!(compaction.max_tokens, Some(8000));
10239        assert_eq!(
10240            compaction.driver_config.as_deref(),
10241            Some(r#"{"extra":[1,2],"reasoning_effort":"low"}"#)
10242        );
10243
10244        let compaction = compaction_call_manifest("    threshold: 0.5\n")
10245            .unwrap()
10246            .inference
10247            .unwrap()
10248            .compaction
10249            .unwrap();
10250        assert_eq!(compaction.max_tokens, None);
10251        assert_eq!(compaction.driver_config, None);
10252
10253        // An empty mapping is an overlay that changes nothing, not an absent one.
10254        let compaction = compaction_call_manifest("    driver_config: {}\n")
10255            .unwrap()
10256            .inference
10257            .unwrap()
10258            .compaction
10259            .unwrap();
10260        assert_eq!(compaction.driver_config.as_deref(), Some("{}"));
10261    }
10262
10263    #[test]
10264    fn compaction_max_tokens_zero_is_refused() {
10265        match compaction_call_manifest("    max_tokens: 0\n").unwrap_err() {
10266            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10267                assert_eq!(field, "inference.compaction.max_tokens");
10268                assert_eq!(message, "must be greater than 0");
10269            }
10270            other => panic!("unexpected error: {other:?}"),
10271        }
10272    }
10273
10274    #[test]
10275    fn compaction_driver_config_must_be_a_mapping() {
10276        for value in ["low", "[reasoning_effort]", "3"] {
10277            match compaction_call_manifest(&format!("    driver_config: {value}\n")).unwrap_err() {
10278                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10279                    assert_eq!(field, "inference.compaction.driver_config");
10280                    assert_eq!(message, "expected mapping/object");
10281                }
10282                other => panic!("unexpected error for {value}: {other:?}"),
10283            }
10284        }
10285    }
10286
10287    /// The harness runs its own compaction under `transport: process`, so neither key would
10288    /// shape any call there.
10289    #[test]
10290    fn process_transport_rejects_compaction_call_keys() {
10291        for (key, value) in [
10292            ("max_tokens", "8000"),
10293            ("driver_config", "{reasoning_effort: low}"),
10294        ] {
10295            let err = RuntimeManifest::from_yaml_str(&format!(
10296                "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-claude-code\n    \
10297                 version: 0.1.0\n    runtime: driver\ninference:\n  transport: process\n  \
10298                 model: claude-haiku-4-5-20251001\n  driver:\n    artifact: murmur/driver-claude-code\n  \
10299                 compaction:\n    {key}: {value}\n"
10300            ))
10301            .unwrap_err();
10302            match err {
10303                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10304                    assert_eq!(field, format!("inference.compaction.{key}"));
10305                    assert_eq!(message, "is not valid with transport: process");
10306                }
10307                other => panic!("unexpected error for {key}: {other:?}"),
10308            }
10309        }
10310    }
10311
10312    /// Setting both compaction prompt sources is a parse error naming the compaction
10313    /// fields — distinguishable from the top-level three-way `inference.system_prompt`
10314    /// exclusivity error, and with neither value silently preferred.
10315    #[test]
10316    fn compaction_prompt_sources_are_mutually_exclusive() {
10317        let err = RuntimeManifest::from_yaml_str(
10318            r#"
10319name: cap
10320version: 0.0.1
10321artifacts:
10322  - name: murmur/driver-anthropic
10323    version: 0.1.0
10324    runtime: driver
10325    gateway:
10326      endpoint: http://127.0.0.1:8080
10327      keyless: true
10328inference:
10329  model: test-model
10330  compaction:
10331    system_prompt: "inline"
10332    system_prompt_file: "compaction-instructions.md"
10333  driver:
10334    artifact: murmur/driver-anthropic
10335"#,
10336        )
10337        .expect_err("both compaction prompt sources set must fail");
10338
10339        match err {
10340            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10341                assert_eq!(field, "inference.compaction.system_prompt");
10342                assert_eq!(
10343                    message,
10344                    "at most one of inference.compaction.system_prompt, \
10345                     inference.compaction.system_prompt_file may be set"
10346                );
10347            }
10348            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
10349        }
10350    }
10351
10352    /// The compaction-level exclusivity check is separate from the top-level one: an
10353    /// inline top-level `system_prompt` alongside a compaction `system_prompt_file` is a
10354    /// perfectly legal manifest.
10355    #[test]
10356    fn top_level_and_compaction_prompt_sources_do_not_collide() {
10357        let manifest = RuntimeManifest::from_yaml_str(
10358            r#"
10359name: cap
10360version: 0.0.1
10361artifacts:
10362  - name: murmur/driver-anthropic
10363    version: 0.1.0
10364    runtime: driver
10365    gateway:
10366      endpoint: http://127.0.0.1:8080
10367      keyless: true
10368inference:
10369  model: test-model
10370  system_prompt: "be helpful"
10371  compaction:
10372    system_prompt_file: "compaction-instructions.md"
10373  driver:
10374    artifact: murmur/driver-anthropic
10375"#,
10376        )
10377        .unwrap();
10378
10379        let inference = manifest.inference.unwrap();
10380        assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
10381        assert_eq!(
10382            inference.compaction.unwrap().system_prompt_file,
10383            Some("compaction-instructions.md".to_string())
10384        );
10385    }
10386
10387    #[test]
10388    fn parses_system_prompt_file() {
10389        let manifest = RuntimeManifest::from_yaml_str(
10390            r#"
10391name: cap
10392version: 0.0.1
10393artifacts:
10394  - name: murmur/driver-anthropic
10395    version: 0.1.0
10396    runtime: driver
10397    gateway:
10398      endpoint: http://127.0.0.1:8080
10399      keyless: true
10400inference:
10401  model: test-model
10402  system_prompt_file: "conventions.md"
10403  driver:
10404    artifact: murmur/driver-anthropic
10405"#,
10406        )
10407        .unwrap();
10408
10409        let inference = manifest.inference.unwrap();
10410        assert_eq!(
10411            inference.system_prompt_file,
10412            Some("conventions.md".to_string())
10413        );
10414        assert!(inference.system_prompt.is_none());
10415    }
10416
10417    #[test]
10418    fn rejects_both_inline_and_file_system_prompt() {
10419        let err = RuntimeManifest::from_yaml_str(
10420            r#"
10421name: cap
10422version: 0.0.1
10423artifacts:
10424  - name: murmur/driver-anthropic
10425    version: 0.1.0
10426    runtime: driver
10427    gateway:
10428      endpoint: http://127.0.0.1:8080
10429      keyless: true
10430inference:
10431  model: test-model
10432  system_prompt: "inline"
10433  system_prompt_file: "conventions.md"
10434  driver:
10435    artifact: murmur/driver-anthropic
10436"#,
10437        )
10438        .unwrap_err();
10439
10440        let msg = err.to_string();
10441        assert!(msg.contains("inference.system_prompt"));
10442        assert!(msg.contains("system_prompt_file"));
10443    }
10444
10445    fn instruction_files_manifest(transport: &str, entries: &str) -> String {
10446        let (artifact, inference) = if transport == "process" {
10447            (
10448                "  - name: murmur/driver-claude-code\n    version: 0.1.0\n    runtime: driver\n",
10449                "  transport: process\n  driver:\n    artifact: murmur/driver-claude-code\n",
10450            )
10451        } else {
10452            (
10453                "  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    \
10454                 gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\n",
10455                "  model: test-model\n  driver:\n    artifact: murmur/driver-anthropic\n",
10456            )
10457        };
10458        format!(
10459            "name: cap\nversion: 0.0.1\nartifacts:\n{artifact}inference:\n{inference}  \
10460             instruction_files: {entries}\n"
10461        )
10462    }
10463
10464    #[test]
10465    fn instruction_files_absent_is_none() {
10466        let manifest = RuntimeManifest::from_yaml_str(
10467            r#"
10468name: cap
10469version: 0.0.1
10470artifacts:
10471  - name: murmur/driver-anthropic
10472    version: 0.1.0
10473    runtime: driver
10474    gateway:
10475      endpoint: http://127.0.0.1:8080
10476      keyless: true
10477inference:
10478  model: test-model
10479  driver:
10480    artifact: murmur/driver-anthropic
10481"#,
10482        )
10483        .unwrap();
10484        assert_eq!(manifest.inference.unwrap().instruction_files, None);
10485    }
10486
10487    /// Entries are kept as written and in manifest order, `.` components included, on both
10488    /// transports, and the key draws no unknown-key report.
10489    #[test]
10490    fn instruction_files_are_kept_verbatim_in_order_on_both_transports() {
10491        for transport in ["http", "process"] {
10492            let manifest = RuntimeManifest::from_yaml_str(&instruction_files_manifest(
10493                transport,
10494                "[CLAUDE.md, ./AGENTS.md, docs/agents/RULES.md]",
10495            ))
10496            .unwrap();
10497            assert!(
10498                manifest.unknown_keys.is_empty(),
10499                "{transport}: {:?}",
10500                manifest.unknown_keys
10501            );
10502            assert_eq!(
10503                manifest.inference.unwrap().instruction_files,
10504                Some(vec![
10505                    "CLAUDE.md".to_string(),
10506                    "./AGENTS.md".to_string(),
10507                    "docs/agents/RULES.md".to_string(),
10508                ]),
10509                "{transport}"
10510            );
10511        }
10512    }
10513
10514    #[test]
10515    fn instruction_files_empty_list_is_some_empty() {
10516        let manifest =
10517            RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", "[]")).unwrap();
10518        assert_eq!(manifest.inference.unwrap().instruction_files, Some(vec![]));
10519    }
10520
10521    /// An entry that is blank, absolute or climbs out with `..` is refused on the indexed field,
10522    /// with the entry quoted, before anything is staged.
10523    #[test]
10524    fn instruction_files_refuse_entries_that_leave_the_work_folder() {
10525        for (entries, index, entry, problem) in [
10526            ("[/etc/passwd]", 0, "/etc/passwd", "is absolute"),
10527            (
10528                "[AGENTS.md, ../AGENTS.md]",
10529                1,
10530                "../AGENTS.md",
10531                "'..' component",
10532            ),
10533            ("[a/../../b]", 0, "a/../../b", "'..' component"),
10534            ("[\"\"]", 0, "", "is empty"),
10535            ("[AGENTS.md, \"  \"]", 1, "  ", "is empty"),
10536        ] {
10537            let err = RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", entries))
10538                .unwrap_err();
10539            let RuntimeManifestError::InvalidInferenceConfig { field, message } = &err else {
10540                panic!("{entries}: expected InvalidInferenceConfig, got {err:?}");
10541            };
10542            assert_eq!(field, &format!("inference.instruction_files[{index}]"));
10543            assert!(message.contains(&format!("'{entry}'")), "{message}");
10544            assert!(message.contains(problem), "{message}");
10545            assert!(
10546                message.contains("must be a relative path inside the work folder"),
10547                "{message}"
10548            );
10549        }
10550    }
10551
10552    /// A `${NAME}` is kept as the name it is, whether or not this process holds the variable: the
10553    /// parse never reads the environment.
10554    #[test]
10555    fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
10556        let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
10557        let yaml = format!(
10558            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      api_key: ${{{key}}}\ninference:\n  transport: http\n  model: test-model\n  driver:\n    artifact: murmur/driver-anthropic\n"
10559        );
10560
10561        assert_eq!(
10562            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
10563                .gateway
10564                .clone()
10565                .unwrap()
10566                .api_key,
10567            Some(ApiKeyReference::Environment(key.to_string()))
10568        );
10569        assert_eq!(
10570            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
10571                .unwrap()
10572                .artifacts[0]
10573                .gateway
10574                .clone()
10575                .unwrap()
10576                .api_key,
10577            None
10578        );
10579    }
10580
10581    #[test]
10582    fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
10583        let literal = ["sk-", "ant-", "debug"].concat();
10584        let debug = format!(
10585            "{:?} {:?}",
10586            ApiKeyReference::Environment("SOME_KEY".to_string()),
10587            ApiKeyReference::Literal(literal.clone())
10588        );
10589        assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
10590        assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
10591        assert!(!debug.contains(&literal), "{debug}");
10592    }
10593
10594    #[test]
10595    fn parse_without_secrets_drops_a_literal_api_key() {
10596        // Key assembled at runtime so the source never contains a
10597        // credential-shaped literal that secret scanners could flag.
10598        let literal = ["sk-", "ant-", "literal"].concat();
10599        let yaml = format!(
10600            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      api_key: {literal}\ninference:\n  transport: http\n  model: test-model\n  driver:\n    artifact: murmur/driver-anthropic\n"
10601        );
10602
10603        assert_eq!(
10604            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
10605                .gateway
10606                .clone()
10607                .unwrap()
10608                .api_key,
10609            Some(ApiKeyReference::Literal(literal))
10610        );
10611        assert_eq!(
10612            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
10613                .unwrap()
10614                .artifacts[0]
10615                .gateway
10616                .clone()
10617                .unwrap()
10618                .api_key,
10619            None
10620        );
10621    }
10622
10623    /// The non-resolving parse is narrower only in what it resolves, never in what it validates:
10624    /// a manifest roost accepts is a manifest `mur run` would also accept.
10625    #[test]
10626    fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
10627        let process_with_key = format!(
10628            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  model: test-model\n  api_key: {}\n",
10629            ["sk-", "ant-", "secret"].concat()
10630        );
10631        let http_without_model = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\ninference:\n  transport: http\n  driver:\n    artifact: murmur/driver-anthropic\n";
10632
10633        for yaml in [process_with_key.as_str(), http_without_model] {
10634            let resolving = RuntimeManifest::from_yaml_str(yaml)
10635                .unwrap_err()
10636                .to_string();
10637            let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
10638                .unwrap_err()
10639                .to_string();
10640            assert_eq!(resolving, without_secrets);
10641        }
10642    }
10643
10644    /// The reference scan answers from the manifest text alone: it reports a reference, ignores
10645    /// a literal, and has nothing to say about a manifest that carries no `inference` block or
10646    /// does not parse at all.
10647    #[test]
10648    fn referenced_env_variables_reports_a_reference_and_nothing_else() {
10649        let referencing = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      api_key: ${MURMUR_TEST_REFERENCED_KEY}\ninference:\n  transport: http\n  model: test-model\n  driver:\n    artifact: murmur/driver-anthropic\n";
10650        let referenced = referenced_env_variables(referencing);
10651        assert_eq!(referenced.len(), 1);
10652        assert_eq!(
10653            referenced[0].field,
10654            "artifacts.murmur/driver-anthropic.gateway.api_key"
10655        );
10656        assert!(referenced[0].is_gateway_credential());
10657        assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
10658
10659        let literal = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      api_key: not-a-reference\ninference:\n  transport: http\n  model: test-model\n  driver:\n    artifact: murmur/driver-anthropic\n";
10660        assert!(referenced_env_variables(literal).is_empty());
10661
10662        let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
10663        assert!(referenced_env_variables(no_inference).is_empty());
10664
10665        let unparseable = "name: broken\nversion: [\n";
10666        assert!(referenced_env_variables(unparseable).is_empty());
10667    }
10668
10669    #[test]
10670    fn inference_requires_driver_artifact() {
10671        let err = RuntimeManifest::from_yaml_str(
10672            r#"
10673name: cap
10674version: 0.0.1
10675artifacts: []
10676inference:
10677  transport: http
10678  endpoint: http://127.0.0.1:8080
10679  model: test-model
10680"#,
10681        )
10682        .unwrap_err();
10683
10684        assert!(err.to_string().contains("inference.driver"));
10685    }
10686
10687    #[test]
10688    fn test_runtime_driver_deserializes() {
10689        let manifest = RuntimeManifest::from_yaml_str(
10690            r#"
10691name: cap
10692version: 0.0.1
10693artifacts:
10694  - name: local/my-provider
10695    version: 1.0.0
10696    runtime: driver
10697"#,
10698        )
10699        .unwrap();
10700
10701        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
10702    }
10703
10704    #[test]
10705    fn test_runtime_unknown_is_error() {
10706        let err = RuntimeManifest::from_yaml_str(
10707            r#"
10708name: cap
10709version: 0.0.1
10710artifacts:
10711  - name: local/foo
10712    version: 1.0.0
10713    runtime: unknown_type
10714"#,
10715        )
10716        .unwrap_err();
10717
10718        let msg = err.to_string();
10719        assert!(msg.contains("runtime"), "error should mention 'runtime'");
10720        assert!(
10721            msg.contains("unknown_type"),
10722            "error should identify the bad value"
10723        );
10724    }
10725
10726    #[test]
10727    fn test_runtime_all_variants_roundtrip() {
10728        for variant in [
10729            ArtifactRuntime::Tool,
10730            ArtifactRuntime::Driver,
10731            ArtifactRuntime::Hook,
10732            ArtifactRuntime::Skill,
10733        ] {
10734            let json = serde_json::to_string(&variant).unwrap();
10735            let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
10736            assert_eq!(variant, back);
10737        }
10738    }
10739
10740    #[test]
10741    fn skill_source_without_version_defaults_to_local() {
10742        let manifest = RuntimeManifest::from_yaml_str(
10743            r#"
10744name: cap
10745version: 0.0.1
10746artifacts:
10747  - name: my-skill
10748    source: ./skills/my-skill/SKILL.md
10749    runtime: skill
10750"#,
10751        )
10752        .unwrap();
10753
10754        assert_eq!(manifest.artifacts.len(), 1);
10755        let artifact = &manifest.artifacts[0];
10756        assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
10757        assert_eq!(
10758            artifact.source.as_deref(),
10759            Some("./skills/my-skill/SKILL.md")
10760        );
10761        assert_eq!(artifact.version, "local");
10762    }
10763
10764    #[test]
10765    fn skill_source_directory_path_is_accepted() {
10766        let manifest = RuntimeManifest::from_yaml_str(
10767            r#"
10768name: cap
10769version: 0.0.1
10770artifacts:
10771  - name: my-skill
10772    source: ./skills/my-skill/
10773    runtime: skill
10774"#,
10775        )
10776        .unwrap();
10777
10778        assert_eq!(
10779            manifest.artifacts[0].source.as_deref(),
10780            Some("./skills/my-skill/")
10781        );
10782        assert_eq!(manifest.artifacts[0].version, "local");
10783    }
10784
10785    #[test]
10786    fn skill_source_with_explicit_version_substitutes_local() {
10787        // Both set is allowed; version is ignored and "local" is substituted.
10788        let manifest = RuntimeManifest::from_yaml_str(
10789            r#"
10790name: cap
10791version: 0.0.1
10792artifacts:
10793  - name: my-skill
10794    source: ./skills/my-skill/SKILL.md
10795    version: 9.9.9
10796    runtime: skill
10797"#,
10798        )
10799        .unwrap();
10800
10801        assert_eq!(manifest.artifacts[0].version, "local");
10802    }
10803
10804    #[test]
10805    fn local_source_true_allows_source_on_non_skill_runtime() {
10806        for runtime in ["tool", "driver", "hook"] {
10807            let yaml = format!(
10808                r#"
10809name: cap
10810version: 0.0.1
10811artifacts:
10812  - name: my-thing
10813    source: ./local/path
10814    local_source: true
10815    runtime: {runtime}
10816"#
10817            );
10818            let manifest = RuntimeManifest::from_yaml_str(&yaml)
10819                .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
10820            let artifact = &manifest.artifacts[0];
10821            assert!(artifact.local_source, "runtime {runtime}");
10822            assert_eq!(artifact.source.as_deref(), Some("./local/path"));
10823            assert_eq!(artifact.version, "local", "runtime {runtime}");
10824        }
10825    }
10826
10827    #[test]
10828    fn local_source_false_on_skill_with_source_is_error() {
10829        // The gate reads the declared field, not the role: an explicit `local_source: false`
10830        // overrides the skill default and rejects `source:`.
10831        let err = RuntimeManifest::from_yaml_str(
10832            r#"
10833name: cap
10834version: 0.0.1
10835artifacts:
10836  - name: my-skill
10837    source: ./skills/my-skill
10838    local_source: false
10839    runtime: skill
10840"#,
10841        )
10842        .unwrap_err();
10843        let msg = err.to_string();
10844        assert!(msg.contains("'local_source: true'"), "error was: {msg}");
10845        assert!(msg.contains("my-skill"), "error was: {msg}");
10846    }
10847
10848    #[test]
10849    fn local_source_defaults_from_role_when_absent() {
10850        let manifest = RuntimeManifest::from_yaml_str(
10851            r#"
10852name: cap
10853version: 0.0.1
10854artifacts:
10855  - name: my-skill
10856    source: ./skills/my-skill
10857    runtime: skill
10858  - name: local/my-tool
10859    version: 1.0.0
10860    runtime: tool
10861"#,
10862        )
10863        .unwrap();
10864        assert!(manifest.artifacts[0].local_source, "skill defaults to true");
10865        assert!(
10866            !manifest.artifacts[1].local_source,
10867            "tool defaults to false"
10868        );
10869    }
10870
10871    #[test]
10872    fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
10873        let manifest = RuntimeManifest::from_yaml_str(
10874            r#"
10875name: cap
10876version: 0.0.1
10877artifacts:
10878  - name: local/my-tool
10879    version: 1.0.0
10880    runtime: tool
10881    prompt_payload: true
10882  - name: murmur/driver-anthropic
10883    version: 0.1.0
10884    runtime: driver
10885    gateway:
10886      endpoint: http://127.0.0.1:8080
10887      keyless: true
10888inference:
10889  model: test-model
10890  system_prompt_artifact: local/my-tool
10891  driver:
10892    artifact: murmur/driver-anthropic
10893"#,
10894        )
10895        .unwrap();
10896        assert!(manifest.artifacts[0].prompt_payload);
10897    }
10898
10899    #[test]
10900    fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
10901        let err = RuntimeManifest::from_yaml_str(
10902            r#"
10903name: cap
10904version: 0.0.1
10905artifacts:
10906  - name: local/my-skill
10907    version: 1.0.0
10908    runtime: skill
10909    prompt_payload: false
10910  - name: murmur/driver-anthropic
10911    version: 0.1.0
10912    runtime: driver
10913    gateway:
10914      endpoint: http://127.0.0.1:8080
10915      keyless: true
10916inference:
10917  model: test-model
10918  system_prompt_artifact: local/my-skill
10919  driver:
10920    artifact: murmur/driver-anthropic
10921"#,
10922        )
10923        .unwrap_err();
10924        let msg = err.to_string();
10925        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
10926    }
10927
10928    #[test]
10929    fn prompt_payload_defaults_from_role_when_absent() {
10930        let manifest = RuntimeManifest::from_yaml_str(
10931            r#"
10932name: cap
10933version: 0.0.1
10934artifacts:
10935  - name: local/my-skill
10936    version: 1.0.0
10937    runtime: skill
10938  - name: local/my-tool
10939    version: 1.0.0
10940    runtime: tool
10941"#,
10942        )
10943        .unwrap();
10944        assert!(
10945            manifest.artifacts[0].prompt_payload,
10946            "skill defaults to true"
10947        );
10948        assert!(
10949            !manifest.artifacts[1].prompt_payload,
10950            "tool defaults to false"
10951        );
10952    }
10953
10954    #[test]
10955    fn source_on_non_skill_runtime_is_error() {
10956        for runtime in ["tool", "driver", "hook"] {
10957            let yaml = format!(
10958                r#"
10959name: cap
10960version: 0.0.1
10961artifacts:
10962  - name: my-thing
10963    source: ./local/path
10964    runtime: {runtime}
10965"#
10966            );
10967            let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
10968            let msg = err.to_string();
10969            assert!(
10970                msg.contains("'local_source: true'"),
10971                "runtime {runtime}: error was: {msg}"
10972            );
10973            assert!(
10974                msg.contains("my-thing"),
10975                "runtime {runtime}: error was: {msg}"
10976            );
10977        }
10978    }
10979
10980    #[test]
10981    fn registry_skill_still_requires_version() {
10982        let err = RuntimeManifest::from_yaml_str(
10983            r#"
10984name: cap
10985version: 0.0.1
10986artifacts:
10987  - name: local/my-skill
10988    runtime: skill
10989"#,
10990        )
10991        .unwrap_err();
10992
10993        let msg = err.to_string();
10994        assert!(
10995            msg.contains("missing required field 'version'"),
10996            "error was: {msg}"
10997        );
10998    }
10999
11000    #[test]
11001    fn rejects_unknown_inference_transport() {
11002        let err = RuntimeManifest::from_yaml_str(
11003            r#"
11004name: cap
11005version: 0.0.1
11006artifacts: []
11007inference:
11008  transport: grpc
11009  model: test-model
11010  driver:
11011    artifact: murmur/driver-anthropic
11012"#,
11013        )
11014        .unwrap_err();
11015
11016        let msg = err.to_string();
11017        assert!(msg.contains("inference.transport"), "error was: {msg}");
11018        assert!(msg.contains("unknown value"), "error was: {msg}");
11019        assert!(msg.contains("grpc"), "error was: {msg}");
11020    }
11021
11022    /// The driver is what knows a harness, so `command:` alone selects nothing: a manifest that
11023    /// names only a command is refused and told which field to add.
11024    #[test]
11025    fn process_transport_with_command_and_no_driver_is_refused() {
11026        let err = RuntimeManifest::from_yaml_str(
11027            r#"
11028name: cap
11029version: 0.0.1
11030artifacts: []
11031inference:
11032  transport: process
11033  command: some-cli
11034  model: some-model
11035  max_turns: 20
11036"#,
11037        )
11038        .unwrap_err();
11039
11040        let msg = err.to_string();
11041        assert!(
11042            msg.contains("inference.driver.artifact"),
11043            "error was: {msg}"
11044        );
11045        assert!(msg.contains("inference.command"), "error was: {msg}");
11046        assert!(
11047            msg.contains("murmur/driver-claude-code"),
11048            "error was: {msg}"
11049        );
11050    }
11051
11052    #[test]
11053    fn process_transport_with_neither_driver_nor_command_is_refused() {
11054        let err = RuntimeManifest::from_yaml_str(
11055            r#"
11056name: cap
11057version: 0.0.1
11058artifacts: []
11059inference:
11060  transport: process
11061  model: some-model
11062"#,
11063        )
11064        .unwrap_err();
11065
11066        let msg = err.to_string();
11067        assert!(
11068            msg.contains("inference.driver.artifact"),
11069            "error was: {msg}"
11070        );
11071        assert!(
11072            msg.contains("murmur/driver-claude-code"),
11073            "error was: {msg}"
11074        );
11075    }
11076
11077    #[test]
11078    fn process_transport_allows_absent_model() {
11079        // model is optional for transport: process — an absent model leaves the choice to the
11080        // harness, which has an account default of its own.
11081        let manifest =
11082            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
11083                .expect("process transport should parse without a model");
11084        let inference = manifest.inference.expect("inference present");
11085        assert_eq!(inference.transport, "process");
11086        assert!(inference.command.is_none());
11087        assert_eq!(
11088            inference.model, "",
11089            "absent model resolves to empty (harness default)"
11090        );
11091    }
11092
11093    fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
11094        format!(
11095            "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n  transport: process\n  \
11096             driver:\n    artifact: local/fixture-process-driver\n{inference_extra}"
11097        )
11098    }
11099
11100    const PROCESS_DRIVER_ENTRY: &str =
11101        "  - name: local/fixture-process-driver\n    version: 0.1.0\n    runtime: driver";
11102
11103    #[test]
11104    fn process_transport_accepts_driver() {
11105        let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
11106            PROCESS_DRIVER_ENTRY,
11107            "    config:\n      profile: work\n  command: /usr/local/bin/fixture-cli\n",
11108        ))
11109        .unwrap();
11110
11111        let inference = manifest.inference.expect("inference should exist");
11112        assert_eq!(inference.transport, "process");
11113        let driver = inference.driver.expect("driver should be parsed");
11114        assert_eq!(driver.artifact, "local/fixture-process-driver");
11115        assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
11116        assert_eq!(
11117            inference.command.as_deref(),
11118            Some("/usr/local/bin/fixture-cli")
11119        );
11120    }
11121
11122    #[test]
11123    fn process_transport_accepts_driver_without_command() {
11124        let manifest =
11125            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
11126                .unwrap();
11127
11128        let inference = manifest.inference.expect("inference should exist");
11129        assert_eq!(
11130            inference.driver.map(|d| d.artifact).as_deref(),
11131            Some("local/fixture-process-driver")
11132        );
11133        assert!(inference.command.is_none());
11134        assert_eq!(inference.model, "");
11135    }
11136
11137    #[test]
11138    fn process_driver_entry_must_be_declared() {
11139        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
11140            "  - name: local/other\n    version: 0.1.0\n    runtime: tool",
11141            "",
11142        ))
11143        .unwrap_err()
11144        .to_string();
11145        assert!(msg.contains("inference.driver.artifact"), "{msg}");
11146        assert!(
11147            msg.contains("artifact 'local/fixture-process-driver' is not declared in artifacts:"),
11148            "{msg}"
11149        );
11150    }
11151
11152    #[test]
11153    fn process_driver_entry_must_be_runtime_driver() {
11154        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
11155            "  - name: local/fixture-process-driver\n    version: 0.1.0\n    runtime: tool",
11156            "",
11157        ))
11158        .unwrap_err()
11159        .to_string();
11160        assert!(
11161            msg.contains("must be declared with runtime: driver"),
11162            "{msg}"
11163        );
11164    }
11165
11166    #[test]
11167    fn process_driver_entry_refuses_capabilities() {
11168        let entry =
11169            format!("{PROCESS_DRIVER_ENTRY}\n    capabilities:\n      env:\n        allow: [HOME]");
11170        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
11171            .unwrap_err()
11172            .to_string();
11173        assert!(msg.contains("granted nothing"), "{msg}");
11174        assert!(msg.contains("inference.driver.config"), "{msg}");
11175    }
11176
11177    #[test]
11178    fn process_driver_entry_refuses_config() {
11179        let entry = format!("{PROCESS_DRIVER_ENTRY}\n    config:\n      profile: work");
11180        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
11181            .unwrap_err()
11182            .to_string();
11183        assert!(msg.contains("granted nothing"), "{msg}");
11184        assert!(msg.contains("remove capabilities:/config:"), "{msg}");
11185    }
11186
11187    #[test]
11188    fn process_transport_rejects_endpoint() {
11189        let err = RuntimeManifest::from_yaml_str(
11190            r#"
11191name: cap
11192version: 0.0.1
11193artifacts: []
11194inference:
11195  transport: process
11196  command: claude
11197  model: claude-haiku-4-5-20251001
11198  endpoint: http://localhost:8080
11199"#,
11200        )
11201        .unwrap_err();
11202
11203        let msg = err.to_string();
11204        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
11205        assert!(msg.contains("was removed"), "error was: {msg}");
11206    }
11207
11208    #[test]
11209    fn process_transport_rejects_api_key() {
11210        // Key assembled at runtime so the source never contains a
11211        // credential-shaped literal that secret scanners could flag.
11212        let key = ["sk-", "ant-", "secret"].concat();
11213        let err = RuntimeManifest::from_yaml_str(&format!(
11214            r#"
11215name: cap
11216version: 0.0.1
11217artifacts: []
11218inference:
11219  transport: process
11220  command: claude
11221  model: claude-haiku-4-5-20251001
11222  api_key: {key}
11223"#
11224        ))
11225        .unwrap_err();
11226
11227        let msg = err.to_string();
11228        assert!(msg.contains("inference.api_key"), "error was: {msg}");
11229        assert!(msg.contains("was removed"), "error was: {msg}");
11230        assert!(!msg.contains(&key), "error was: {msg}");
11231    }
11232
11233    #[test]
11234    fn process_transport_rejects_max_tokens() {
11235        // Rejected even at a perfectly valid value: the CLI subprocess path never builds a
11236        // driver payload, so accepting it would leave it silently inert.
11237        let err = RuntimeManifest::from_yaml_str(
11238            r#"
11239name: cap
11240version: 0.0.1
11241artifacts: []
11242inference:
11243  transport: process
11244  command: claude
11245  model: claude-haiku-4-5-20251001
11246  max_tokens: 4096
11247"#,
11248        )
11249        .unwrap_err();
11250
11251        let msg = err.to_string();
11252        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
11253        assert!(
11254            msg.contains("not valid with transport: process"),
11255            "error was: {msg}"
11256        );
11257    }
11258
11259    /// The harness's own reported counts are what this transport meters, so the ceiling parses
11260    /// here as it does under `http`. A driver that reports no counts is refused at load, not
11261    /// here: the manifest cannot see which driver it names.
11262    #[test]
11263    fn process_transport_carries_max_session_tokens() {
11264        let manifest = RuntimeManifest::from_yaml_str(
11265            r#"
11266name: cap
11267version: 0.0.1
11268artifacts:
11269  - name: murmur/driver-claude-code
11270    version: 0.1.0
11271    runtime: driver
11272inference:
11273  transport: process
11274  driver:
11275    artifact: murmur/driver-claude-code
11276  model: claude-haiku-4-5-20251001
11277  max_session_tokens: 200000
11278"#,
11279        )
11280        .unwrap();
11281
11282        assert_eq!(
11283            manifest.inference.unwrap().max_session_tokens,
11284            Some(200_000)
11285        );
11286    }
11287
11288    #[test]
11289    fn process_transport_rejects_a_zero_max_session_tokens() {
11290        let err = RuntimeManifest::from_yaml_str(
11291            r#"
11292name: cap
11293version: 0.0.1
11294artifacts:
11295  - name: murmur/driver-claude-code
11296    version: 0.1.0
11297    runtime: driver
11298inference:
11299  transport: process
11300  driver:
11301    artifact: murmur/driver-claude-code
11302  model: claude-haiku-4-5-20251001
11303  max_session_tokens: 0
11304"#,
11305        )
11306        .unwrap_err();
11307
11308        let msg = err.to_string();
11309        assert!(
11310            msg.contains("inference.max_session_tokens"),
11311            "error was: {msg}"
11312        );
11313        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
11314    }
11315
11316    #[test]
11317    fn inference_max_session_tokens_round_trips() {
11318        let manifest = RuntimeManifest::from_yaml_str(
11319            r#"
11320name: cap
11321version: 0.0.1
11322artifacts:
11323  - name: murmur/driver-anthropic
11324    version: 0.1.0
11325    runtime: driver
11326    gateway:
11327      endpoint: https://api.anthropic.com
11328      api_key: test-key
11329inference:
11330  transport: http
11331  model: claude-opus-4-5
11332  max_session_tokens: 5000000000
11333  driver:
11334    artifact: murmur/driver-anthropic
11335"#,
11336        )
11337        .unwrap();
11338
11339        assert_eq!(
11340            manifest.inference.unwrap().max_session_tokens,
11341            Some(5_000_000_000)
11342        );
11343    }
11344
11345    #[test]
11346    fn inference_max_session_tokens_absent_is_none() {
11347        let manifest = RuntimeManifest::from_yaml_str(
11348            r#"
11349name: cap
11350version: 0.0.1
11351artifacts:
11352  - name: murmur/driver-anthropic
11353    version: 0.1.0
11354    runtime: driver
11355    gateway:
11356      endpoint: https://api.anthropic.com
11357      api_key: test-key
11358inference:
11359  transport: http
11360  model: claude-opus-4-5
11361  driver:
11362    artifact: murmur/driver-anthropic
11363"#,
11364        )
11365        .unwrap();
11366
11367        assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
11368    }
11369
11370    #[test]
11371    fn inference_max_session_tokens_zero_is_rejected() {
11372        let err = RuntimeManifest::from_yaml_str(
11373            r#"
11374name: cap
11375version: 0.0.1
11376artifacts:
11377  - name: murmur/driver-anthropic
11378    version: 0.1.0
11379    runtime: driver
11380    gateway:
11381      endpoint: https://api.anthropic.com
11382      api_key: test-key
11383inference:
11384  transport: http
11385  model: claude-opus-4-5
11386  max_session_tokens: 0
11387  driver:
11388    artifact: murmur/driver-anthropic
11389"#,
11390        )
11391        .unwrap_err();
11392
11393        let msg = err.to_string();
11394        assert!(
11395            msg.contains("inference.max_session_tokens"),
11396            "error was: {msg}"
11397        );
11398        assert!(msg.contains("greater than 0"), "error was: {msg}");
11399    }
11400
11401    /// An http capsule manifest with `extra` appended to its `inference:` block.
11402    fn http_manifest_with_inference(extra: &str) -> String {
11403        format!(
11404            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    \
11405             version: 0.1.0\n    runtime: driver\n    gateway:\n      \
11406             endpoint: https://api.anthropic.com\n      api_key: test-key\n\
11407             inference:\n  transport: http\n  model: claude-opus-4-5\n  driver:\n    \
11408             artifact: murmur/driver-anthropic\n{extra}"
11409        )
11410    }
11411
11412    #[test]
11413    fn inference_tool_refresh_absent_is_compaction() {
11414        let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
11415        assert_eq!(
11416            manifest.inference.unwrap().tool_refresh,
11417            ToolRefresh::Compaction
11418        );
11419        assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
11420    }
11421
11422    #[test]
11423    fn inference_tool_refresh_parses_both_values() {
11424        for (value, expected) in [
11425            ("compaction", ToolRefresh::Compaction),
11426            ("immediate", ToolRefresh::Immediate),
11427        ] {
11428            let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
11429                "  tool_refresh: {value}\n"
11430            )))
11431            .unwrap();
11432            assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
11433            assert_eq!(expected.wire_name(), value);
11434            assert!(
11435                manifest.unknown_keys.is_empty(),
11436                "tool_refresh is a known key: {:?}",
11437                manifest.unknown_keys
11438            );
11439        }
11440    }
11441
11442    #[test]
11443    fn inference_tool_refresh_rejects_an_unknown_value() {
11444        let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
11445            "  tool_refresh: sometimes\n",
11446        ))
11447        .unwrap_err();
11448        match err {
11449            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
11450                assert_eq!(field, "inference.tool_refresh");
11451                assert_eq!(
11452                    message,
11453                    "must be one of: compaction, immediate; got 'sometimes'"
11454                );
11455            }
11456            other => panic!("unexpected error: {other:?}"),
11457        }
11458    }
11459
11460    /// Refused whatever its value: the harness owns its tool list under `transport: process`,
11461    /// so the key would be silently inert there.
11462    #[test]
11463    fn process_transport_rejects_tool_refresh() {
11464        for value in ["compaction", "immediate"] {
11465            let err = RuntimeManifest::from_yaml_str(&format!(
11466                "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  \
11467                 command: claude\n  model: claude-haiku-4-5-20251001\n  tool_refresh: {value}\n"
11468            ))
11469            .unwrap_err();
11470            match err {
11471                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
11472                    assert_eq!(field, "inference.tool_refresh");
11473                    assert_eq!(message, "is not valid with transport: process");
11474                }
11475                other => panic!("unexpected error for {value}: {other:?}"),
11476            }
11477        }
11478    }
11479
11480    #[test]
11481    fn inference_max_tokens_round_trips() {
11482        let manifest = RuntimeManifest::from_yaml_str(
11483            r#"
11484name: cap
11485version: 0.0.1
11486artifacts:
11487  - name: murmur/driver-anthropic
11488    version: 0.1.0
11489    runtime: driver
11490    gateway:
11491      endpoint: https://api.anthropic.com
11492      api_key: test-key
11493inference:
11494  transport: http
11495  model: claude-opus-4-5
11496  max_tokens: 4096
11497  driver:
11498    artifact: murmur/driver-anthropic
11499"#,
11500        )
11501        .unwrap();
11502
11503        let inference = manifest.inference.unwrap();
11504        assert_eq!(inference.max_tokens, Some(4096));
11505    }
11506
11507    #[test]
11508    fn inference_max_tokens_absent_is_none() {
11509        // Absent means "the manifest didn't set it" all the way down; the runtime resolves each
11510        // driver choice's cap from its driver's `models:` table, not here.
11511        let manifest = RuntimeManifest::from_yaml_str(
11512            r#"
11513name: cap
11514version: 0.0.1
11515artifacts:
11516  - name: murmur/driver-anthropic
11517    version: 0.1.0
11518    runtime: driver
11519    gateway:
11520      endpoint: https://api.anthropic.com
11521      api_key: test-key
11522inference:
11523  transport: http
11524  model: claude-opus-4-5
11525  driver:
11526    artifact: murmur/driver-anthropic
11527"#,
11528        )
11529        .unwrap();
11530
11531        assert_eq!(manifest.inference.unwrap().max_tokens, None);
11532    }
11533
11534    #[test]
11535    fn inference_max_tokens_zero_is_rejected() {
11536        let err = RuntimeManifest::from_yaml_str(
11537            r#"
11538name: cap
11539version: 0.0.1
11540artifacts:
11541  - name: murmur/driver-anthropic
11542    version: 0.1.0
11543    runtime: driver
11544    gateway:
11545      endpoint: https://api.anthropic.com
11546      api_key: test-key
11547inference:
11548  transport: http
11549  model: claude-opus-4-5
11550  max_tokens: 0
11551  driver:
11552    artifact: murmur/driver-anthropic
11553"#,
11554        )
11555        .unwrap_err();
11556
11557        let msg = err.to_string();
11558        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
11559        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
11560    }
11561
11562    #[test]
11563    fn inference_max_tokens_large_value_is_accepted() {
11564        // Manifest validation stays advisory: an over-large cap is the provider's to reject
11565        // at request time, not ours to clamp or block at load time.
11566        let manifest = RuntimeManifest::from_yaml_str(
11567            r#"
11568name: cap
11569version: 0.0.1
11570artifacts:
11571  - name: murmur/driver-anthropic
11572    version: 0.1.0
11573    runtime: driver
11574    gateway:
11575      endpoint: https://api.anthropic.com
11576      api_key: test-key
11577inference:
11578  transport: http
11579  model: claude-opus-4-5
11580  max_tokens: 999999
11581  driver:
11582    artifact: murmur/driver-anthropic
11583"#,
11584        )
11585        .unwrap();
11586
11587        assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
11588    }
11589
11590    #[test]
11591    fn inference_and_context_max_tokens_are_independent() {
11592        // Two unrelated concepts that happen to share a leaf name: per-turn output cap vs.
11593        // session compaction budget. Neither may leak into the other.
11594        let manifest = RuntimeManifest::from_yaml_str(
11595            r#"
11596name: cap
11597version: 0.0.1
11598artifacts:
11599  - name: murmur/driver-anthropic
11600    version: 0.1.0
11601    runtime: driver
11602    gateway:
11603      endpoint: https://api.anthropic.com
11604      api_key: test-key
11605inference:
11606  transport: http
11607  model: claude-opus-4-5
11608  max_tokens: 4096
11609  driver:
11610    artifact: murmur/driver-anthropic
11611context:
11612  max_tokens: 200000
11613"#,
11614        )
11615        .unwrap();
11616
11617        assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
11618        assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
11619    }
11620
11621    #[test]
11622    fn hook_config_defaults_when_fields_absent() {
11623        let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
11624        assert_eq!(config.binding, HookBinding::All);
11625        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
11626        assert_eq!(config.commit_policy, HookCommitPolicy::None);
11627    }
11628
11629    #[test]
11630    fn hook_config_parses_all_fields() {
11631        let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
11632        let config = parse_hook_config_from_yaml(yaml).unwrap();
11633        assert_eq!(config.binding, HookBinding::OnCompaction);
11634        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
11635        assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
11636    }
11637
11638    #[test]
11639    fn hook_config_parses_on_task_start() {
11640        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
11641        let config = parse_hook_config_from_yaml(yaml).unwrap();
11642        assert_eq!(config.binding, HookBinding::OnTaskStart);
11643    }
11644
11645    #[test]
11646    fn hook_config_parses_on_task_end() {
11647        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
11648        let config = parse_hook_config_from_yaml(yaml).unwrap();
11649        assert_eq!(config.binding, HookBinding::OnTaskEnd);
11650    }
11651
11652    #[test]
11653    fn hook_config_unknown_binding_lists_task_events() {
11654        let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
11655        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11656        assert!(err.contains("on-task-start"), "error was: {err}");
11657        assert!(err.contains("on-task-end"), "error was: {err}");
11658    }
11659
11660    #[test]
11661    fn hook_config_async_with_commit_is_rejected() {
11662        let yaml =
11663            "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
11664        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11665        assert!(err.contains("async-with-commit"), "error was: {err}");
11666    }
11667
11668    #[test]
11669    fn hook_config_on_stage_async_is_rejected() {
11670        let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
11671        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11672        assert!(err.contains("on-stage"), "error was: {err}");
11673        assert!(err.contains("blocking"), "error was: {err}");
11674    }
11675
11676    #[test]
11677    fn hook_config_async_none_is_valid() {
11678        let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
11679        let config = parse_hook_config_from_yaml(yaml).unwrap();
11680        assert_eq!(config.execution_mode, HookExecutionMode::Async);
11681        assert_eq!(config.commit_policy, HookCommitPolicy::None);
11682    }
11683
11684    #[test]
11685    fn hook_config_parses_reopen_task_commit_policy() {
11686        let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
11687        let config = parse_hook_config_from_yaml(yaml).unwrap();
11688        assert_eq!(config.binding, HookBinding::OnTaskEnd);
11689        assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
11690    }
11691
11692    #[test]
11693    fn hook_config_unknown_commit_policy_lists_reopen_task() {
11694        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11695        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11696        assert!(err.contains("reopen-task"), "error was: {err}");
11697    }
11698
11699    #[test]
11700    fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
11701        for binding in ["on-shell", "on-tool-call"] {
11702            let yaml =
11703                format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
11704            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11705            assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
11706        }
11707        assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
11708    }
11709
11710    /// `deny` is the one policy an omitted `binding:` cannot carry: a hook that does not name
11711    /// which of the two gated events it decides on would be asked at both.
11712    #[test]
11713    fn hook_config_deny_without_a_binding_is_rejected() {
11714        let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
11715        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11716        assert!(
11717            err.contains("requires an explicit binding"),
11718            "error was: {err}"
11719        );
11720        assert!(err.contains("on-shell"), "error was: {err}");
11721        assert!(err.contains("on-tool-call"), "error was: {err}");
11722    }
11723
11724    /// Every other binding refuses `deny` through the existing binding-mismatch check.
11725    #[test]
11726    fn hook_config_deny_on_an_ungated_binding_is_rejected() {
11727        let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
11728        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11729        assert!(err.contains("is not valid for binding"), "error was: {err}");
11730        assert!(err.contains("replace-context"), "error was: {err}");
11731    }
11732
11733    /// The unchanged async/commit check covers `deny` for free; this pins that it does.
11734    #[test]
11735    fn hook_config_async_deny_is_rejected() {
11736        let yaml =
11737            "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
11738        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11739        assert!(err.contains("async-with-commit"), "error was: {err}");
11740        assert!(err.contains("deny"), "error was: {err}");
11741    }
11742
11743    #[test]
11744    fn hook_config_unknown_commit_policy_lists_deny() {
11745        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11746        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11747        assert!(err.contains("deny"), "error was: {err}");
11748    }
11749
11750    /// The existing, unmodified async/commit validation must also reject
11751    /// `commit_policy: reopen-task` combined with `execution_mode: async`.
11752    #[test]
11753    fn hook_config_async_reopen_task_is_rejected() {
11754        let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
11755        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11756        assert!(err.contains("async-with-commit"), "error was: {err}");
11757        assert!(err.contains("reopen-task"), "error was: {err}");
11758    }
11759
11760    // ── binding is the single source of truth for what a hook commits ─────────────
11761
11762    /// The two bindings whose events honor no `hook-output` arm at all cannot declare
11763    /// any non-`none` `commit_policy`. The error names the binding, the declared policy,
11764    /// and `none` as what the binding honors.
11765    #[test]
11766    fn hook_config_non_committing_binding_rejects_any_commit_policy() {
11767        for binding in ["on-session-start", "on-session-end"] {
11768            let yaml = format!(
11769                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
11770            );
11771            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11772            assert!(err.contains(binding), "error was: {err}");
11773            assert!(err.contains("replace-context"), "error was: {err}");
11774            assert!(
11775                err.contains("honors commit_policy 'none'"),
11776                "error was: {err}"
11777            );
11778        }
11779    }
11780
11781    /// A binding that honors one arm rejects every *other* policy, naming the one it does
11782    /// honor so the fix is obvious from the message alone.
11783    #[test]
11784    fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
11785        for (binding, declared, honored) in [
11786            ("on-stage", "reopen-task", "write-manifests"),
11787            ("on-compaction", "write-manifests", "replace-context"),
11788            ("on-task-end", "replace-context", "reopen-task"),
11789            ("on-task-start", "replace-context", "seed-context"),
11790            ("on-shell", "replace-context", "deny"),
11791            ("on-tool-call", "replace-context", "deny"),
11792        ] {
11793            let yaml = format!(
11794                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
11795            );
11796            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11797            assert!(err.contains(binding), "error was: {err}");
11798            assert!(
11799                err.contains(&format!("commit_policy '{declared}' is not valid")),
11800                "error was: {err}"
11801            );
11802            assert!(
11803                err.contains(&format!("honors commit_policy '{honored}'")),
11804                "error was: {err}"
11805            );
11806        }
11807    }
11808
11809    /// `on-inference` honors the `artifact` arm, which has no `commit_policy` spelling —
11810    /// so *no* non-`none` policy is ever valid for it, including `write-manifests`.
11811    #[test]
11812    fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
11813        for declared in ["replace-context", "write-manifests", "reopen-task"] {
11814            let yaml = format!(
11815                "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
11816            );
11817            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11818            assert!(err.contains("on-inference"), "error was: {err}");
11819            assert!(err.contains(declared), "error was: {err}");
11820            assert!(
11821                err.contains("honors commit_policy 'none'"),
11822                "error was: {err}"
11823            );
11824            assert!(err.contains("artifact"), "error was: {err}");
11825        }
11826    }
11827
11828    /// A binding declaring exactly the policy it honors is accepted, for every
11829    /// representable pair.
11830    #[test]
11831    fn hook_config_matching_binding_and_commit_policy_is_valid() {
11832        for (binding, policy, expected) in [
11833            (
11834                "on-stage",
11835                "write-manifests",
11836                HookCommitPolicy::WriteManifests,
11837            ),
11838            (
11839                "on-compaction",
11840                "replace-context",
11841                HookCommitPolicy::ReplaceContext,
11842            ),
11843            ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
11844            ("on-shell", "deny", HookCommitPolicy::Deny),
11845            ("on-tool-call", "deny", HookCommitPolicy::Deny),
11846        ] {
11847            let yaml =
11848                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
11849            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11850            assert_eq!(config.commit_policy, expected);
11851        }
11852    }
11853
11854    /// Every binding may declare `commit_policy: none` — including the ones that honor
11855    /// an arm, which simply means the hook opts out of committing.
11856    #[test]
11857    fn hook_config_commit_policy_none_is_valid_for_every_binding() {
11858        for binding in [
11859            "on-stage",
11860            "on-session-start",
11861            "on-task-start",
11862            "on-inference",
11863            "on-tool-call",
11864            "on-shell",
11865            "on-compaction",
11866            "on-task-end",
11867            "on-session-end",
11868        ] {
11869            let yaml =
11870                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
11871            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11872            assert_eq!(config.commit_policy, HookCommitPolicy::None);
11873        }
11874    }
11875
11876    /// Resolved ambiguity: an omitted `binding:` is `HookBinding::All`, which is dispatched
11877    /// to every event — including every one that honors an arm — so every `commit_policy` is
11878    /// accepted for it. Deliberate: no narrowing for `All`. `deny` is the one exception,
11879    /// covered by [`hook_config_deny_without_a_binding_is_rejected`].
11880    #[test]
11881    fn hook_config_omitted_binding_accepts_every_commit_policy() {
11882        for (policy, expected) in [
11883            ("replace-context", HookCommitPolicy::ReplaceContext),
11884            ("write-manifests", HookCommitPolicy::WriteManifests),
11885            ("reopen-task", HookCommitPolicy::ReopenTask),
11886            ("none", HookCommitPolicy::None),
11887        ] {
11888            let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
11889            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11890            assert_eq!(config.binding, HookBinding::All);
11891            assert_eq!(config.commit_policy, expected);
11892        }
11893    }
11894
11895    /// Every hook artifact shipped in `default-artifacts` today must keep parsing after the
11896    /// binding/commit_policy check exists — this change is non-breaking for all eight. The
11897    /// field combinations are reproduced literally rather than read from that repo, so this
11898    /// suite stays self-contained.
11899    #[test]
11900    fn hook_config_shipped_default_artifact_manifests_still_parse() {
11901        let shipped: [(&str, &str, HookConfig); 8] = [
11902            (
11903                "murmur-hook-compact",
11904                "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
11905                HookConfig {
11906                    binding: HookBinding::OnCompaction,
11907                    execution_mode: HookExecutionMode::Blocking,
11908                    commit_policy: HookCommitPolicy::ReplaceContext,
11909                },
11910            ),
11911            (
11912                "murmur-hook-shell-desc",
11913                "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
11914                HookConfig {
11915                    binding: HookBinding::OnStage,
11916                    execution_mode: HookExecutionMode::Blocking,
11917                    commit_policy: HookCommitPolicy::WriteManifests,
11918                },
11919            ),
11920            (
11921                // No `binding:` line in the shipped manifest — `All`, not `on-task-end`.
11922                "murmur-hook-regression-verifier",
11923                "execution_mode: blocking\ncommit_policy: reopen-task\n",
11924                HookConfig {
11925                    binding: HookBinding::All,
11926                    execution_mode: HookExecutionMode::Blocking,
11927                    commit_policy: HookCommitPolicy::ReopenTask,
11928                },
11929            ),
11930            (
11931                "murmur-hook-memory",
11932                "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
11933                HookConfig {
11934                    binding: HookBinding::OnTaskStart,
11935                    execution_mode: HookExecutionMode::Blocking,
11936                    commit_policy: HookCommitPolicy::SeedContext,
11937                },
11938            ),
11939            (
11940                "murmur-hook-debug",
11941                "execution_mode: async\ncommit_policy: none\n",
11942                HookConfig {
11943                    binding: HookBinding::All,
11944                    execution_mode: HookExecutionMode::Async,
11945                    commit_policy: HookCommitPolicy::None,
11946                },
11947            ),
11948            (
11949                "murmur-hook-diff-summary",
11950                "execution_mode: blocking\ncommit_policy: none\n",
11951                HookConfig {
11952                    binding: HookBinding::All,
11953                    execution_mode: HookExecutionMode::Blocking,
11954                    commit_policy: HookCommitPolicy::None,
11955                },
11956            ),
11957            ("murmur-hook-eval", "", HookConfig::default()),
11958            ("murmur-hook-grafana", "", HookConfig::default()),
11959        ];
11960
11961        for (name, fields, expected) in shipped {
11962            let yaml = format!("name: {name}\nruntime: hook\n{fields}");
11963            let config = parse_hook_config_from_yaml(&yaml)
11964                .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
11965            assert_eq!(config, expected, "shipped hook {name}");
11966        }
11967    }
11968
11969    /// The manifest-side honored-policy table agrees with the manifest spellings the
11970    /// parser accepts; `capsule-runtime` separately cross-checks it against its own
11971    /// `HONORED_OUTPUT_ARM` dispatch table.
11972    #[test]
11973    fn commit_policy_for_binding_matches_the_declarable_pairs() {
11974        assert_eq!(
11975            commit_policy_for_binding(&HookBinding::OnStage),
11976            Some(HookCommitPolicy::WriteManifests)
11977        );
11978        assert_eq!(
11979            commit_policy_for_binding(&HookBinding::OnCompaction),
11980            Some(HookCommitPolicy::ReplaceContext)
11981        );
11982        assert_eq!(
11983            commit_policy_for_binding(&HookBinding::OnTaskEnd),
11984            Some(HookCommitPolicy::ReopenTask)
11985        );
11986        assert_eq!(
11987            commit_policy_for_binding(&HookBinding::OnTaskStart),
11988            Some(HookCommitPolicy::SeedContext)
11989        );
11990        assert_eq!(
11991            commit_policy_for_binding(&HookBinding::OnToolCall),
11992            Some(HookCommitPolicy::Deny)
11993        );
11994        assert_eq!(
11995            commit_policy_for_binding(&HookBinding::OnShell),
11996            Some(HookCommitPolicy::Deny)
11997        );
11998        // `on-inference` honors `artifact`, which has no `commit_policy` spelling; the
11999        // other two honor nothing. `All` is unconstrained by design, apart from the `deny`
12000        // carve-out the parser applies.
12001        for binding in [
12002            HookBinding::OnSessionStart,
12003            HookBinding::OnInference,
12004            HookBinding::OnSessionEnd,
12005            HookBinding::All,
12006        ] {
12007            assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
12008        }
12009    }
12010
12011    /// The `on-task-start` pairing is declarable end to end: the parser accepts it, and
12012    /// refuses the same policy on any other binding by name.
12013    #[test]
12014    fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
12015        let config = parse_hook_config_from_yaml(
12016            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
12017             commit_policy: seed-context\n",
12018        )
12019        .expect("on-task-start + seed-context is the declarable pairing");
12020        assert_eq!(config.binding, HookBinding::OnTaskStart);
12021        assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
12022
12023        let error = parse_hook_config_from_yaml(
12024            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
12025             commit_policy: seed-context\n",
12026        )
12027        .expect_err("on-compaction cannot commit a seed");
12028        assert!(error.contains("on-compaction"), "{error}");
12029        assert!(error.contains("seed-context"), "{error}");
12030    }
12031
12032    /// The unknown-value diagnostic lists every accepted spelling, so an operator who
12033    /// mistypes one reads the whole set rather than guessing.
12034    #[test]
12035    fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
12036        let error = parse_hook_config_from_yaml(
12037            "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
12038        )
12039        .expect_err("a misspelled policy is rejected");
12040        assert!(error.contains("seed-context"), "{error}");
12041    }
12042
12043    // ── context.seed_budget / context.seed_overflow_margin ───────────────────
12044
12045    fn context_of(yaml_block: &str) -> Option<ContextConfig> {
12046        RuntimeManifest::from_yaml_str(&format!(
12047            "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
12048        ))
12049        .expect("the manifest under test parses")
12050        .context
12051    }
12052
12053    #[test]
12054    fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
12055        let context = context_of(
12056            "context:\n  max_tokens: 200000\n  seed_budget: 0.10\n  seed_overflow_margin: 0.10\n",
12057        )
12058        .expect("a declared context block parses to Some");
12059        assert_eq!(context.max_tokens, Some(200_000));
12060        assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
12061        assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
12062    }
12063
12064    #[test]
12065    fn seed_budget_and_overflow_margin_default_when_omitted() {
12066        let context = context_of("context:\n  max_tokens: 200000\n")
12067            .expect("a declared context block parses to Some");
12068        assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
12069        assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
12070    }
12071
12072    /// No `context:` block at all stays `None` — the two fractions default *within* a
12073    /// declared block, and never conjure one that the operator did not write.
12074    #[test]
12075    fn seed_budget_defaults_do_not_create_a_context_block() {
12076        assert_eq!(context_of(""), None);
12077    }
12078
12079    #[test]
12080    fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
12081        for (block, field) in [
12082            ("context:\n  seed_budget: 1.5\n", "context.seed_budget"),
12083            ("context:\n  seed_budget: -0.1\n", "context.seed_budget"),
12084            (
12085                "context:\n  seed_overflow_margin: 2.0\n",
12086                "context.seed_overflow_margin",
12087            ),
12088        ] {
12089            let error = RuntimeManifest::from_yaml_str(&format!(
12090                "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
12091            ))
12092            .expect_err("an out-of-range fraction is rejected");
12093            match error {
12094                RuntimeManifestError::InvalidInferenceConfig {
12095                    field: got,
12096                    ref message,
12097                } => {
12098                    assert_eq!(got, field);
12099                    assert!(message.contains("0.0 and 1.0"), "{message}");
12100                }
12101                other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
12102            }
12103        }
12104    }
12105
12106    #[test]
12107    fn deploy_block_mur_version_parses() {
12108        let manifest = RuntimeManifest::from_yaml_str(
12109            "name: cap\nversion: 0.1.0\nartifacts: []\ndeploy:\n  mur_version: \"0.4.5\"\n",
12110        )
12111        .unwrap();
12112        assert_eq!(manifest.deploy_mur_version(), Some("0.4.5"));
12113        assert_eq!(
12114            manifest.deploy,
12115            Some(DeployConfig {
12116                mur_version: Some("0.4.5".to_string())
12117            })
12118        );
12119    }
12120
12121    #[test]
12122    fn deploy_block_absent_is_none() {
12123        let manifest =
12124            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
12125        assert_eq!(manifest.deploy, None);
12126        assert_eq!(manifest.deploy_mur_version(), None);
12127    }
12128
12129    /// A blank pin and an empty block both mean "install the deploying mur's own version".
12130    #[test]
12131    fn deploy_block_blank_mur_version_is_none() {
12132        for deploy in [
12133            "deploy:\n  mur_version: \"\"\n",
12134            "deploy:\n  mur_version: \"  \"\n",
12135        ] {
12136            let manifest = RuntimeManifest::from_yaml_str(&format!(
12137                "name: cap\nversion: 0.1.0\nartifacts: []\n{deploy}"
12138            ))
12139            .unwrap();
12140            assert_eq!(manifest.deploy_mur_version(), None, "{deploy:?}");
12141        }
12142        let manifest =
12143            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\ndeploy: {}\n").unwrap();
12144        assert_eq!(manifest.deploy, Some(DeployConfig::default()));
12145        assert_eq!(manifest.deploy_mur_version(), None);
12146    }
12147
12148    #[test]
12149    fn deploy_block_misspelled_key_is_reported_under_deploy() {
12150        assert_eq!(
12151            unknown_keys_of("name: cap\nversion: 0.1.0\ndeploy:\n  mur_versoin: \"0.4.5\"\n"),
12152            vec![(
12153                "mur_versoin".to_string(),
12154                "deploy".to_string(),
12155                Some("mur_version".to_string())
12156            )]
12157        );
12158    }
12159
12160    #[test]
12161    fn a_top_level_mur_version_is_refused_naming_deploy_mur_version() {
12162        for pin in [
12163            "mur_version: \"0.4.5\"\n",
12164            "mur_version: 0.4.0\n",
12165            "mur_version: \"\"\n",
12166            "mur_version:\n",
12167        ] {
12168            let error = RuntimeManifest::from_yaml_str(&format!(
12169                "name: cap\nversion: 0.1.0\nartifacts: []\n{pin}"
12170            ))
12171            .expect_err(pin);
12172            assert!(
12173                matches!(error, RuntimeManifestError::TopLevelMurVersion),
12174                "{pin:?}: {error:?}"
12175            );
12176            assert!(error.to_string().contains("deploy.mur_version"), "{error}");
12177        }
12178        assert_eq!(
12179            RuntimeManifestError::TopLevelMurVersion.to_string(),
12180            "murmur.yaml: 'mur_version' is not a top-level key — the mur release a deploy \
12181             installs is pinned as 'deploy.mur_version'"
12182        );
12183    }
12184
12185    #[test]
12186    fn a_top_level_mur_version_is_refused_beside_a_deploy_block() {
12187        let yaml = "name: cap\nversion: 0.1.0\nmur_version: \"0.4.5\"\ndeploy:\n  mur_version: \"0.4.5\"\n";
12188        assert!(matches!(
12189            RuntimeManifest::from_yaml_str(yaml),
12190            Err(RuntimeManifestError::TopLevelMurVersion)
12191        ));
12192        assert!(matches!(
12193            RuntimeManifest::from_yaml_str_without_secrets(yaml),
12194            Err(RuntimeManifestError::TopLevelMurVersion)
12195        ));
12196    }
12197
12198    #[test]
12199    fn inference_max_turns_absent_sets_no_cap() {
12200        let manifest = RuntimeManifest::from_yaml_str(
12201            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\ninference:\n  model: test-model\n  driver:\n    artifact: murmur/driver-anthropic\n",
12202        ).unwrap();
12203        assert_eq!(manifest.inference.unwrap().max_turns, None);
12204    }
12205
12206    #[test]
12207    fn inference_max_turns_explicit_value() {
12208        let manifest = RuntimeManifest::from_yaml_str(
12209            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\ninference:\n  model: test-model\n  max_turns: 20\n  driver:\n    artifact: murmur/driver-anthropic\n",
12210        ).unwrap();
12211        assert_eq!(manifest.inference.unwrap().max_turns, Some(20));
12212    }
12213
12214    #[test]
12215    fn inference_max_turns_zero_is_rejected() {
12216        let err = RuntimeManifest::from_yaml_str(
12217            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\ninference:\n  model: test-model\n  max_turns: 0\n  driver:\n    artifact: murmur/driver-anthropic\n",
12218        ).unwrap_err();
12219        let msg = err.to_string();
12220        assert!(msg.contains("inference.max_turns"), "error was: {msg}");
12221        assert!(msg.contains("greater than 0"), "error was: {msg}");
12222    }
12223
12224    /// A `lifecycle:` block that says nothing about the budget still permits one reopen.
12225    #[test]
12226    fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
12227        let manifest = RuntimeManifest::from_yaml_str(
12228            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 45\n",
12229        )
12230        .unwrap();
12231        let lifecycle = manifest.lifecycle.unwrap();
12232        assert_eq!(lifecycle.shell_grace_secs, Some(45));
12233        // Every other lifecycle field is untouched by declaring this one.
12234        assert_eq!(
12235            lifecycle.task_acceptance,
12236            LifecycleConfig::default().task_acceptance
12237        );
12238        assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
12239        assert_eq!(
12240            lifecycle.queue_depth,
12241            LifecycleConfig::default().queue_depth
12242        );
12243        assert_eq!(lifecycle.input_timeout_secs, None);
12244        assert_eq!(
12245            lifecycle.conversation_mode,
12246            LifecycleConfig::default().conversation_mode
12247        );
12248        assert_eq!(
12249            lifecycle.max_task_reopens,
12250            LifecycleConfig::default().max_task_reopens
12251        );
12252    }
12253
12254    /// An undeclared grace is no grace at all, whether the `lifecycle:` block is there or not.
12255    #[test]
12256    fn lifecycle_shell_grace_secs_is_unset_when_not_declared() {
12257        let manifest = RuntimeManifest::from_yaml_str(
12258            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12259        )
12260        .unwrap();
12261        let lifecycle = manifest.lifecycle.unwrap();
12262        assert_eq!(lifecycle.shell_grace_secs, None);
12263        assert_eq!(lifecycle.queue_depth, 3);
12264
12265        let without_block =
12266            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12267        assert_eq!(without_block.effective_lifecycle().shell_grace_secs, None);
12268        assert_eq!(LifecycleConfig::default().shell_grace_secs, None);
12269    }
12270
12271    /// The bound a capsule delegates under is declared, and a capsule that declares nothing is
12272    /// still bounded: absent means a ceiling, never "wait forever".
12273    #[test]
12274    fn lifecycle_delegation_deadline_secs_defaults_to_600() {
12275        let without_block =
12276            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12277        assert_eq!(
12278            without_block.effective_lifecycle().delegation_deadline_secs,
12279            600
12280        );
12281
12282        let without_key = RuntimeManifest::from_yaml_str(
12283            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12284        )
12285        .unwrap();
12286        let lifecycle = without_key.lifecycle.unwrap();
12287        assert_eq!(lifecycle.delegation_deadline_secs, 600);
12288        assert_eq!(lifecycle.queue_depth, 3);
12289    }
12290
12291    #[test]
12292    fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
12293        let manifest = RuntimeManifest::from_yaml_str(
12294            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
12295             delegation_deadline_secs: 30\n",
12296        )
12297        .unwrap();
12298        let lifecycle = manifest.lifecycle.unwrap();
12299        assert_eq!(lifecycle.delegation_deadline_secs, 30);
12300        // Every other lifecycle field is untouched by declaring this one.
12301        assert_eq!(
12302            lifecycle.shell_grace_secs,
12303            LifecycleConfig::default().shell_grace_secs
12304        );
12305        assert_eq!(
12306            lifecycle.max_task_reopens,
12307            LifecycleConfig::default().max_task_reopens
12308        );
12309    }
12310
12311    /// Declaring the key produces no unknown-key report, and a misspelling of it still does.
12312    #[test]
12313    fn lifecycle_delegation_deadline_secs_is_a_known_key() {
12314        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
12315        let manifest = RuntimeManifest::from_yaml_str(
12316            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
12317             delegation_deadline_secs: 30\n  delegation_deadline: 30\n",
12318        )
12319        .unwrap();
12320        let reported: Vec<(String, String)> = manifest
12321            .unknown_keys
12322            .iter()
12323            .map(|key| (key.key.clone(), key.block_path.clone()))
12324            .collect();
12325        assert_eq!(
12326            reported,
12327            vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
12328            "the declared key is known and only the misspelt neighbour is reported"
12329        );
12330        assert_eq!(
12331            manifest.lifecycle.unwrap().delegation_deadline_secs,
12332            30,
12333            "the misspelt neighbour does not disturb the declared value"
12334        );
12335    }
12336
12337    /// The deadline notice's margin is optional: absent leaves the runtime's default in force.
12338    #[test]
12339    fn lifecycle_deadline_notice_secs_defaults_to_none() {
12340        let without_block =
12341            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12342        assert_eq!(
12343            without_block.effective_lifecycle().deadline_notice_secs,
12344            None
12345        );
12346        let without_key = RuntimeManifest::from_yaml_str(
12347            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12348        )
12349        .unwrap();
12350        assert_eq!(without_key.lifecycle.unwrap().deadline_notice_secs, None);
12351    }
12352
12353    /// `0` and a positive value are both taken as declared: `0` is "send no notice", not absent.
12354    #[test]
12355    fn lifecycle_deadline_notice_secs_takes_zero_and_positive_values() {
12356        for (declared, expected) in [("0", 0), ("90", 90)] {
12357            let manifest = RuntimeManifest::from_yaml_str(&format!(
12358                "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
12359                 deadline_notice_secs: {declared}\n"
12360            ))
12361            .unwrap();
12362            assert!(manifest.unknown_keys.is_empty());
12363            assert_eq!(
12364                manifest.lifecycle.unwrap().deadline_notice_secs,
12365                Some(expected)
12366            );
12367        }
12368    }
12369
12370    #[test]
12371    fn lifecycle_deadline_notice_secs_refuses_a_negative_or_non_integer_value() {
12372        for declared in ["-5", "1.5", "soon"] {
12373            let refused = RuntimeManifest::from_yaml_str(&format!(
12374                "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
12375                 deadline_notice_secs: {declared}\n"
12376            ));
12377            assert!(refused.is_err(), "{declared} is refused: {refused:?}");
12378        }
12379    }
12380
12381    #[test]
12382    fn lifecycle_deadline_notice_secs_is_a_known_key() {
12383        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"deadline_notice_secs"));
12384    }
12385
12386    fn lifecycle_with_keep_working(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
12387        RuntimeManifest::from_yaml_str(&format!(
12388            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  keep_working:\n{block}"
12389        ))
12390    }
12391
12392    fn refused_keep_working_field(block: &str) -> (String, String) {
12393        match lifecycle_with_keep_working(block) {
12394            Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
12395                (field, message)
12396            }
12397            other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
12398        }
12399    }
12400
12401    #[test]
12402    fn lifecycle_keep_working_is_off_when_absent() {
12403        let without_block =
12404            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12405        assert_eq!(without_block.effective_lifecycle().keep_working, None);
12406        let without_key = RuntimeManifest::from_yaml_str(
12407            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12408        )
12409        .unwrap();
12410        assert_eq!(without_key.lifecycle.unwrap().keep_working, None);
12411    }
12412
12413    #[test]
12414    fn lifecycle_keep_working_takes_the_declared_message_and_limit() {
12415        let manifest =
12416            lifecycle_with_keep_working("    message: \"go on\"\n    limit: 3\n").unwrap();
12417        assert!(
12418            manifest.unknown_keys.is_empty(),
12419            "{:?}",
12420            manifest.unknown_keys
12421        );
12422        assert_eq!(
12423            manifest.lifecycle.unwrap().keep_working,
12424            Some(KeepWorking {
12425                message: "go on".into(),
12426                limit: 3
12427            })
12428        );
12429    }
12430
12431    /// The blank check trims; the message the runtime sends is the operator's bytes as written.
12432    #[test]
12433    fn lifecycle_keep_working_keeps_the_message_as_written() {
12434        let manifest =
12435            lifecycle_with_keep_working("    message: \"  go on \\n\"\n    limit: 1\n").unwrap();
12436        assert_eq!(
12437            manifest.lifecycle.unwrap().keep_working.unwrap().message,
12438            "  go on \n"
12439        );
12440    }
12441
12442    #[test]
12443    fn lifecycle_keep_working_refuses_a_missing_or_blank_message() {
12444        for block in [
12445            "    limit: 3\n",
12446            "    message: \"\"\n    limit: 3\n",
12447            "    message: \"  \\n\\t \"\n    limit: 3\n",
12448        ] {
12449            let (field, _) = refused_keep_working_field(block);
12450            assert_eq!(field, "lifecycle.keep_working.message", "{block:?}");
12451        }
12452    }
12453
12454    #[test]
12455    fn lifecycle_keep_working_refuses_a_missing_limit() {
12456        let (field, message) = refused_keep_working_field("    message: go on\n");
12457        assert_eq!(field, "lifecycle.keep_working.limit");
12458        assert_eq!(message, "is required");
12459    }
12460
12461    #[test]
12462    fn lifecycle_keep_working_refuses_a_zero_limit_and_says_how_to_turn_it_off() {
12463        let (field, message) = refused_keep_working_field("    message: go on\n    limit: 0\n");
12464        assert_eq!(field, "lifecycle.keep_working.limit");
12465        assert!(
12466            message.contains("omit lifecycle.keep_working"),
12467            "the refusal says how to turn the option off: {message}"
12468        );
12469    }
12470
12471    #[test]
12472    fn lifecycle_keep_working_reports_an_unknown_key_inside_the_block() {
12473        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"keep_working"));
12474        let manifest =
12475            lifecycle_with_keep_working("    message: go on\n    limit: 2\n    lmit: 3\n").unwrap();
12476        let reported: Vec<(String, String)> = manifest
12477            .unknown_keys
12478            .iter()
12479            .map(|key| (key.key.clone(), key.block_path.clone()))
12480            .collect();
12481        assert_eq!(
12482            reported,
12483            vec![("lmit".to_string(), "lifecycle.keep_working".to_string())]
12484        );
12485    }
12486
12487    fn lifecycle_with_done_check(
12488        allow: &str,
12489        block: &str,
12490    ) -> Result<RuntimeManifest, RuntimeManifestError> {
12491        RuntimeManifest::from_yaml_str(&format!(
12492            "name: cap\nversion: 0.0.1\nartifacts: []\n{allow}lifecycle:\n  done_check:\n{block}"
12493        ))
12494    }
12495
12496    const ALLOW_BASH: &str = "capabilities:\n  shell:\n    allow: [bash]\n";
12497
12498    fn refused_done_check_field(allow: &str, block: &str) -> (String, String) {
12499        match lifecycle_with_done_check(allow, block) {
12500            Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
12501                (field, message)
12502            }
12503            other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
12504        }
12505    }
12506
12507    #[test]
12508    fn lifecycle_done_check_is_off_when_absent() {
12509        let without_block =
12510            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12511        assert_eq!(without_block.effective_lifecycle().done_check, None);
12512        let without_key = RuntimeManifest::from_yaml_str(
12513            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12514        )
12515        .unwrap();
12516        assert_eq!(without_key.lifecycle.unwrap().done_check, None);
12517    }
12518
12519    #[test]
12520    fn lifecycle_done_check_takes_the_command_and_limit_with_the_default_timeout() {
12521        let manifest = lifecycle_with_done_check(
12522            ALLOW_BASH,
12523            "    command: \"bash -c 'true'\"\n    limit: 2\n",
12524        )
12525        .unwrap();
12526        assert!(
12527            manifest.unknown_keys.is_empty(),
12528            "{:?}",
12529            manifest.unknown_keys
12530        );
12531        let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
12532        assert_eq!(
12533            done_check,
12534            DoneCheck {
12535                command: "bash -c 'true'".into(),
12536                limit: 2,
12537                timeout_secs: 600,
12538            }
12539        );
12540        assert_eq!(done_check.argv(), ["bash", "-c", "true"]);
12541    }
12542
12543    /// `0` observes without reopening; an explicit timeout is kept.
12544    #[test]
12545    fn lifecycle_done_check_accepts_a_zero_limit_and_an_explicit_timeout() {
12546        let manifest = lifecycle_with_done_check(
12547            ALLOW_BASH,
12548            "    command: bash -c 'exit 1'\n    limit: 0\n    timeout_secs: 5\n",
12549        )
12550        .unwrap();
12551        let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
12552        assert_eq!((done_check.limit, done_check.timeout_secs), (0, 5));
12553    }
12554
12555    #[test]
12556    fn lifecycle_done_check_refuses_each_invalid_field() {
12557        for (allow, block, field, message) in [
12558            (ALLOW_BASH, "    limit: 1\n", "command", "is required"),
12559            (
12560                ALLOW_BASH,
12561                "    command: \"  \"\n    limit: 1\n",
12562                "command",
12563                "must not be blank",
12564            ),
12565            (
12566                ALLOW_BASH,
12567                "    command: \"''\"\n    limit: 1\n",
12568                "command",
12569                "must name a program",
12570            ),
12571            (
12572                ALLOW_BASH,
12573                "    command: cargo test\n    limit: 1\n",
12574                "command",
12575                "runs 'cargo', which is not in capabilities.shell.allow",
12576            ),
12577            (
12578                "",
12579                "    command: bash -c true\n    limit: 1\n",
12580                "command",
12581                "runs 'bash', which is not in capabilities.shell.allow",
12582            ),
12583            (
12584                ALLOW_BASH,
12585                "    command: bash -c true\n",
12586                "limit",
12587                "is required",
12588            ),
12589            (
12590                ALLOW_BASH,
12591                "    command: bash -c true\n    limit: 1\n    timeout_secs: 0\n",
12592                "timeout_secs",
12593                "must be at least 1",
12594            ),
12595        ] {
12596            assert_eq!(
12597                refused_done_check_field(allow, block),
12598                (format!("lifecycle.done_check.{field}"), message.to_string()),
12599                "{block:?}"
12600            );
12601        }
12602    }
12603
12604    #[test]
12605    fn lifecycle_done_check_reports_an_unknown_key_inside_the_block() {
12606        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"done_check"));
12607        let manifest = lifecycle_with_done_check(
12608            ALLOW_BASH,
12609            "    command: bash -c true\n    limit: 2\n    limt: 3\n",
12610        )
12611        .unwrap();
12612        let reported: Vec<(String, String)> = manifest
12613            .unknown_keys
12614            .iter()
12615            .map(|key| (key.key.clone(), key.block_path.clone()))
12616            .collect();
12617        assert_eq!(
12618            reported,
12619            vec![("limt".to_string(), "lifecycle.done_check".to_string())]
12620        );
12621    }
12622
12623    /// `0` is a valid explicit value, not an absent one: it demotes at the first poll after the
12624    /// spawn, so effectively every command detaches.
12625    #[test]
12626    fn lifecycle_shell_grace_secs_zero_is_accepted() {
12627        let manifest = RuntimeManifest::from_yaml_str(
12628            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 0\n",
12629        )
12630        .unwrap();
12631        let lifecycle = manifest.lifecycle.unwrap();
12632        assert_eq!(lifecycle.shell_grace_secs, Some(0));
12633        assert_eq!(
12634            lifecycle.max_task_reopens,
12635            LifecycleConfig::default().max_task_reopens
12636        );
12637    }
12638
12639    #[test]
12640    fn lifecycle_max_task_reopens_defaults_to_1() {
12641        let manifest = RuntimeManifest::from_yaml_str(
12642            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  after_task: sleep\n",
12643        )
12644        .unwrap();
12645        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
12646    }
12647
12648    /// No `lifecycle:` block at all lands on the same default through `effective_lifecycle`.
12649    #[test]
12650    fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
12651        let manifest =
12652            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12653        assert!(manifest.lifecycle.is_none());
12654        assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
12655    }
12656
12657    #[test]
12658    fn lifecycle_max_task_reopens_explicit_value() {
12659        let manifest = RuntimeManifest::from_yaml_str(
12660            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 3\n",
12661        )
12662        .unwrap();
12663        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
12664    }
12665
12666    /// Unlike `inference.max_turns`, `0` is a valid explicit value — it disables reopening.
12667    #[test]
12668    fn lifecycle_max_task_reopens_zero_is_accepted() {
12669        let manifest = RuntimeManifest::from_yaml_str(
12670            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 0\n",
12671        )
12672        .unwrap();
12673        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
12674    }
12675
12676    /// The budget is independent of the inference transport in play.
12677    #[test]
12678    fn lifecycle_max_task_reopens_with_process_transport() {
12679        let manifest = RuntimeManifest::from_yaml_str(
12680            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/fixture-process-driver\n    version: 0.1.0\n    runtime: driver\ninference:\n  transport: process\n  driver:\n    artifact: local/fixture-process-driver\nlifecycle:\n  max_task_reopens: 2\n",
12681        ).unwrap();
12682        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
12683    }
12684
12685    #[test]
12686    fn inference_max_task_reopens_is_rejected_under_http_transport() {
12687        let err = RuntimeManifest::from_yaml_str(
12688            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\ninference:\n  model: test-model\n  max_task_reopens: 3\n  driver:\n    artifact: murmur/driver-anthropic\n",
12689        ).unwrap_err();
12690        assert!(
12691            matches!(
12692                &err,
12693                RuntimeManifestError::InvalidInferenceConfig { field, .. }
12694                    if field == "inference.max_task_reopens"
12695            ),
12696            "error was: {err:?}"
12697        );
12698        let msg = err.to_string();
12699        assert!(
12700            msg.contains("lifecycle.max_task_reopens"),
12701            "error was: {msg}"
12702        );
12703    }
12704
12705    #[test]
12706    fn inference_max_task_reopens_is_rejected_under_process_transport() {
12707        let err = RuntimeManifest::from_yaml_str(
12708            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  max_task_reopens: 2\n",
12709        ).unwrap_err();
12710        assert!(
12711            matches!(
12712                &err,
12713                RuntimeManifestError::InvalidInferenceConfig { field, .. }
12714                    if field == "inference.max_task_reopens"
12715            ),
12716            "error was: {err:?}"
12717        );
12718        let msg = err.to_string();
12719        assert!(
12720            msg.contains("lifecycle.max_task_reopens"),
12721            "error was: {msg}"
12722        );
12723    }
12724
12725    /// The rejection is unconditional: the new key being present alongside it changes nothing.
12726    #[test]
12727    fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
12728        let err = RuntimeManifest::from_yaml_str(
12729            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  max_task_reopens: 2\nlifecycle:\n  max_task_reopens: 3\n",
12730        ).unwrap_err();
12731        assert!(
12732            matches!(
12733                &err,
12734                RuntimeManifestError::InvalidInferenceConfig { field, .. }
12735                    if field == "inference.max_task_reopens"
12736            ),
12737            "error was: {err:?}"
12738        );
12739    }
12740
12741    #[test]
12742    fn skill_is_llm_visible() {
12743        assert!(ArtifactRuntime::Skill.is_llm_visible());
12744    }
12745
12746    #[test]
12747    fn driver_and_hook_are_not_llm_visible() {
12748        assert!(!ArtifactRuntime::Driver.is_llm_visible());
12749        assert!(!ArtifactRuntime::Hook.is_llm_visible());
12750    }
12751
12752    #[test]
12753    fn parses_system_prompt_artifact() {
12754        let manifest = RuntimeManifest::from_yaml_str(
12755            r#"
12756name: cap
12757version: 0.0.1
12758artifacts:
12759  - name: local/my-skill
12760    version: 1.0.0
12761    runtime: skill
12762  - name: murmur/driver-anthropic
12763    version: 0.1.0
12764    runtime: driver
12765    gateway:
12766      endpoint: http://127.0.0.1:8080
12767      keyless: true
12768inference:
12769  model: test-model
12770  system_prompt_artifact: local/my-skill
12771  driver:
12772    artifact: murmur/driver-anthropic
12773"#,
12774        )
12775        .unwrap();
12776
12777        let inference = manifest.inference.unwrap();
12778        assert_eq!(
12779            inference.system_prompt_artifact,
12780            Some("local/my-skill".to_string())
12781        );
12782        assert!(inference.system_prompt.is_none());
12783        assert!(inference.system_prompt_file.is_none());
12784    }
12785
12786    #[test]
12787    fn rejects_system_prompt_artifact_with_inline_prompt() {
12788        let err = RuntimeManifest::from_yaml_str(
12789            r#"
12790name: cap
12791version: 0.0.1
12792artifacts:
12793  - name: local/my-skill
12794    version: 1.0.0
12795    runtime: skill
12796  - name: murmur/driver-anthropic
12797    version: 0.1.0
12798    runtime: driver
12799    gateway:
12800      endpoint: http://127.0.0.1:8080
12801      keyless: true
12802inference:
12803  model: test-model
12804  system_prompt: "inline"
12805  system_prompt_artifact: local/my-skill
12806  driver:
12807    artifact: murmur/driver-anthropic
12808"#,
12809        )
12810        .unwrap_err();
12811
12812        let msg = err.to_string();
12813        assert!(msg.contains("at most one"), "error was: {msg}");
12814    }
12815
12816    #[test]
12817    fn rejects_system_prompt_artifact_naming_non_skill() {
12818        let err = RuntimeManifest::from_yaml_str(
12819            r#"
12820name: cap
12821version: 0.0.1
12822artifacts:
12823  - name: local/my-tool
12824    version: 1.0.0
12825    runtime: tool
12826  - name: murmur/driver-anthropic
12827    version: 0.1.0
12828    runtime: driver
12829    gateway:
12830      endpoint: http://127.0.0.1:8080
12831      keyless: true
12832inference:
12833  model: test-model
12834  system_prompt_artifact: local/my-tool
12835  driver:
12836    artifact: murmur/driver-anthropic
12837"#,
12838        )
12839        .unwrap_err();
12840
12841        let msg = err.to_string();
12842        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12843        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
12844    }
12845
12846    #[test]
12847    fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
12848        let err = RuntimeManifest::from_yaml_str(
12849            r#"
12850name: cap
12851version: 0.0.1
12852artifacts:
12853  - name: murmur/driver-anthropic
12854    version: 0.1.0
12855    runtime: driver
12856    gateway:
12857      endpoint: http://127.0.0.1:8080
12858      keyless: true
12859inference:
12860  model: test-model
12861  system_prompt_artifact: missing-skill
12862  driver:
12863    artifact: murmur/driver-anthropic
12864"#,
12865        )
12866        .unwrap_err();
12867
12868        let msg = err.to_string();
12869        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12870        assert!(msg.contains("not declared"), "error was: {msg}");
12871    }
12872
12873    #[test]
12874    fn rejects_all_three_system_prompt_sources() {
12875        let err = RuntimeManifest::from_yaml_str(
12876            r#"
12877name: cap
12878version: 0.0.1
12879artifacts:
12880  - name: local/my-skill
12881    version: 1.0.0
12882    runtime: skill
12883  - name: murmur/driver-anthropic
12884    version: 0.1.0
12885    runtime: driver
12886    gateway:
12887      endpoint: http://127.0.0.1:8080
12888      keyless: true
12889inference:
12890  model: test-model
12891  system_prompt: "inline"
12892  system_prompt_file: "conventions.md"
12893  system_prompt_artifact: local/my-skill
12894  driver:
12895    artifact: murmur/driver-anthropic
12896"#,
12897        )
12898        .unwrap_err();
12899
12900        let msg = err.to_string();
12901        assert!(msg.contains("at most one"), "error was: {msg}");
12902    }
12903
12904    // ── Per-artifact capability grants ───────────────────────────────────────
12905
12906    #[test]
12907    fn hook_artifact_capabilities_parse_network_and_filesystem() {
12908        let manifest = RuntimeManifest::from_yaml_str(
12909            r#"
12910name: cap
12911version: 0.0.1
12912artifacts:
12913  - name: local/telemetry-hook
12914    version: 1.0.0
12915    runtime: hook
12916    capabilities:
12917      network:
12918        allow:
12919          - https://telemetry.example.com
12920      filesystem:
12921        scope: hook-state
12922"#,
12923        )
12924        .unwrap();
12925
12926        let caps = manifest.artifacts[0]
12927            .capabilities
12928            .as_ref()
12929            .expect("hook entry carries a capability grant");
12930        assert_eq!(
12931            caps.network.as_ref().unwrap().allow,
12932            vec!["https://telemetry.example.com".to_string()]
12933        );
12934        assert_eq!(
12935            caps.filesystem.as_ref().unwrap().scope,
12936            Some("hook-state".to_string())
12937        );
12938    }
12939
12940    /// Default-deny: a hook entry with no `capabilities:` key parses to `None`, which the
12941    /// runtime lowers to zero network and zero preopened directories.
12942    #[test]
12943    fn hook_artifact_without_capabilities_is_none() {
12944        let manifest = RuntimeManifest::from_yaml_str(
12945            r#"
12946name: cap
12947version: 0.0.1
12948artifacts:
12949  - name: local/quiet-hook
12950    version: 1.0.0
12951    runtime: hook
12952"#,
12953        )
12954        .unwrap();
12955
12956        assert!(manifest.artifacts[0].capabilities.is_none());
12957    }
12958
12959    /// Tools and drivers execute, so they may carry a grant — the runtime reads it as a
12960    /// narrowing of the capsule-wide ceiling rather than as a default-deny baseline.
12961    #[test]
12962    fn tool_and_driver_artifact_capabilities_parse() {
12963        for role in ["tool", "driver"] {
12964            let yaml = format!(
12965                r#"
12966name: cap
12967version: 0.0.1
12968artifacts:
12969  - name: local/scoped
12970    version: 1.0.0
12971    runtime: {role}
12972    capabilities:
12973      network:
12974        allow:
12975          - https://api.example.com
12976      filesystem:
12977        scope: cache
12978"#
12979            );
12980            let manifest = RuntimeManifest::from_yaml_str(&yaml)
12981                .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
12982
12983            let caps = manifest.artifacts[0]
12984                .capabilities
12985                .as_ref()
12986                .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
12987            assert_eq!(
12988                caps.network.as_ref().unwrap().allow,
12989                vec!["https://api.example.com".to_string()]
12990            );
12991            assert_eq!(
12992                caps.filesystem.as_ref().unwrap().scope,
12993                Some("cache".to_string())
12994            );
12995        }
12996    }
12997
12998    /// A tool/driver entry with no `capabilities:` key parses to `None`, which the runtime
12999    /// lowers to the unchanged capsule ceiling.
13000    #[test]
13001    fn tool_artifact_without_capabilities_is_none() {
13002        let manifest = RuntimeManifest::from_yaml_str(
13003            r#"
13004name: cap
13005version: 0.0.1
13006artifacts:
13007  - name: local/plain-tool
13008    version: 1.0.0
13009    runtime: tool
13010"#,
13011        )
13012        .unwrap();
13013
13014        assert!(manifest.artifacts[0].capabilities.is_none());
13015    }
13016
13017    /// A skill has no execution surface, so a grant on one would be silently unenforced —
13018    /// rejected at parse time, with a message naming the roles that do work.
13019    #[test]
13020    fn skill_artifact_capabilities_are_rejected() {
13021        let yaml = r#"
13022name: cap
13023version: 0.0.1
13024artifacts:
13025  - name: local/sneaky
13026    version: 1.0.0
13027    runtime: skill
13028    capabilities:
13029      network:
13030        allow:
13031          - https://evil.example.com
13032"#;
13033        let err = match RuntimeManifest::from_yaml_str(yaml) {
13034            Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
13035            Err(err) => err,
13036        };
13037        let msg = err.to_string();
13038        assert!(msg.contains("sneaky"), "error was: {msg}");
13039        assert!(
13040            msg.contains(
13041                "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
13042                 entries"
13043            ),
13044            "error was: {msg}"
13045        );
13046        assert!(msg.contains("runtime: skill"), "error was: {msg}");
13047    }
13048
13049    // ── Per-artifact `config:` (operator-authored artifact configuration) ────
13050
13051    /// The block is carried verbatim as YAML. This crate accepts any mapping; the runtime is
13052    /// what lowers it to JSON and refuses a shape that cannot travel that way.
13053    #[test]
13054    fn artifact_config_is_carried_verbatim() {
13055        let manifest = RuntimeManifest::from_yaml_str(
13056            r#"
13057name: cap
13058version: 0.0.1
13059artifacts:
13060  - name: murmur/tool-corpus
13061    version: 0.1.0
13062    runtime: tool
13063    config:
13064      read_recent: { default: 20, max: 100 }
13065"#,
13066        )
13067        .unwrap();
13068
13069        let config = manifest.artifacts[0]
13070            .config
13071            .as_ref()
13072            .expect("a declared block parses");
13073        assert_eq!(
13074            config["read_recent"]["max"],
13075            serde_norway::Value::Number(100.into())
13076        );
13077    }
13078
13079    /// Absent means absent: no key, no variable, and nothing for the runtime to lower.
13080    #[test]
13081    fn an_undeclared_config_is_none() {
13082        let manifest = RuntimeManifest::from_yaml_str(
13083            r#"
13084name: cap
13085version: 0.0.1
13086artifacts:
13087  - name: local/git
13088    version: 1.0.0
13089    runtime: tool
13090"#,
13091        )
13092        .unwrap();
13093
13094        assert!(manifest.artifacts[0].config.is_none());
13095    }
13096
13097    /// `config:` with nothing under it is a written declaration that carries nothing, and must
13098    /// stay distinguishable from the key being absent — the runtime refuses it, and can only do
13099    /// so if the parser does not collapse YAML null into `None`.
13100    #[test]
13101    fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
13102        let manifest = RuntimeManifest::from_yaml_str(
13103            r#"
13104name: cap
13105version: 0.0.1
13106artifacts:
13107  - name: local/git
13108    version: 1.0.0
13109    runtime: tool
13110    config:
13111"#,
13112        )
13113        .unwrap();
13114
13115        assert_eq!(
13116            manifest.artifacts[0].config,
13117            Some(serde_norway::Value::Null),
13118            "an empty block must survive parsing as a declaration, not vanish into None"
13119        );
13120    }
13121
13122    /// A skill runs no component and holds no grant, so nothing would ever deliver a config
13123    /// block to one — rejected at parse time, with a message naming the roles that do.
13124    #[test]
13125    fn skill_artifact_config_is_rejected() {
13126        let yaml = r#"
13127name: cap
13128version: 0.0.1
13129artifacts:
13130  - name: local/sneaky
13131    version: 1.0.0
13132    runtime: skill
13133    config:
13134      who: me
13135"#;
13136        let err = match RuntimeManifest::from_yaml_str(yaml) {
13137            Ok(_) => panic!("config: must be rejected for runtime: skill"),
13138            Err(err) => err,
13139        };
13140        assert!(matches!(
13141            err,
13142            RuntimeManifestError::InvalidArtifact { index: 0, .. }
13143        ));
13144        let msg = err.to_string();
13145        assert!(msg.contains("sneaky"), "error was: {msg}");
13146        assert!(msg.contains("runtime: skill"), "error was: {msg}");
13147        assert!(
13148            msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
13149            "error was: {msg}"
13150        );
13151    }
13152
13153    /// Config is delivered on one artifact's own grant, so a capsule-wide block reaches nothing.
13154    /// Refused rather than silently ignored, and the message points at the artifact entry.
13155    #[test]
13156    fn a_capsule_wide_config_block_is_rejected() {
13157        let yaml = r#"
13158name: cap
13159version: 0.0.1
13160config:
13161  who: capsule
13162artifacts:
13163  - name: local/git
13164    version: 1.0.0
13165    runtime: tool
13166"#;
13167        let err = match RuntimeManifest::from_yaml_str(yaml) {
13168            Ok(_) => panic!("a capsule-wide config block must be rejected"),
13169            Err(err) => err,
13170        };
13171        let msg = err.to_string();
13172        assert!(msg.contains("config"), "error was: {msg}");
13173        assert!(msg.contains("declared per artifact"), "error was: {msg}");
13174    }
13175
13176    // ── Per-artifact `on_overflow:` (async hook queue policy) ────────────────
13177
13178    /// `on_overflow: block` on a hook entry parses to the blocking policy.
13179    #[test]
13180    fn hook_on_overflow_block_parses() {
13181        let manifest = RuntimeManifest::from_yaml_str(
13182            r#"
13183name: cap
13184version: 0.0.1
13185artifacts:
13186  - name: murmur/hook-grafana
13187    version: 1.0.0
13188    runtime: hook
13189    on_overflow: block
13190"#,
13191        )
13192        .unwrap();
13193
13194        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
13195    }
13196
13197    /// Omitting the key means `drop`: an operator who says nothing gets the loop kept moving,
13198    /// not backpressure they never asked for.
13199    #[test]
13200    fn hook_on_overflow_defaults_to_drop() {
13201        let manifest = RuntimeManifest::from_yaml_str(
13202            r#"
13203name: cap
13204version: 0.0.1
13205artifacts:
13206  - name: murmur/hook-grafana
13207    version: 1.0.0
13208    runtime: hook
13209"#,
13210        )
13211        .unwrap();
13212
13213        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
13214        assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
13215    }
13216
13217    /// Only a hook has a job queue, so the key is rejected on every other role rather than
13218    /// silently ignored — the same rule per-artifact `capabilities:` follows on a skill.
13219    #[test]
13220    fn on_overflow_on_a_non_hook_artifact_is_rejected() {
13221        let yaml = r#"
13222name: cap
13223version: 0.0.1
13224artifacts:
13225  - name: local/git
13226    version: 1.0.0
13227    runtime: tool
13228    on_overflow: block
13229"#;
13230        let err = match RuntimeManifest::from_yaml_str(yaml) {
13231            Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
13232            Err(err) => err,
13233        };
13234        assert!(matches!(
13235            err,
13236            RuntimeManifestError::InvalidArtifact { index: 0, .. }
13237        ));
13238        let msg = err.to_string();
13239        assert!(msg.contains("git"), "error was: {msg}");
13240        assert!(
13241            msg.contains("only recognized on 'runtime: hook' entries"),
13242            "error was: {msg}"
13243        );
13244    }
13245
13246    /// An unknown value is a typo, not a policy — rejected with both spellings named.
13247    #[test]
13248    fn unknown_on_overflow_value_is_rejected() {
13249        let yaml = r#"
13250name: cap
13251version: 0.0.1
13252artifacts:
13253  - name: murmur/hook-grafana
13254    version: 1.0.0
13255    runtime: hook
13256    on_overflow: spill
13257"#;
13258        let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
13259        let msg = err.to_string();
13260        assert!(msg.contains("spill"), "error was: {msg}");
13261        assert!(msg.contains("expected: drop, block"), "error was: {msg}");
13262    }
13263
13264    /// The key is legal on a *blocking* hook entry and simply inert: the operator manifest
13265    /// cannot know a hook's execution mode, which lives in the artifact's own manifest.
13266    #[test]
13267    fn on_overflow_is_accepted_on_any_hook_entry() {
13268        let manifest = RuntimeManifest::from_yaml_str(
13269            r#"
13270name: cap
13271version: 0.0.1
13272artifacts:
13273  - name: murmur/hook-eval
13274    version: 1.0.0
13275    runtime: hook
13276    on_overflow: drop
13277    capabilities:
13278      network:
13279        allow:
13280          - https://telemetry.example.com
13281"#,
13282        )
13283        .unwrap();
13284
13285        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
13286        assert!(manifest.artifacts[0].capabilities.is_some());
13287    }
13288
13289    /// The capsule-wide top-level block is untouched by the per-artifact field: both may be
13290    /// present in one manifest and neither shadows the other.
13291    #[test]
13292    fn capsule_wide_and_hook_capabilities_coexist() {
13293        let manifest = RuntimeManifest::from_yaml_str(
13294            r#"
13295name: cap
13296version: 0.0.1
13297artifacts:
13298  - name: local/telemetry-hook
13299    version: 1.0.0
13300    runtime: hook
13301    capabilities:
13302      network:
13303        allow:
13304          - https://telemetry.example.com
13305capabilities:
13306  network:
13307    allow:
13308      - https://api.anthropic.com
13309"#,
13310        )
13311        .unwrap();
13312
13313        assert_eq!(
13314            manifest.capabilities.unwrap().network.unwrap().allow,
13315            vec!["https://api.anthropic.com".to_string()]
13316        );
13317        assert_eq!(
13318            manifest.artifacts[0]
13319                .capabilities
13320                .as_ref()
13321                .unwrap()
13322                .network
13323                .as_ref()
13324                .unwrap()
13325                .allow,
13326            vec!["https://telemetry.example.com".to_string()]
13327        );
13328    }
13329
13330    /// No self-escalation: the hook artifact's *own* bundled murmur.yaml is parsed only by
13331    /// `parse_hook_config_from_yaml`, which has no capabilities field and no code path that
13332    /// reads one. A self-declared `capabilities:` block there is inert.
13333    #[test]
13334    fn hook_own_manifest_capabilities_are_not_read_into_config() {
13335        let config = parse_hook_config_from_yaml(
13336            r#"
13337name: telemetry-hook
13338version: 1.0.0
13339binding: on-inference
13340execution_mode: blocking
13341capabilities:
13342  network:
13343    allow:
13344      - https://evil.example.com
13345  filesystem:
13346    scope: /
13347"#,
13348        )
13349        .expect("a self-declared capabilities block is ignored, not an error");
13350
13351        assert_eq!(
13352            config,
13353            HookConfig {
13354                binding: HookBinding::OnInference,
13355                execution_mode: HookExecutionMode::Blocking,
13356                commit_policy: HookCommitPolicy::None,
13357            }
13358        );
13359    }
13360
13361    // ── Containment class ────────────────────────────────────────────────────
13362
13363    #[test]
13364    fn containment_classes_order_weakest_to_strongest() {
13365        assert!(ContainmentClass::Unconfined < ContainmentClass::Advisory);
13366        assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
13367        assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
13368        assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
13369        assert_eq!(
13370            ContainmentClass::ALL.iter().copied().max(),
13371            Some(ContainmentClass::Sealed)
13372        );
13373    }
13374
13375    #[test]
13376    fn containment_class_defaults_to_advisory() {
13377        assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
13378    }
13379
13380    #[test]
13381    fn containment_class_round_trips_through_string() {
13382        for class in ContainmentClass::ALL {
13383            assert_eq!(
13384                class.to_string().parse::<ContainmentClass>(),
13385                Ok(class),
13386                "{class} must survive Display -> FromStr"
13387            );
13388        }
13389        assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
13390        assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
13391        assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
13392        assert_eq!(ContainmentClass::Unconfined.as_str(), "unconfined");
13393        assert_eq!(ContainmentClass::Unconfined.to_string(), "unconfined");
13394        assert_eq!(
13395            serde_json::to_string(&ContainmentClass::Unconfined).unwrap(),
13396            "\"unconfined\""
13397        );
13398        assert_eq!(
13399            serde_norway::from_str::<ContainmentClass>("unconfined").unwrap(),
13400            ContainmentClass::Unconfined
13401        );
13402    }
13403
13404    #[test]
13405    fn containment_declarable_classes_exclude_unconfined() {
13406        use ContainmentClass::{Advisory, Scoped, Sealed, Unconfined};
13407        assert_eq!(ContainmentClass::DECLARABLE, [Advisory, Scoped, Sealed]);
13408        assert_eq!(
13409            ContainmentClass::ALL,
13410            [Unconfined, Advisory, Scoped, Sealed]
13411        );
13412        let inputs: Vec<Option<ContainmentClass>> = std::iter::once(None)
13413            .chain(ContainmentClass::DECLARABLE.map(Some))
13414            .collect();
13415        for &workspace in &inputs {
13416            for &manifest in &inputs {
13417                for &cli in &inputs {
13418                    assert_ne!(
13419                        effective_containment_floor(workspace, manifest, cli),
13420                        Unconfined
13421                    );
13422                }
13423            }
13424        }
13425    }
13426
13427    #[test]
13428    fn containment_class_rejects_unknown_names() {
13429        let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
13430        assert_eq!(err.value, "paranoid");
13431        assert_eq!(
13432            err.to_string(),
13433            "must be one of: advisory, scoped, sealed; got 'paranoid'"
13434        );
13435        // Case-sensitive by design: the wire form is lowercase everywhere.
13436        assert!("Sealed".parse::<ContainmentClass>().is_err());
13437    }
13438
13439    #[test]
13440    fn containment_class_serializes_as_its_wire_name() {
13441        assert_eq!(
13442            serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
13443            "\"scoped\""
13444        );
13445        assert_eq!(
13446            serde_norway::from_str::<ContainmentClass>("sealed").unwrap(),
13447            ContainmentClass::Sealed
13448        );
13449    }
13450
13451    #[test]
13452    fn effective_floor_is_advisory_when_nothing_is_declared() {
13453        assert_eq!(
13454            effective_containment_floor(None, None, None),
13455            ContainmentClass::Advisory
13456        );
13457    }
13458
13459    #[test]
13460    fn effective_floor_takes_the_strongest_across_every_presence_combination() {
13461        use ContainmentClass::{Advisory, Scoped, Sealed};
13462
13463        // Exactly one source present: that source wins outright.
13464        assert_eq!(
13465            effective_containment_floor(Some(Scoped), None, None),
13466            Scoped
13467        );
13468        assert_eq!(
13469            effective_containment_floor(None, Some(Scoped), None),
13470            Scoped
13471        );
13472        assert_eq!(
13473            effective_containment_floor(None, None, Some(Scoped)),
13474            Scoped
13475        );
13476        assert_eq!(
13477            effective_containment_floor(Some(Sealed), None, None),
13478            Sealed
13479        );
13480        assert_eq!(
13481            effective_containment_floor(None, Some(Sealed), None),
13482            Sealed
13483        );
13484        assert_eq!(
13485            effective_containment_floor(None, None, Some(Sealed)),
13486            Sealed
13487        );
13488
13489        // Two sources present: the stronger wins regardless of which slot holds it, and a
13490        // weaker source can never pull the stronger one down.
13491        assert_eq!(
13492            effective_containment_floor(Some(Advisory), Some(Sealed), None),
13493            Sealed
13494        );
13495        assert_eq!(
13496            effective_containment_floor(Some(Sealed), Some(Advisory), None),
13497            Sealed
13498        );
13499        assert_eq!(
13500            effective_containment_floor(Some(Advisory), None, Some(Scoped)),
13501            Scoped
13502        );
13503        assert_eq!(
13504            effective_containment_floor(Some(Scoped), None, Some(Advisory)),
13505            Scoped
13506        );
13507        assert_eq!(
13508            effective_containment_floor(None, Some(Advisory), Some(Scoped)),
13509            Scoped
13510        );
13511        assert_eq!(
13512            effective_containment_floor(None, Some(Scoped), Some(Advisory)),
13513            Scoped
13514        );
13515
13516        // All three present: max wins from every slot, and all-equal is a fixed point.
13517        assert_eq!(
13518            effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
13519            Sealed
13520        );
13521        assert_eq!(
13522            effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
13523            Sealed
13524        );
13525        assert_eq!(
13526            effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
13527            Sealed
13528        );
13529        assert_eq!(
13530            effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
13531            Scoped
13532        );
13533        assert_eq!(
13534            effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
13535            Advisory
13536        );
13537    }
13538
13539    #[test]
13540    fn manifest_parses_each_declared_containment_class() {
13541        for class in ContainmentClass::DECLARABLE {
13542            let manifest = RuntimeManifest::from_yaml_str(&format!(
13543                r#"
13544name: cap
13545version: 0.0.1
13546capabilities:
13547  containment: {class}
13548"#
13549            ))
13550            .expect("a valid containment class parses");
13551
13552            assert_eq!(
13553                manifest.capabilities.unwrap().containment,
13554                Some(class),
13555                "capabilities.containment: {class}"
13556            );
13557        }
13558    }
13559
13560    #[test]
13561    fn manifest_without_containment_key_declares_nothing() {
13562        // Absent `capabilities:` block entirely.
13563        let manifest = RuntimeManifest::from_yaml_str(
13564            r#"
13565name: cap
13566version: 0.0.1
13567"#,
13568        )
13569        .unwrap();
13570        assert!(manifest.capabilities.is_none());
13571
13572        // Present `capabilities:` block that simply omits the key: still None, never a
13573        // silently-defaulted Advisory floor stored as if the operator had written it.
13574        let manifest = RuntimeManifest::from_yaml_str(
13575            r#"
13576name: cap
13577version: 0.0.1
13578capabilities:
13579  network:
13580    allow:
13581      - https://api.example.com
13582"#,
13583        )
13584        .unwrap();
13585        assert_eq!(manifest.capabilities.unwrap().containment, None);
13586    }
13587
13588    #[test]
13589    fn manifest_rejects_an_unknown_containment_class() {
13590        let err = RuntimeManifest::from_yaml_str(
13591            r#"
13592name: cap
13593version: 0.0.1
13594capabilities:
13595  containment: paranoid
13596"#,
13597        )
13598        .expect_err("an unknown containment class is rejected at parse time");
13599
13600        match err {
13601            RuntimeManifestError::InvalidCapabilities { field, message } => {
13602                assert_eq!(field, "capabilities.containment");
13603                assert_eq!(
13604                    message,
13605                    "must be one of: advisory, scoped, sealed; got 'paranoid'"
13606                );
13607            }
13608            other => panic!("expected InvalidCapabilities, got {other:?}"),
13609        }
13610    }
13611
13612    #[test]
13613    fn manifest_refuses_an_unconfined_containment_class() {
13614        let err = RuntimeManifest::from_yaml_str(
13615            r#"
13616name: cap
13617version: 0.0.1
13618capabilities:
13619  containment: unconfined
13620"#,
13621        )
13622        .expect_err("a manifest cannot choose the unconfined stance");
13623
13624        match err {
13625            RuntimeManifestError::InvalidCapabilities { field, message } => {
13626                assert_eq!(field, "capabilities.containment");
13627                assert!(message.contains("mur run --unconfined"), "{message}");
13628                assert!(message.contains("~/.murmur/config.yaml"), "{message}");
13629            }
13630            other => panic!("expected InvalidCapabilities, got {other:?}"),
13631        }
13632    }
13633
13634    // ── exports.files ────────────────────────────────────────────────────────
13635
13636    fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
13637        RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
13638    }
13639
13640    fn export_error(block: &str) -> (String, String) {
13641        match exports_manifest(block).expect_err("this exports block must not parse") {
13642            RuntimeManifestError::InvalidExports { field, message } => (field, message),
13643            other => panic!("expected InvalidExports, got {other:?}"),
13644        }
13645    }
13646
13647    fn peer_fetch_error(block: &str) -> (String, String) {
13648        match exports_manifest(block).expect_err("this capabilities block must not parse") {
13649            RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
13650            other => panic!("expected InvalidCapabilities, got {other:?}"),
13651        }
13652    }
13653
13654    // ── exports.peer_files ───────────────────────────────────────────────────
13655
13656    #[test]
13657    fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
13658        assert_eq!(parse_duration_secs("90").unwrap(), 90);
13659        assert_eq!(parse_duration_secs("30s").unwrap(), 30);
13660        assert_eq!(parse_duration_secs("15m").unwrap(), 900);
13661        assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
13662        assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
13663        assert_eq!(parse_duration_secs("  2h  ").unwrap(), 7200);
13664        assert_eq!(parse_duration_secs("0").unwrap(), 0);
13665    }
13666
13667    /// Every spelling that is not the one accepted form, refused rather than guessed at: a
13668    /// manifest meaning 5 seconds and one meaning 5 minutes must not both parse.
13669    #[test]
13670    fn parse_duration_secs_rejects_every_other_spelling() {
13671        for input in [
13672            "",
13673            "  ",
13674            "5 minutes",
13675            "30S",
13676            "1H",
13677            "5min",
13678            "1.5h",
13679            "-1",
13680            "m",
13681            "h",
13682            "1h30m",
13683            "0x10",
13684            "30 s",
13685        ] {
13686            let error = parse_duration_secs(input)
13687                .expect_err(&format!("'{input}' must not parse as a duration"));
13688            assert!(
13689                error.contains(DURATION_ACCEPTED_FORM),
13690                "'{input}' must be refused with the accepted form; got: {error}"
13691            );
13692        }
13693    }
13694
13695    #[test]
13696    fn parse_duration_secs_refuses_to_overflow() {
13697        assert!(parse_duration_secs("18446744073709551615h")
13698            .unwrap_err()
13699            .contains("overflows"));
13700    }
13701
13702    #[test]
13703    fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
13704        let manifest = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
13705            .expect("a minimal peer_files block parses");
13706        let peer = manifest.exports.unwrap().peer_files.unwrap();
13707        assert_eq!(peer.root, "out/");
13708        assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
13709        assert_eq!(peer.max_bytes, 10_485_760);
13710        // Undeclared, not defaulted: the two ephemerality cases answer an absent value
13711        // differently, and substituting here would erase the difference.
13712        assert_eq!(peer.max_ttl_secs, None);
13713        assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
13714        assert_eq!(peer.effective_max_ttl_secs(), 3600);
13715    }
13716
13717    #[test]
13718    fn peer_files_accepts_every_duration_spelling() {
13719        for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
13720            let manifest = exports_manifest(&format!(
13721                "exports:\n  peer_files:\n    root: out/\n    max_ttl: {declared}\n"
13722            ))
13723            .expect("a declared max_ttl parses");
13724            let peer = manifest.exports.unwrap().peer_files.unwrap();
13725            assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
13726            assert_eq!(peer.effective_max_ttl_secs(), expected);
13727        }
13728    }
13729
13730    /// The two export blocks are separate authorisers: declaring one says nothing about the
13731    /// other, and both may be declared side by side over different subtrees.
13732    #[test]
13733    fn the_two_export_blocks_are_independent() {
13734        let both = exports_manifest(
13735            "exports:\n  files:\n    root: out/\n    mode: read-only\n  \
13736             peer_files:\n    root: out/handoff/\n",
13737        )
13738        .expect("both blocks parse together")
13739        .exports
13740        .unwrap();
13741        assert_eq!(both.files.unwrap().root, "out/");
13742        assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
13743
13744        let only_files =
13745            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
13746                .unwrap()
13747                .exports
13748                .unwrap();
13749        assert!(only_files.peer_files.is_none());
13750
13751        let only_peer = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
13752            .unwrap()
13753            .exports
13754            .unwrap();
13755        assert!(only_peer.files.is_none());
13756
13757        let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
13758        assert!(neither.files.is_none() && neither.peer_files.is_none());
13759    }
13760
13761    #[test]
13762    fn peer_files_root_must_be_relative_and_inside_the_workdir() {
13763        for root in ["../out", "/etc", "out/../../etc"] {
13764            let (field, message) =
13765                export_error(&format!("exports:\n  peer_files:\n    root: {root}\n"));
13766            assert_eq!(field, "exports.peer_files.root", "root: {root}");
13767            assert!(
13768                message.contains(EXPORT_ROOT_ACCEPTED_FORM),
13769                "root: {root}; got: {message}"
13770            );
13771        }
13772    }
13773
13774    #[test]
13775    fn peer_files_root_is_required() {
13776        let (field, message) = export_error("exports:\n  peer_files: {}\n");
13777        assert_eq!(field, "exports.peer_files.root");
13778        assert!(message.contains("is required"));
13779        assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
13780    }
13781
13782    #[test]
13783    fn peer_files_max_ttl_states_its_accepted_form() {
13784        for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
13785            let (field, message) = export_error(&format!(
13786                "exports:\n  peer_files:\n    root: out/\n    max_ttl: \"{declared}\"\n"
13787            ));
13788            assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
13789            assert!(
13790                message.contains(DURATION_ACCEPTED_FORM),
13791                "max_ttl: {declared}; got: {message}"
13792            );
13793        }
13794    }
13795
13796    /// A zero ceiling reads like a declared surface and refuses every mint. Better discovered at
13797    /// the line that wrote it than at the first `share-file` call.
13798    #[test]
13799    fn peer_files_max_ttl_rejects_zero() {
13800        let (field, message) =
13801            export_error("exports:\n  peer_files:\n    root: out/\n    max_ttl: 0\n");
13802        assert_eq!(field, "exports.peer_files.max_ttl");
13803        assert!(message.contains("greater than zero"));
13804    }
13805
13806    #[test]
13807    fn peer_files_max_bytes_states_its_accepted_form() {
13808        let (field, message) =
13809            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 10MB\n");
13810        assert_eq!(field, "exports.peer_files.max_bytes");
13811        assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
13812    }
13813
13814    #[test]
13815    fn peer_files_max_bytes_rejects_zero() {
13816        let (field, message) =
13817            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 0\n");
13818        assert_eq!(field, "exports.peer_files.max_bytes");
13819        assert!(message.contains("greater than zero"));
13820    }
13821
13822    // ── exports.peer_tasks ───────────────────────────────────────────────────
13823
13824    /// Absent and `accept: false` are the same posture; only an explicit `true` consents.
13825    #[test]
13826    fn peer_tasks_consents_only_on_an_explicit_true() {
13827        let absent = exports_manifest("artifacts: []\n").expect("no exports block parses");
13828        assert!(absent.exports.is_none());
13829        assert!(!absent.accepts_peer_tasks());
13830
13831        let refused = exports_manifest("exports:\n  peer_tasks:\n    accept: false\n")
13832            .expect("an explicit refusal parses");
13833        assert_eq!(
13834            refused.exports.as_ref().unwrap().peer_tasks,
13835            Some(PeerTasksExport { accept: false })
13836        );
13837        assert!(!refused.accepts_peer_tasks());
13838        assert!(!refused.exports.as_ref().unwrap().accepts_peer_tasks());
13839
13840        let accepted = exports_manifest("exports:\n  peer_tasks:\n    accept: true\n")
13841            .expect("consent parses");
13842        assert_eq!(
13843            accepted.exports.as_ref().unwrap().peer_tasks,
13844            Some(PeerTasksExport { accept: true })
13845        );
13846        assert!(accepted.accepts_peer_tasks());
13847        assert!(accepted.exports.as_ref().unwrap().accepts_peer_tasks());
13848    }
13849
13850    /// An `exports:` block declaring only `peer_tasks` opens neither file plane.
13851    #[test]
13852    fn peer_tasks_alone_declares_no_file_plane() {
13853        let manifest = exports_manifest("exports:\n  peer_tasks:\n    accept: true\n")
13854            .expect("a peer_tasks-only exports block parses");
13855        let exports = manifest.exports.unwrap();
13856        assert_eq!(exports.files, None);
13857        assert_eq!(exports.peer_files, None);
13858    }
13859
13860    /// A file export says nothing about peer tasks.
13861    #[test]
13862    fn a_file_export_does_not_consent_to_peer_tasks() {
13863        let manifest =
13864            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
13865                .expect("a files export parses");
13866        assert_eq!(manifest.exports.as_ref().unwrap().peer_tasks, None);
13867        assert!(!manifest.accepts_peer_tasks());
13868    }
13869
13870    /// Consent is never inferred: a `peer_tasks:` block without `accept:` is refused naming the
13871    /// key, as `capabilities.plan` without `submit:` is.
13872    #[test]
13873    fn peer_tasks_without_accept_is_refused() {
13874        let (field, message) = export_error("exports:\n  peer_tasks: {}\n");
13875        assert_eq!(field, "exports.peer_tasks.accept");
13876        assert_eq!(
13877            message,
13878            "must be set explicitly to true or false — consent to peer tasks is never inferred"
13879        );
13880    }
13881
13882    /// A value of the wrong type is refused at parse, and the refusal names where it is.
13883    #[test]
13884    fn peer_tasks_of_the_wrong_type_is_refused_naming_the_field() {
13885        for (block, field) in [
13886            (
13887                "exports:\n  peer_tasks:\n    accept: \"yes\"\n",
13888                "exports.peer_tasks.accept",
13889            ),
13890            (
13891                "exports:\n  peer_tasks:\n    accept: 1\n",
13892                "exports.peer_tasks.accept",
13893            ),
13894            ("exports:\n  peer_tasks: true\n", "exports.peer_tasks"),
13895            ("exports:\n  peer_tasks: [peer-a]\n", "exports.peer_tasks"),
13896        ] {
13897            let message = exports_manifest(block)
13898                .expect_err(&format!("{block:?} must not parse"))
13899                .to_string();
13900            assert!(message.contains(field), "{block:?}: {message}");
13901        }
13902    }
13903
13904    /// An unknown key under the block is a warning reported by path, not a refusal — and naming
13905    /// a peer there grants nothing.
13906    #[test]
13907    fn an_unknown_peer_tasks_key_is_reported_by_path() {
13908        let manifest =
13909            exports_manifest("exports:\n  peer_tasks:\n    accept: false\n    allow: [peer-a]\n")
13910                .expect("an unknown key is a warning, not a parse error");
13911        assert!(
13912            manifest
13913                .unknown_keys
13914                .iter()
13915                .any(|key| key.block_path == "exports.peer_tasks" && key.key == "allow"),
13916            "{:?}",
13917            manifest.unknown_keys
13918        );
13919        assert!(!manifest.accepts_peer_tasks());
13920    }
13921
13922    // ── capabilities.peer_fetch ──────────────────────────────────────────────
13923
13924    #[test]
13925    fn peer_fetch_allow_carries_its_declared_destinations() {
13926        let manifest = exports_manifest(
13927            "capabilities:\n  peer_fetch:\n    allow:\n      - localhost:41234\n      \
13928             - reporting.internal\n      - https://gateway.example.com\n",
13929        )
13930        .expect("a peer_fetch block parses");
13931        assert_eq!(
13932            manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
13933            vec![
13934                "localhost:41234".to_string(),
13935                "reporting.internal".to_string(),
13936                "https://gateway.example.com".to_string(),
13937            ]
13938        );
13939    }
13940
13941    /// An empty list is a parse error rather than a silent deny: `allow: []` reads as a declared
13942    /// grant and would otherwise refuse every fetch with no line to point at.
13943    #[test]
13944    fn peer_fetch_allow_must_not_be_empty() {
13945        let (field, message) = peer_fetch_error("capabilities:\n  peer_fetch:\n    allow: []\n");
13946        assert_eq!(field, "capabilities.peer_fetch.allow");
13947        assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
13948    }
13949
13950    #[test]
13951    fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
13952        for entry in [
13953            "http://[not a host",
13954            "\"\"",
13955            "localhost:notaport",
13956            "http://example.com/path",
13957            "ftp://example.com",
13958        ] {
13959            let (field, message) = peer_fetch_error(&format!(
13960                "capabilities:\n  peer_fetch:\n    allow:\n      - {entry}\n"
13961            ));
13962            assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
13963            assert!(
13964                message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
13965                "entry: {entry}; got: {message}"
13966            );
13967        }
13968    }
13969
13970    /// `peer_fetch` is a separate authoriser and never an alias: declaring it leaves
13971    /// `network.allow` exactly as the manifest wrote it, and vice versa.
13972    #[test]
13973    fn peer_fetch_does_not_widen_network_allow() {
13974        let capabilities = exports_manifest(
13975            "capabilities:\n  network:\n    allow:\n      - api.example.com\n  \
13976             peer_fetch:\n    allow:\n      - localhost:41234\n",
13977        )
13978        .unwrap()
13979        .capabilities
13980        .unwrap();
13981        assert_eq!(
13982            capabilities.network.unwrap().allow,
13983            vec!["api.example.com".to_string()]
13984        );
13985        assert_eq!(
13986            capabilities.peer_fetch.unwrap().allow,
13987            vec!["localhost:41234".to_string()]
13988        );
13989    }
13990
13991    #[test]
13992    fn an_absent_peer_fetch_block_is_absent() {
13993        assert!(exports_manifest("capabilities: {}\n")
13994            .unwrap()
13995            .capabilities
13996            .unwrap()
13997            .peer_fetch
13998            .is_none());
13999    }
14000
14001    #[test]
14002    fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
14003        assert_eq!(parse_byte_size("0").unwrap(), 0);
14004        assert_eq!(parse_byte_size("4096").unwrap(), 4096);
14005        assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
14006        assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
14007        assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
14008        assert_eq!(parse_byte_size("  10Mi  ").unwrap(), 10 * 1024 * 1024);
14009    }
14010
14011    /// Decimal suffixes are refused rather than guessed at: a manifest meaning 10 000 000 and one
14012    /// meaning 10 485 760 must not both parse.
14013    #[test]
14014    fn parse_byte_size_rejects_every_other_spelling() {
14015        for input in [
14016            "", "  ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
14017            "1Ki1",
14018        ] {
14019            let error = parse_byte_size(input)
14020                .expect_err(&format!("'{input}' must not parse as a byte size"));
14021            assert!(
14022                error.contains(BYTE_SIZE_ACCEPTED_FORM),
14023                "'{input}' must be refused with the accepted form; got: {error}"
14024            );
14025        }
14026    }
14027
14028    #[test]
14029    fn parse_byte_size_refuses_to_overflow() {
14030        assert!(parse_byte_size("18446744073709551615Gi")
14031            .unwrap_err()
14032            .contains("overflows"));
14033    }
14034
14035    #[test]
14036    fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
14037        let manifest =
14038            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
14039                .expect("a minimal exports block parses");
14040        let files = manifest.exports.unwrap().files.unwrap();
14041        assert_eq!(files.root, "out/");
14042        assert_eq!(files.mode, ExportMode::ReadOnly);
14043        assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
14044        assert_eq!(files.max_bytes, 10_485_760);
14045    }
14046
14047    #[test]
14048    fn exports_files_accepts_both_byte_spellings() {
14049        for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
14050            let manifest = exports_manifest(&format!(
14051                "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: {declared}\n"
14052            ))
14053            .expect("a declared max_bytes parses");
14054            assert_eq!(
14055                manifest.exports.unwrap().files.unwrap().max_bytes,
14056                expected,
14057                "max_bytes: {declared}"
14058            );
14059        }
14060    }
14061
14062    /// An `exports:` block with no `files:` is not a resource plane. It parses, and the plane
14063    /// stays undeclared — which is the deny case, not a defaulted-open one.
14064    #[test]
14065    fn an_exports_block_without_files_declares_no_plane() {
14066        let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
14067        assert!(manifest.exports.unwrap().files.is_none());
14068    }
14069
14070    #[test]
14071    fn an_absent_exports_block_is_absent() {
14072        let manifest = exports_manifest("").expect("a manifest without exports parses");
14073        assert!(manifest.exports.is_none());
14074    }
14075
14076    #[test]
14077    fn exports_files_root_must_be_relative_and_inside_the_workdir() {
14078        for root in ["../out", "/etc", "out/../../etc", "''"] {
14079            let (field, message) = export_error(&format!(
14080                "exports:\n  files:\n    root: {root}\n    mode: read-only\n"
14081            ));
14082            assert_eq!(field, "exports.files.root", "root: {root}");
14083            assert!(
14084                message.contains("must be a relative path inside the workdir"),
14085                "root: {root}; got: {message}"
14086            );
14087        }
14088    }
14089
14090    #[test]
14091    fn exports_files_root_is_required() {
14092        let (field, message) = export_error("exports:\n  files:\n    mode: read-only\n");
14093        assert_eq!(field, "exports.files.root");
14094        assert_eq!(
14095            message,
14096            "is required and must be a relative path inside the workdir"
14097        );
14098    }
14099
14100    #[test]
14101    fn exports_files_mode_is_required_and_read_only() {
14102        let (field, message) = export_error("exports:\n  files:\n    root: out/\n");
14103        assert_eq!(field, "exports.files.mode");
14104        assert_eq!(message, "is required and must be 'read-only'");
14105
14106        let (field, message) =
14107            export_error("exports:\n  files:\n    root: out/\n    mode: read-write\n");
14108        assert_eq!(field, "exports.files.mode");
14109        assert_eq!(message, "'read-write' must be 'read-only'");
14110    }
14111
14112    #[test]
14113    fn exports_files_max_bytes_states_its_accepted_form() {
14114        let (field, message) = export_error(
14115            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 10MB\n",
14116        );
14117        assert_eq!(field, "exports.files.max_bytes");
14118        assert_eq!(
14119            message,
14120            "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
14121        );
14122    }
14123
14124    /// A zero ceiling would refuse every read while reading like a declared export, so it is
14125    /// refused where it is written rather than at the first request.
14126    #[test]
14127    fn exports_files_max_bytes_rejects_zero() {
14128        let (field, message) = export_error(
14129            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 0\n",
14130        );
14131        assert_eq!(field, "exports.files.max_bytes");
14132        assert!(message.contains("must be greater than zero"), "{message}");
14133    }
14134
14135    /// A leading `./` is a relative path like any other and is not an escape.
14136    #[test]
14137    fn exports_files_root_accepts_a_current_dir_prefix() {
14138        let manifest =
14139            exports_manifest("exports:\n  files:\n    root: ./out\n    mode: read-only\n")
14140                .expect("./out is a legal root");
14141        assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
14142    }
14143
14144    // ── Unrecognized manifest keys (W-SEC-019) ────────────────────────────────
14145
14146    fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
14147        RuntimeManifest::from_yaml_str(yaml)
14148            .expect("fixture must parse")
14149            .unknown_keys
14150            .into_iter()
14151            .map(|key| (key.key, key.block_path, key.nearest_known))
14152            .collect()
14153    }
14154
14155    /// The typo the whole warning exists for: a hyphen where the key takes an underscore, named
14156    /// with the block that held it and with the key it should have been.
14157    #[test]
14158    fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
14159        assert_eq!(
14160            unknown_keys_of(
14161                "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    read-only:\n      - tests\n"
14162            ),
14163            vec![(
14164                "read-only".to_string(),
14165                "capabilities.filesystem".to_string(),
14166                Some("read_only".to_string())
14167            )]
14168        );
14169    }
14170
14171    /// A manifest exercising every layer the walk descends through — capsule-wide capabilities,
14172    /// a per-artifact block, shell, network and an artifact entry — reports nothing. A warning
14173    /// that fires on correct input is noise, and noise is ignored.
14174    #[test]
14175    fn a_manifest_of_recognized_keys_reports_nothing() {
14176        assert!(unknown_keys_of(
14177            r#"
14178name: cap
14179version: 0.1.0
14180deploy:
14181  mur_version: "0.2.0"
14182artifacts:
14183  - name: local/notes-tool
14184    version: 0.1.0
14185    runtime: tool
14186    capabilities:
14187      filesystem:
14188        scope: notes
14189      network:
14190        allow:
14191          - example.com:443
14192capabilities:
14193  filesystem:
14194    read_only:
14195      - tests
14196      - bench/fixtures
14197    workdir_exec: false
14198  shell:
14199    allow:
14200      - git
14201      - python3
14202  network:
14203    allow:
14204      - example.com:443
14205    unix_sockets: false
14206"#
14207        )
14208        .is_empty());
14209    }
14210
14211    /// A per-artifact capability block reports under its artifact's index, so an operator is not
14212    /// sent to the capsule-wide block for a key they wrote on one entry.
14213    #[test]
14214    fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
14215        assert_eq!(
14216            unknown_keys_of(
14217                "name: cap\nversion: 0.1.0\nartifacts:\n  - name: local/t\n    version: 0.1.0\n    \
14218                 runtime: tool\n    capabilities:\n      shell:\n        allow:\n          - git\n        \
14219                 allwo:\n          - git\n"
14220            ),
14221            vec![(
14222                "allwo".to_string(),
14223                "artifacts[0].capabilities.shell".to_string(),
14224                Some("allow".to_string())
14225            )]
14226        );
14227    }
14228
14229    /// A top-level key with no near neighbour reports an empty path and no suggestion — the two
14230    /// facts the emitter needs to word it as a possibly-newer key rather than a misspelling.
14231    #[test]
14232    fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
14233        assert_eq!(
14234            unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
14235            vec![("quantum_teleport".to_string(), String::new(), None)]
14236        );
14237    }
14238
14239    /// The minimum publishable delegated sub-capsule — `runtime:` plus `execution:` — reports
14240    /// nothing. Every registrant of a delegating capsule must be published, so every one of them
14241    /// carries these keys; warning on them would make the diagnostic unreadable exactly where it
14242    /// is needed most.
14243    #[test]
14244    fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
14245        assert!(unknown_keys_of(
14246            "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
14247        )
14248        .is_empty());
14249    }
14250
14251    /// All four keys `mur build` and `mur publish` read beyond `name:` and `version:`, together.
14252    #[test]
14253    fn every_build_side_key_is_recognized() {
14254        assert!(unknown_keys_of(
14255            "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
14256             wasm\nrequires_files:\n  - tool.wasm\n  - assets/logo.png\n"
14257        )
14258        .is_empty());
14259    }
14260
14261    /// `W-SEC-019` still covers every key no command reads: one sitting beside the four
14262    /// build-side keys reports, and reports alone.
14263    #[test]
14264    fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
14265        assert_eq!(
14266            unknown_keys_of(
14267                "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
14268                 static\nrequires_files:\n  - capsule.wasm\nquantum_teleport: true\n"
14269            ),
14270            vec![("quantum_teleport".to_string(), String::new(), None)]
14271        );
14272    }
14273
14274    /// The four keys are recognized and then dropped: they add no refusal and no run-time meaning,
14275    /// so a manifest carrying them parses to what it parses to without them. A wrong-shaped value
14276    /// is build's business to reject, not the run path's.
14277    #[test]
14278    fn the_build_side_keys_change_nothing_the_run_path_reads() {
14279        let body = "artifacts:\n  - name: local/notes-tool\n    version: 0.1.0\n    runtime: tool\n\
14280                    \x20 - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: \
14281                    driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\n\
14282                    capabilities:\n  filesystem:\n    read_only:\n      - tests\n\
14283                    inference:\n  transport: http\n  model: test-model\n  driver:\n    \
14284                    artifact: murmur/driver-anthropic\n";
14285        let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
14286            .expect("fixture must parse");
14287        let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
14288            "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n  \
14289             any: shape\nrequires_files: not-a-list\n{body}"
14290        ))
14291        .expect("a build-side key of any shape never refuses a manifest");
14292
14293        assert!(with_build_keys.unknown_keys.is_empty());
14294        assert!(
14295            !plain.artifacts.is_empty()
14296                && plain.capabilities.is_some()
14297                && plain.inference.is_some(),
14298            "the fixture must populate the blocks the comparison below rests on, or it \
14299             compares absence against absence"
14300        );
14301        assert_eq!(with_build_keys.name, plain.name);
14302        assert_eq!(with_build_keys.version, plain.version);
14303        assert_eq!(with_build_keys.artifacts, plain.artifacts);
14304        assert_eq!(with_build_keys.capabilities, plain.capabilities);
14305        assert_eq!(with_build_keys.inference, plain.inference);
14306    }
14307
14308    /// The overflow map captures the key without consuming the block: the rest of it still parses
14309    /// and still applies. A capsule with an unrecognized key launches.
14310    #[test]
14311    fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
14312        let manifest = RuntimeManifest::from_yaml_str(
14313            "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    scope: notes\n    \
14314             read-only:\n      - tests\n",
14315        )
14316        .expect("an unrecognized key never refuses a manifest");
14317        assert_eq!(
14318            manifest
14319                .capabilities
14320                .as_ref()
14321                .and_then(|caps| caps.filesystem.as_ref())
14322                .and_then(|fs| fs.scope.as_deref()),
14323            Some("notes")
14324        );
14325        assert_eq!(manifest.unknown_keys.len(), 1);
14326    }
14327
14328    /// Every block on the way down is walked, not just the capability sub-blocks.
14329    #[test]
14330    fn nested_blocks_outside_capabilities_are_walked_too() {
14331        let keys = unknown_keys_of(
14332            "name: cap\nversion: 0.1.0\ncontext:\n  max_tokns: 100\nexports:\n  files:\n    \
14333             root: out\n    mode: read-only\n    max_byts: 10\n",
14334        );
14335        assert_eq!(
14336            keys,
14337            vec![
14338                (
14339                    "max_tokns".to_string(),
14340                    "context".to_string(),
14341                    Some("max_tokens".to_string())
14342                ),
14343                (
14344                    "max_byts".to_string(),
14345                    "exports.files".to_string(),
14346                    Some("max_bytes".to_string())
14347                ),
14348            ]
14349        );
14350    }
14351
14352    /// Source-derived, so a `Raw*` struct added later cannot skip the overflow map, and a field
14353    /// added to one cannot skip its `KNOWN_KEYS`: the suggester would then never propose it.
14354    ///
14355    /// Reads this file's own text rather than a hand-written list for the same reason the
14356    /// `CapabilityPolicy` coverage test in `capsule-runtime` does — a list maintained by hand
14357    /// records what someone remembered, not what the code says.
14358    #[test]
14359    fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
14360        let source = include_str!("runtime_manifest.rs");
14361        let lines: Vec<&str> = source.lines().collect();
14362
14363        let mut checked = 0usize;
14364        for (start, line) in lines.iter().enumerate() {
14365            let Some(name) = line
14366                .strip_prefix("struct Raw")
14367                .and_then(|rest| rest.strip_suffix(" {"))
14368            else {
14369                continue;
14370            };
14371            let name = format!("Raw{name}");
14372            let end = (start + 1..lines.len())
14373                .find(|index| lines[*index] == "}")
14374                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
14375            let body = &lines[start + 1..end];
14376
14377            assert!(
14378                body.contains(&"    #[serde(flatten)]")
14379                    && body.contains(&"    unknown: UnknownKeys,"),
14380                "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
14381                 so a key it does not recognize would be dropped instead of reported"
14382            );
14383
14384            let declared = serde_field_names(body);
14385            let known = known_keys_of(source, &name);
14386            assert_eq!(
14387                declared, known,
14388                "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
14389                 suggester would propose the wrong set for that block"
14390            );
14391            checked += 1;
14392        }
14393        assert!(
14394            checked >= 30,
14395            "expected every Raw* struct to be scanned, found only {checked}"
14396        );
14397    }
14398
14399    /// Source-derived for the same reason its sibling above is: a `Raw*`-typed field added to a
14400    /// block and left unwalked reports none of the keys inside that block, and reports nothing
14401    /// about having done so. The scan is keyed on the *parent's field*, not on the child's type,
14402    /// because one type can sit under several parents — `RawInferenceDriver` is both
14403    /// `inference.driver` and each alternate's `driver` — and a check keyed on the type would call
14404    /// one covered because the other is walked.
14405    #[test]
14406    fn every_raw_struct_descends_into_the_blocks_it_owns() {
14407        let source = include_str!("runtime_manifest.rs");
14408        let lines: Vec<&str> = source.lines().collect();
14409
14410        let mut structs = 0usize;
14411        let mut children = 0usize;
14412        for (start, line) in lines.iter().enumerate() {
14413            let Some(name) = line
14414                .strip_prefix("struct Raw")
14415                .and_then(|rest| rest.strip_suffix(" {"))
14416            else {
14417                continue;
14418            };
14419            let name = format!("Raw{name}");
14420            let end = (start + 1..lines.len())
14421                .find(|index| lines[*index] == "}")
14422                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
14423            let owned = block_typed_fields(&lines[start + 1..end]);
14424            structs += 1;
14425
14426            if owned.is_empty() {
14427                continue;
14428            }
14429            let block = raw_block_impl_of(source, &name);
14430            assert!(
14431                block.contains("fn walk_children"),
14432                "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
14433                 `walk_children`, so every key written inside {} would go unreported",
14434                if owned.len() == 1 { "" } else { "s" },
14435                owned.join(", "),
14436                if owned.len() == 1 { "it" } else { "them" }
14437            );
14438            for field in &owned {
14439                assert!(
14440                    mentions_field(&block, field),
14441                    "{name}'s `walk_children` never touches `self.{field}`, so every key written \
14442                     inside that block would go unreported — descend into it with \
14443                     `collect_block(.., &child_path(path, \"{field}\"), out)`"
14444                );
14445                children += 1;
14446            }
14447        }
14448        assert!(
14449            structs >= 30,
14450            "expected every Raw* struct to be scanned, found only {structs}"
14451        );
14452        assert!(
14453            children >= 31,
14454            "expected every nested block field to be scanned, found only {children}"
14455        );
14456    }
14457
14458    /// Whether `impl` reads `self.<field>` as that whole field, not as the head of a longer one:
14459    /// a plain substring test lets a struct owning both `foo` and `foo_bar` walk only `foo_bar`
14460    /// and still be called covered, which is the silent pass this scan exists to deny.
14461    fn mentions_field(impl_body: &str, field: &str) -> bool {
14462        let needle = format!("self.{field}");
14463        impl_body.match_indices(&needle).any(|(at, _)| {
14464            impl_body[at + needle.len()..]
14465                .chars()
14466                .next()
14467                .is_none_or(|next| !next.is_alphanumeric() && next != '_')
14468        })
14469    }
14470
14471    /// The fields of one struct body whose declared type names another `Raw*` type, in
14472    /// declaration order. Comment and attribute lines are skipped, so a doc comment that mentions
14473    /// a `Raw*` type by name is not read as a field.
14474    fn block_typed_fields(body: &[&str]) -> Vec<String> {
14475        let mut fields = Vec::new();
14476        for line in body {
14477            let trimmed = line.trim();
14478            if trimmed.starts_with("//") || trimmed.starts_with("#[") {
14479                continue;
14480            }
14481            let Some((field, declared)) = line
14482                .strip_prefix("    ")
14483                .and_then(|rest| rest.split_once(':'))
14484            else {
14485                continue;
14486            };
14487            if field.is_empty()
14488                || !field
14489                    .chars()
14490                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
14491            {
14492                continue;
14493            }
14494            if declared.contains("Raw") {
14495                fields.push(field.to_string());
14496            }
14497        }
14498        fields
14499    }
14500
14501    /// The text of `impl RawBlock for <name>`, cut at the next line that is exactly `}` at column
14502    /// 0 so one impl's body cannot be read as the next one's.
14503    fn raw_block_impl_of(source: &str, name: &str) -> String {
14504        let header = format!("impl RawBlock for {name} {{");
14505        let body = source
14506            .split_once(&header)
14507            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
14508            .1;
14509        body.split_once("\n}\n")
14510            .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
14511            .0
14512            .to_string()
14513    }
14514
14515    /// The walk's own result, taken straight off a deserialized `RawRuntimeManifest`.
14516    ///
14517    /// Deliberately not routed through [`RuntimeManifest::from_yaml_str`]: that validates, and a
14518    /// fixture that names every block at once would have to satisfy every semantic rule as well
14519    /// to reach the walk. What is under test here is which blocks are descended into, which the
14520    /// validator has no part in.
14521    fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
14522        let raw: RawRuntimeManifest = serde_norway::from_str(yaml).expect("fixture must parse");
14523        collect_unknown_keys(&raw)
14524            .into_iter()
14525            .map(|key| (key.key, key.block_path))
14526            .collect()
14527    }
14528
14529    #[test]
14530    fn spawn_workdir_snapshot_is_a_known_key_beside_a_sibling_probe() {
14531        let keys = raw_unknown_keys(
14532            "name: cap\nversion: 0.1.0\ncapabilities:\n  spawn:\n    allow: [local/r]\n    \
14533             workdir_snapshot: [local/r]\n    probe_spawn: 1\n",
14534        );
14535        assert_eq!(
14536            keys,
14537            vec![("probe_spawn".to_string(), "capabilities.spawn".to_string())]
14538        );
14539    }
14540
14541    /// Every block the manifest type owns, each carrying one key no field of it claims, reported
14542    /// once and at its own dotted path. A block added after this one is held to the same rule by
14543    /// `every_raw_struct_descends_into_the_blocks_it_owns`; this is what "descended into" means.
14544    #[test]
14545    fn every_block_reports_its_own_unknown_key_at_its_own_path() {
14546        let keys = raw_unknown_keys(
14547            r#"
14548name: every-block
14549version: 0.1.0
14550probe_root: 1
14551artifacts:
14552  - name: local/t
14553    version: 0.1.0
14554    runtime: tool
14555    probe_artifact: 1
14556    capabilities:
14557      probe_artifact_capabilities: 1
14558      shell:
14559        probe_artifact_shell: 1
14560capabilities:
14561  probe_capabilities: 1
14562  network:
14563    probe_network: 1
14564  peer_fetch:
14565    probe_peer_fetch: 1
14566  filesystem:
14567    probe_filesystem: 1
14568  shell:
14569    probe_shell: 1
14570    interpreter_runtime:
14571      - binary: python3
14572        probe_interpreter_runtime: 1
14573        dirs:
14574          - path: /usr/lib/python3
14575            probe_interpreter_runtime_dir: 1
14576    staged_runtime:
14577      - binary: rg
14578        probe_staged_runtime: 1
14579  spawn:
14580    probe_spawn: 1
14581  env:
14582    probe_env: 1
14583  limits:
14584    probe_limits: 1
14585  resources:
14586    probe_resources: 1
14587  state:
14588    probe_state: 1
14589  task_io:
14590    probe_task_io: 1
14591  conversation:
14592    probe_conversation: 1
14593  install:
14594    probe_install: 1
14595inference:
14596  probe_inference: 1
14597  driver:
14598    probe_driver: 1
14599  compaction:
14600    probe_compaction: 1
14601context:
14602  probe_context: 1
14603observability:
14604  probe_observability: 1
14605  eval:
14606    probe_eval: 1
14607    scorers:
14608      - type: exact_match
14609        probe_scorer: 1
14610trace:
14611  probe_trace: 1
14612network:
14613  probe_network_config: 1
14614lifecycle:
14615  probe_lifecycle: 1
14616exports:
14617  probe_exports: 1
14618  files:
14619    probe_files: 1
14620  peer_files:
14621    probe_peer_files: 1
14622control:
14623  probe_control: 1
14624"#,
14625        );
14626
14627        let expected: Vec<(String, String)> = [
14628            ("probe_root", ""),
14629            ("probe_artifact", "artifacts[0]"),
14630            ("probe_artifact_capabilities", "artifacts[0].capabilities"),
14631            ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
14632            ("probe_capabilities", "capabilities"),
14633            ("probe_network", "capabilities.network"),
14634            ("probe_peer_fetch", "capabilities.peer_fetch"),
14635            ("probe_filesystem", "capabilities.filesystem"),
14636            ("probe_shell", "capabilities.shell"),
14637            (
14638                "probe_interpreter_runtime",
14639                "capabilities.shell.interpreter_runtime[0]",
14640            ),
14641            (
14642                "probe_interpreter_runtime_dir",
14643                "capabilities.shell.interpreter_runtime[0].dirs[0]",
14644            ),
14645            (
14646                "probe_staged_runtime",
14647                "capabilities.shell.staged_runtime[0]",
14648            ),
14649            ("probe_spawn", "capabilities.spawn"),
14650            ("probe_env", "capabilities.env"),
14651            ("probe_limits", "capabilities.limits"),
14652            ("probe_resources", "capabilities.resources"),
14653            ("probe_state", "capabilities.state"),
14654            ("probe_task_io", "capabilities.task_io"),
14655            ("probe_conversation", "capabilities.conversation"),
14656            ("probe_install", "capabilities.install"),
14657            ("probe_inference", "inference"),
14658            ("probe_driver", "inference.driver"),
14659            ("probe_compaction", "inference.compaction"),
14660            ("probe_context", "context"),
14661            ("probe_observability", "observability"),
14662            ("probe_eval", "observability.eval"),
14663            ("probe_scorer", "observability.eval.scorers[0]"),
14664            ("probe_trace", "trace"),
14665            ("probe_network_config", "network"),
14666            ("probe_lifecycle", "lifecycle"),
14667            ("probe_exports", "exports"),
14668            ("probe_files", "exports.files"),
14669            ("probe_peer_files", "exports.peer_files"),
14670            ("probe_control", "control"),
14671        ]
14672        .into_iter()
14673        .map(|(key, path)| (key.to_string(), path.to_string()))
14674        .collect();
14675
14676        assert_eq!(keys, expected);
14677    }
14678
14679    /// The names serde matches on for one struct body: field names in declaration order, with a
14680    /// `#[serde(rename = "...")]` field under its renamed spelling, and the overflow map itself
14681    /// excluded because it claims no name of its own.
14682    fn serde_field_names(body: &[&str]) -> Vec<String> {
14683        let mut names = Vec::new();
14684        let mut rename: Option<String> = None;
14685        for line in body {
14686            let trimmed = line.trim();
14687            if trimmed.starts_with("#[") {
14688                if let Some(rest) = trimmed.split("rename = \"").nth(1) {
14689                    if !trimmed.contains("rename_all") {
14690                        rename = rest.split('"').next().map(str::to_string);
14691                    }
14692                }
14693                continue;
14694            }
14695            if trimmed.starts_with("//") {
14696                continue;
14697            }
14698            let Some(field) = line
14699                .strip_prefix("    ")
14700                .and_then(|rest| rest.split(':').next())
14701            else {
14702                continue;
14703            };
14704            if field.is_empty()
14705                || !field
14706                    .chars()
14707                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
14708            {
14709                continue;
14710            }
14711            if field == "unknown" {
14712                rename = None;
14713                continue;
14714            }
14715            names.push(rename.take().unwrap_or_else(|| field.to_string()));
14716        }
14717        names
14718    }
14719
14720    /// The `KNOWN_KEYS` array `impl RawBlock for <name>` declares, read out of the source text so
14721    /// the assertion compares two independent readings of the same file rather than one value
14722    /// against itself.
14723    fn known_keys_of(source: &str, name: &str) -> Vec<String> {
14724        let header = format!("impl RawBlock for {name} {{");
14725        let body = source
14726            .split_once(&header)
14727            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
14728            .1;
14729        // rustfmt moves an array too long for the declaration's line onto the next one.
14730        let array = body
14731            .split_once("KNOWN_KEYS: &'static [&'static str] =")
14732            .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
14733            .1
14734            .trim_start()
14735            .strip_prefix("&[")
14736            .unwrap_or_else(|| panic!("{name}'s KNOWN_KEYS is not an array literal"))
14737            .split_once("];")
14738            .expect("KNOWN_KEYS array is unterminated")
14739            .0;
14740        array
14741            .split('"')
14742            .skip(1)
14743            .step_by(2)
14744            .map(str::to_string)
14745            .collect()
14746    }
14747}
14748
14749#[cfg(test)]
14750mod control_tests {
14751    use super::*;
14752
14753    const TOOL_ENTRY: &str = "  - name: local/card-api\n    version: 0.1.0\n    runtime: tool\n    gateway:\n      endpoint: https://cards.example.com\n      api_key: ${CARD_TOKEN}\n";
14754
14755    /// An `http` agent capsule whose driver is keyed by `${DRIVER_KEY}` and whose `card-api` tool
14756    /// is keyed by `${CARD_TOKEN}`, with `control` spliced in at the top level.
14757    fn agent(transport: &str, control: &str) -> String {
14758        let driver = if transport == "http" {
14759            "  - name: local/drv\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.example.com\n      api_key: ${DRIVER_KEY}\n"
14760        } else {
14761            "  - name: local/drv\n    version: 0.1.0\n    runtime: driver\n"
14762        };
14763        format!(
14764            "name: cap\nversion: 0.0.1\nartifacts:\n{driver}{TOOL_ENTRY}inference:\n  transport: {transport}\n  model: m\n  driver:\n    artifact: local/drv\n{control}"
14765        )
14766    }
14767
14768    fn refusal(yaml: &str) -> (String, String) {
14769        match RuntimeManifest::from_yaml_str(yaml) {
14770            Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14771            other => panic!("expected InvalidControl, got {other:?}"),
14772        }
14773    }
14774
14775    #[test]
14776    fn control_absent_empty_and_empty_lists_load_as_none() {
14777        for control in [
14778            "",
14779            "control: {}\n",
14780            "control:\n",
14781            "control:\n  settings: []\n  secrets: []\n",
14782        ] {
14783            let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
14784                .unwrap_or_else(|err| panic!("{control:?}: {err}"));
14785            assert_eq!(manifest.control, None, "{control:?}");
14786        }
14787    }
14788
14789    #[test]
14790    fn control_parses_settings_and_secrets_in_manifest_order() {
14791        let yaml = agent(
14792            "http",
14793            "control:\n  settings: [inference.max_tokens]\n  secrets: [CARD_TOKEN]\n",
14794        );
14795        for manifest in [
14796            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14797            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14798        ] {
14799            let control = manifest.control.unwrap();
14800            assert_eq!(
14801                control.settings,
14802                vec![ControllableSetting::InferenceMaxTokens]
14803            );
14804            assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
14805            assert!(control.declares_secret("CARD_TOKEN"));
14806            assert!(!control.declares_secret("DRIVER_KEY"));
14807        }
14808    }
14809
14810    #[test]
14811    fn control_setting_wire_names_round_trip() {
14812        for setting in ControllableSetting::ALL {
14813            assert_eq!(
14814                ControllableSetting::from_wire_name(setting.wire_name()),
14815                Some(*setting)
14816            );
14817        }
14818        assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
14819    }
14820
14821    /// Every refusal names `control.settings` or `control.secrets` as its field and quotes the
14822    /// entry it refused.
14823    #[test]
14824    fn control_manifest_refusals_name_the_field() {
14825        let cases: &[(&str, &str, &str, &str)] = &[
14826            (
14827                "http",
14828                "control:\n  settings: [inference.model]\n",
14829                "control.settings",
14830                "'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
14831            ),
14832            (
14833                "process",
14834                "control:\n  settings: [inference.max_tokens]\n",
14835                "control.settings",
14836                "'inference.max_tokens' is controllable only under inference.transport: http",
14837            ),
14838            (
14839                "http",
14840                "control:\n  settings: [inference.max_tokens, inference.max_tokens]\n",
14841                "control.settings",
14842                "'inference.max_tokens' is listed more than once",
14843            ),
14844            (
14845                "http",
14846                "control:\n  secrets: [CARD_TOKEN, CARD_TOKEN]\n",
14847                "control.secrets",
14848                "'CARD_TOKEN' is listed more than once",
14849            ),
14850            (
14851                "http",
14852                "control:\n  secrets: [card-token]\n",
14853                "control.secrets",
14854                "'card-token' is not a credential name",
14855            ),
14856            (
14857                "http",
14858                "control:\n  secrets: [OTHER_TOKEN]\n",
14859                "control.secrets",
14860                "'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
14861            ),
14862            (
14863                "http",
14864                "control:\n  secrets: [DRIVER_KEY]\n",
14865                "control.secrets",
14866                "'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.local/drv.gateway.api_key)",
14867            ),
14868            (
14869                "http",
14870                "control:\n  settings: inference.max_tokens\n",
14871                "control.settings",
14872                "must be a list of names",
14873            ),
14874            (
14875                "http",
14876                "control:\n  secrets: [1]\n",
14877                "control.secrets",
14878                "must be a list of names",
14879            ),
14880        ];
14881        for (transport, control, field, message) in cases {
14882            let (got_field, got_message) = refusal(&agent(transport, control));
14883            assert_eq!(&got_field, field, "{control:?}");
14884            assert!(
14885                got_message.starts_with(message),
14886                "{control:?}: {got_message}"
14887            );
14888            let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
14889                .unwrap_err()
14890                .to_string();
14891            assert!(
14892                rendered.contains(&format!("invalid control config for '{field}'")),
14893                "{rendered}"
14894            );
14895        }
14896    }
14897
14898    #[test]
14899    fn control_on_a_capsule_with_no_inference_is_refused() {
14900        let yaml = format!(
14901            "name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n  secrets: [CARD_TOKEN]\n"
14902        );
14903        let (field, message) = refusal(&yaml);
14904        assert_eq!(field, "control");
14905        assert!(message.contains("no inference: block"), "{message}");
14906    }
14907
14908    #[test]
14909    fn control_unknown_key_is_reported_once_at_its_block() {
14910        let manifest = RuntimeManifest::from_yaml_str(&agent(
14911            "http",
14912            "control:\n  settings: [inference.max_tokens]\n  agent_may_call: true\n",
14913        ))
14914        .unwrap();
14915        let keys: Vec<(&str, &str)> = manifest
14916            .unknown_keys
14917            .iter()
14918            .map(|key| (key.key.as_str(), key.block_path.as_str()))
14919            .collect();
14920        assert_eq!(keys, vec![("agent_may_call", "control")]);
14921        assert_eq!(
14922            manifest.control.unwrap().settings,
14923            vec![ControllableSetting::InferenceMaxTokens]
14924        );
14925    }
14926}
14927
14928#[cfg(test)]
14929mod driver_choice_tests {
14930    use super::*;
14931
14932    const DRV: &str = "  - name: local/drv\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.example.com\n      api_key: ${DRIVER_KEY}\n";
14933    const ALT: &str = "  - name: local/alt\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://alt.example.com\n      api_key: ${ALT_KEY}\n";
14934    const ALT_NO_GATEWAY: &str = "  - name: local/alt\n    version: 0.1.0\n    runtime: driver\n";
14935    const TOOL: &str = "  - name: local/card-api\n    version: 0.1.0\n    runtime: tool\n";
14936    const GPT: &str =
14937        "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: local/alt\n";
14938    const HAIKU: &str =
14939        "    - name: haiku\n      model: haiku\n      driver:\n        artifact: local/drv\n";
14940    const SWITCHABLE: &str = "control:\n  settings: [inference.driver]\n";
14941
14942    /// An `http` capsule on `drv` with model `m`, `artifacts` after `drv`, `alternates` as the
14943    /// `inference.alternates` list body (empty for no key), and `control` at the top level.
14944    fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
14945        let alternates = if alternates.is_empty() {
14946            String::new()
14947        } else {
14948            format!("  alternates:\n{alternates}")
14949        };
14950        format!(
14951            "name: cap\nversion: 0.0.1\nartifacts:\n{DRV}{artifacts}inference:\n  transport: http\n  model: m\n  driver:\n    artifact: local/drv\n{alternates}{control}"
14952        )
14953    }
14954
14955    fn refusal(yaml: &str) -> (String, String) {
14956        match RuntimeManifest::from_yaml_str(yaml) {
14957            Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
14958            | Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14959            other => panic!("expected an inference or control refusal, got {other:?}"),
14960        }
14961    }
14962
14963    #[test]
14964    fn driver_choice_alternates_parse_in_manifest_order() {
14965        let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
14966        for manifest in [
14967            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14968            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14969        ] {
14970            let inference = manifest.inference.unwrap();
14971            assert_eq!(
14972                inference.alternates,
14973                vec![
14974                    InferenceAlternate {
14975                        name: "gpt".to_string(),
14976                        model: "gpt-5".to_string(),
14977                        driver: "local/alt".to_string(),
14978                    },
14979                    InferenceAlternate {
14980                        name: "haiku".to_string(),
14981                        model: "haiku".to_string(),
14982                        driver: "local/drv".to_string(),
14983                    },
14984                ]
14985            );
14986            let control = manifest.control.unwrap();
14987            assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
14988            assert!(control.agent_settings.is_empty());
14989        }
14990    }
14991
14992    #[test]
14993    fn driver_choice_absent_alternates_are_empty() {
14994        let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
14995        assert!(manifest.inference.unwrap().alternates.is_empty());
14996    }
14997
14998    #[test]
14999    fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
15000        let manifest = RuntimeManifest::from_yaml_str(&capsule(
15001            ALT,
15002            GPT,
15003            "control:\n  agent_settings: [inference.driver]\n",
15004        ))
15005        .unwrap();
15006        let control = manifest.control.unwrap();
15007        assert!(control.settings.is_empty());
15008        assert_eq!(
15009            control.agent_settings,
15010            vec![ControllableSetting::InferenceDriver]
15011        );
15012        assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
15013        assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
15014        assert!(control.secrets.is_empty());
15015    }
15016
15017    #[test]
15018    fn driver_choice_wire_name_round_trips() {
15019        assert_eq!(
15020            ControllableSetting::from_wire_name("inference.driver"),
15021            Some(ControllableSetting::InferenceDriver)
15022        );
15023        assert_eq!(
15024            ControllableSetting::InferenceDriver.wire_name(),
15025            "inference.driver"
15026        );
15027        assert_eq!(
15028            ControllableSetting::AGENT_SETTABLE,
15029            &[ControllableSetting::InferenceDriver]
15030        );
15031    }
15032
15033    /// An alternate's driver may carry `gateway:`, which a driver entry nothing names may not.
15034    #[test]
15035    fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
15036        RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
15037            .expect("an alternate's driver carries its own gateway");
15038        match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
15039            Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
15040                assert!(message.contains("inference.alternates"), "{message}")
15041            }
15042            other => panic!("expected InvalidArtifact, got {other:?}"),
15043        }
15044    }
15045
15046    /// Two models of one provider: the alternate shares the primary's driver and gateway.
15047    #[test]
15048    fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
15049        let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
15050        assert_eq!(
15051            manifest.inference.unwrap().alternates[0].driver,
15052            "local/drv"
15053        );
15054    }
15055
15056    #[test]
15057    fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
15058        let manifest = RuntimeManifest::from_yaml_str(&capsule(
15059            ALT,
15060            GPT,
15061            "control:\n  settings: [inference.driver]\n  secrets: [ALT_KEY]\n",
15062        ))
15063        .unwrap();
15064        assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
15065        let (field, message) = refusal(&capsule(
15066            ALT,
15067            GPT,
15068            "control:\n  settings: [inference.driver]\n  secrets: [DRIVER_KEY]\n",
15069        ));
15070        assert_eq!(field, "control.secrets");
15071        assert!(
15072            message.contains("keys the configured inference driver's gateway"),
15073            "{message}"
15074        );
15075    }
15076
15077    #[test]
15078    fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
15079        let alternate = "    - name: gpt\n      model: gpt-5\n      temperature: 1\n      driver:\n        artifact: local/alt\n";
15080        let manifest =
15081            RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
15082        let keys: Vec<(&str, &str)> = manifest
15083            .unknown_keys
15084            .iter()
15085            .map(|key| (key.key.as_str(), key.block_path.as_str()))
15086            .collect();
15087        assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
15088    }
15089
15090    /// Every refusal the alternates and the driver setting can draw, each naming its field and
15091    /// rendering as the manifest error `E-MAN-003` maps from.
15092    #[test]
15093    fn driver_choice_manifest_refusals_name_the_field() {
15094        let alternate = |name: &str, model: &str, driver: &str| {
15095            format!("    - name: {name}\n      model: {model}\n      driver:\n        artifact: {driver}\n")
15096        };
15097        let process = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/drv\n    version: 0.1.0\n    runtime: driver\ninference:\n  transport: process\n  driver:\n    artifact: local/drv\n";
15098        let cases: Vec<(String, &str, &str)> = vec![
15099            (
15100                format!("{process}  alternates:\n{GPT}"),
15101                "inference.alternates",
15102                "is not valid with transport: process",
15103            ),
15104            (
15105                format!("{process}control:\n  settings: [inference.driver]\n"),
15106                "control.settings",
15107                "'inference.driver' is controllable only under inference.transport: http",
15108            ),
15109            (
15110                capsule(ALT, &alternate("primary", "gpt-5", "local/alt"), SWITCHABLE),
15111                "inference.alternates[0].name",
15112                "'primary' is reserved",
15113            ),
15114            (
15115                capsule(
15116                    ALT,
15117                    &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt", "gpt-4", "local/alt")),
15118                    SWITCHABLE,
15119                ),
15120                "inference.alternates[1].name",
15121                "'gpt' names more than one alternate",
15122            ),
15123            (
15124                capsule(ALT, &alternate("GPT", "gpt-5", "local/alt"), SWITCHABLE),
15125                "inference.alternates[0].name",
15126                "'GPT' is not a driver choice name",
15127            ),
15128            (
15129                capsule(ALT, &alternate("5gpt", "gpt-5", "local/alt"), SWITCHABLE),
15130                "inference.alternates[0].name",
15131                "'5gpt' is not a driver choice name",
15132            ),
15133            (
15134                capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "local/alt"), SWITCHABLE),
15135                "inference.alternates[0].name",
15136                "is not a driver choice name",
15137            ),
15138            (
15139                capsule(ALT, "    - name: gpt\n      model: \"\"\n      driver:\n        artifact: local/alt\n", SWITCHABLE),
15140                "inference.alternates[0].model",
15141                "missing required field",
15142            ),
15143            (
15144                capsule(ALT, "    - name: gpt\n      model: gpt-5\n", SWITCHABLE),
15145                "inference.alternates[0].driver.artifact",
15146                "missing required field",
15147            ),
15148            (
15149                capsule("", &alternate("gpt", "gpt-5", "local/nowhere"), SWITCHABLE),
15150                "inference.alternates[0].driver.artifact",
15151                "artifact 'local/nowhere' is not declared in artifacts:",
15152            ),
15153            (
15154                capsule(TOOL, &alternate("gpt", "gpt-5", "local/card-api"), SWITCHABLE),
15155                "inference.alternates[0].driver.artifact",
15156                "artifact 'local/card-api' is declared with runtime: tool",
15157            ),
15158            (
15159                capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "local/alt"), SWITCHABLE),
15160                "inference.alternates[0].driver.artifact",
15161                "artifact 'local/alt' declares no gateway.endpoint",
15162            ),
15163            (
15164                capsule(
15165                    ALT,
15166                    "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: local/alt\n        config:\n          thinking: enabled\n",
15167                    SWITCHABLE,
15168                ),
15169                "inference.alternates[0].driver.config",
15170                "is not valid on an alternate; inference.driver.config applies to every declared driver, and per-driver settings belong in that driver's artifacts[].config",
15171            ),
15172            (
15173                capsule("", &alternate("same", "m", "local/drv"), SWITCHABLE),
15174                "inference.alternates[0]",
15175                "'same' is driver 'local/drv' with model 'm', which is the primary choice",
15176            ),
15177            (
15178                capsule(
15179                    ALT,
15180                    &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt-too", "gpt-5", "local/alt")),
15181                    SWITCHABLE,
15182                ),
15183                "inference.alternates[1]",
15184                "'gpt-too' is driver 'local/alt' with model 'gpt-5', which alternate 'gpt' already is",
15185            ),
15186            (
15187                capsule(ALT, GPT, ""),
15188                "inference.alternates",
15189                "declares alternates that nothing could ever select",
15190            ),
15191            (
15192                capsule(ALT, GPT, "control:\n  settings: [inference.max_tokens]\n"),
15193                "inference.alternates",
15194                "declares alternates that nothing could ever select",
15195            ),
15196            (
15197                capsule("", "", SWITCHABLE),
15198                "control.settings",
15199                "'inference.driver' selects among inference.alternates, and this capsule declares none",
15200            ),
15201            (
15202                capsule("", "", "control:\n  agent_settings: [inference.driver]\n"),
15203                "control.agent_settings",
15204                "'inference.driver' selects among inference.alternates, and this capsule declares none",
15205            ),
15206            (
15207                capsule(ALT, GPT, "control:\n  agent_settings: [inference.max_tokens]\n"),
15208                "control.agent_settings",
15209                "'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
15210            ),
15211            (
15212                capsule(
15213                    ALT,
15214                    GPT,
15215                    "control:\n  agent_settings: [inference.driver, inference.driver]\n",
15216                ),
15217                "control.agent_settings",
15218                "'inference.driver' is listed more than once",
15219            ),
15220            (
15221                capsule(ALT, GPT, "control:\n  agent_settings: inference.driver\n"),
15222                "control.agent_settings",
15223                "must be a list of names",
15224            ),
15225        ];
15226        for (yaml, field, message) in &cases {
15227            let (got_field, got_message) = refusal(yaml);
15228            assert_eq!(&got_field, field, "{yaml}");
15229            assert!(got_message.contains(message), "{yaml}\n{got_message}");
15230            let rendered = RuntimeManifest::from_yaml_str(yaml)
15231                .unwrap_err()
15232                .to_string();
15233            assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
15234        }
15235    }
15236}
15237
15238#[cfg(test)]
15239mod network_authentication_tests {
15240    use super::*;
15241
15242    fn network_authentication_of(
15243        block: &str,
15244    ) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
15245        RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
15246            .map(|manifest| manifest.network.and_then(|network| network.authentication))
15247    }
15248
15249    fn network_authentication_refusal(block: &str) -> (String, String) {
15250        match network_authentication_of(block) {
15251            Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
15252            other => panic!("expected InvalidNetworkConfig, got {other:?}"),
15253        }
15254    }
15255
15256    #[test]
15257    fn network_authentication_with_credentials_parses_sorted_by_name() {
15258        let auth = network_authentication_of(
15259            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15260             watcher:\n        scopes: [GetTask, murmur.WatchSession]\n      \
15261             reader:\n        scopes: [resources/files]\n",
15262        )
15263        .unwrap()
15264        .expect("declared");
15265        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
15266        assert_eq!(
15267            auth.credentials,
15268            vec![
15269                DoorCredential {
15270                    name: "reader".to_string(),
15271                    scopes: vec!["resources/files".to_string()],
15272                },
15273                DoorCredential {
15274                    name: "watcher".to_string(),
15275                    // Declared order, not sorted.
15276                    scopes: vec!["GetTask".to_string(), "murmur.WatchSession".to_string()],
15277                },
15278            ]
15279        );
15280    }
15281
15282    #[test]
15283    fn network_authentication_scheme_alone_parses() {
15284        let auth = network_authentication_of("network:\n  authentication:\n    scheme: bearer\n")
15285            .unwrap()
15286            .expect("declared");
15287        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
15288        assert!(auth.credentials.is_empty());
15289    }
15290
15291    #[test]
15292    fn network_authentication_absent_is_a_public_door() {
15293        assert_eq!(network_authentication_of("").unwrap(), None);
15294        assert_eq!(
15295            network_authentication_of("network:\n  internal_port: 8080\n").unwrap(),
15296            None
15297        );
15298        let manifest = RuntimeManifest::from_yaml_str(
15299            "name: cap\nversion: 0.1.0\nnetwork:\n  internal_port: 8080\n",
15300        )
15301        .unwrap();
15302        assert_eq!(
15303            manifest.network,
15304            Some(NetworkConfig {
15305                internal_port: Some(8080),
15306                authentication: None,
15307            })
15308        );
15309    }
15310
15311    #[test]
15312    fn network_authentication_without_a_scheme_is_refused() {
15313        let (field, message) = network_authentication_refusal(
15314            "network:\n  authentication:\n    credentials:\n      w:\n        scopes: [GetTask]\n",
15315        );
15316        assert_eq!(field, "network.authentication.scheme");
15317        assert!(message.contains("required"), "{message}");
15318    }
15319
15320    #[test]
15321    fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
15322        for scheme in ["mtls", "Bearer", "basic"] {
15323            let (field, message) = network_authentication_refusal(&format!(
15324                "network:\n  authentication:\n    scheme: {scheme}\n"
15325            ));
15326            assert_eq!(field, "network.authentication.scheme");
15327            assert!(message.contains("bearer"), "{message}");
15328            assert!(message.contains(scheme), "{message}");
15329        }
15330    }
15331
15332    #[test]
15333    fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
15334        let long = format!("a{}", "b".repeat(32));
15335        for name in [
15336            "operator",
15337            "Watcher",
15338            "1st",
15339            "_x",
15340            "-x",
15341            "wat.cher",
15342            long.as_str(),
15343        ] {
15344            let (field, message) = network_authentication_refusal(&format!(
15345                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15346                 '{name}':\n        scopes: [GetTask]\n"
15347            ));
15348            assert_eq!(field, "network.authentication.credentials", "{name}");
15349            assert!(message.contains(name), "{message}");
15350        }
15351        let longest = format!("a{}", "b".repeat(31));
15352        for name in ["w", "a-b_c9", longest.as_str()] {
15353            network_authentication_of(&format!(
15354                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15355                 {name}:\n        scopes: [GetTask]\n"
15356            ))
15357            .unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
15358        }
15359    }
15360
15361    #[test]
15362    fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
15363        let credential = |body: &str| {
15364            format!(
15365                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15366                 watcher:\n{body}"
15367            )
15368        };
15369        let field = "network.authentication.credentials.watcher.scopes";
15370
15371        let (got, message) = network_authentication_refusal(&credential("        {}\n"));
15372        assert_eq!(got, field);
15373        assert!(message.contains("required"), "{message}");
15374
15375        let (got, message) = network_authentication_refusal(&credential("        scopes: []\n"));
15376        assert_eq!(got, field);
15377        assert!(message.contains("no scope"), "{message}");
15378
15379        let (got, message) =
15380            network_authentication_refusal(&credential("        scopes: [tasks/list]\n"));
15381        assert_eq!(got, field);
15382        assert!(message.contains("tasks/list"), "{message}");
15383        assert!(message.contains("resources/files"), "{message}");
15384
15385        let (got, message) =
15386            network_authentication_refusal(&credential("        scopes: [GetExtendedAgentCard]\n"));
15387        assert_eq!(got, field);
15388        assert!(message.contains("GetExtendedAgentCard"), "{message}");
15389
15390        let (got, message) = network_authentication_refusal(&credential(
15391            "        scopes: [GetTask, murmur.WatchSession, GetTask]\n",
15392        ));
15393        assert_eq!(got, field);
15394        assert!(message.contains("more than once"), "{message}");
15395    }
15396
15397    #[test]
15398    fn network_authentication_refuses_a_retired_scope_naming_its_replacement() {
15399        let field = "network.authentication.credentials.watcher.scopes";
15400        for (retired, replacement) in [
15401            ("message/send", "SendMessage"),
15402            ("message/stream", "SendStreamingMessage"),
15403            ("tasks/get", "GetTask"),
15404            ("tasks/cancel", "CancelTask"),
15405            ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
15406            ("stream/watch", "murmur.WatchSession"),
15407            ("session/stop", "murmur.StopSession"),
15408        ] {
15409            let error = network_authentication_of(&format!(
15410                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15411                 watcher:\n        scopes: [murmur.WatchSession, {retired}]\n"
15412            ))
15413            .unwrap_err();
15414            let rendered = error.to_string();
15415            match error {
15416                RuntimeManifestError::RetiredDoorScope {
15417                    field: got_field,
15418                    scope,
15419                    replacement: got_replacement,
15420                } => {
15421                    assert_eq!(got_field, field);
15422                    assert_eq!(scope, retired);
15423                    assert_eq!(got_replacement, replacement);
15424                }
15425                other => panic!("expected RetiredDoorScope for {retired}, got {other:?}"),
15426            }
15427            assert!(
15428                rendered.contains(&format!(
15429                    "'{retired}' is a retired name of a door method, and the door answers only \
15430                     the names its card lists"
15431                )),
15432                "{rendered}"
15433            );
15434            assert!(rendered.contains(field), "{rendered}");
15435        }
15436    }
15437
15438    #[test]
15439    fn network_authentication_accepts_the_v1_scopes() {
15440        let auth = network_authentication_of(
15441            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15442             watcher:\n        scopes: [GetTask, murmur.WatchSession, murmur.StopSession]\n",
15443        )
15444        .unwrap()
15445        .unwrap();
15446        assert_eq!(
15447            auth.credentials[0].scopes,
15448            ["GetTask", "murmur.WatchSession", "murmur.StopSession"]
15449        );
15450    }
15451
15452    /// The two task methods beside `GetTask` are scopes a credential may list, in the method
15453    /// table's order.
15454    #[test]
15455    fn door_scope_list_tasks_and_subscribe_to_task_load_on_a_credential() {
15456        let auth = network_authentication_of(
15457            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15458             alice:\n        scopes: [SendMessage, ListTasks, SubscribeToTask]\n",
15459        )
15460        .unwrap()
15461        .unwrap();
15462        assert_eq!(
15463            auth.credentials[0].scopes,
15464            ["SendMessage", "ListTasks", "SubscribeToTask"]
15465        );
15466        assert_eq!(
15467            DOOR_SCOPES,
15468            [
15469                "SendMessage",
15470                "SendStreamingMessage",
15471                "murmur.WatchSession",
15472                "GetTask",
15473                "ListTasks",
15474                "CancelTask",
15475                "SubscribeToTask",
15476                "murmur.StopSession",
15477                "resources/files",
15478            ]
15479        );
15480    }
15481
15482    #[test]
15483    fn network_authentication_accepts_every_door_scope() {
15484        let auth = network_authentication_of(&format!(
15485            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15486             everything:\n        scopes: [{}]\n",
15487            DOOR_SCOPES.join(", ")
15488        ))
15489        .unwrap()
15490        .unwrap();
15491        assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
15492        assert_eq!(OPERATOR_CREDENTIAL, "operator");
15493    }
15494
15495    #[test]
15496    fn network_authentication_is_refused_beside_spawn_allow() {
15497        let (field, message) = network_authentication_refusal(
15498            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
15499             allow: [local/worker]\n",
15500        );
15501        assert_eq!(field, "network.authentication");
15502        assert!(message.contains("capabilities.spawn.allow"), "{message}");
15503        assert!(message.contains("token"), "{message}");
15504        let error = RuntimeManifest::from_yaml_str(
15505            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n\
15506             capabilities:\n  spawn:\n    allow: [local/worker]\n",
15507        )
15508        .unwrap_err()
15509        .to_string();
15510        assert!(error.contains("network.authentication"), "{error}");
15511        assert!(error.contains("capabilities.spawn.allow"), "{error}");
15512
15513        // An empty allow-list delegates nothing, so it does not conflict.
15514        network_authentication_of(
15515            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
15516             allow: []\n",
15517        )
15518        .unwrap();
15519    }
15520
15521    #[test]
15522    fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
15523        let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
15524            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n    \
15525             schemes: x\n    credentials:\n      watcher:\n        scopes: [GetTask]\n        \
15526             scope: [GetTask]\n",
15527        )
15528        .unwrap()
15529        .unknown_keys
15530        .into_iter()
15531        .map(|key| (key.key, key.block_path, key.nearest_known))
15532        .collect();
15533        assert_eq!(
15534            keys,
15535            vec![
15536                (
15537                    "schemes".to_string(),
15538                    "network.authentication".to_string(),
15539                    Some("scheme".to_string())
15540                ),
15541                (
15542                    "scope".to_string(),
15543                    "network.authentication.credentials.watcher".to_string(),
15544                    Some("scopes".to_string())
15545                ),
15546            ]
15547        );
15548    }
15549}
15550
15551#[cfg(test)]
15552mod registry_block_tests {
15553    use super::*;
15554
15555    const REGISTRY_BLOCK: &str = "registry:\n  default: local\n";
15556
15557    #[test]
15558    fn registry_block_is_refused_by_both_entry_points() {
15559        let input = format!("name: cap\nversion: 0.0.1\nartifacts: []\n{REGISTRY_BLOCK}");
15560        for result in [
15561            RuntimeManifest::from_yaml_str(&input),
15562            RuntimeManifest::from_yaml_str_without_secrets(&input),
15563        ] {
15564            let err = result.unwrap_err();
15565            assert!(
15566                matches!(err, RuntimeManifestError::RegistryBlock),
15567                "{err:?}"
15568            );
15569            assert_eq!(err.to_string(), REGISTRY_BLOCK_MESSAGE);
15570        }
15571    }
15572
15573    #[test]
15574    fn registry_block_is_refused_before_any_other_field() {
15575        // `name` is missing and `lifecycle.queue_depth: 0` under `queue` is refused on its own;
15576        // the registry refusal still wins.
15577        let input = format!(
15578            "version: 0.0.1\nlifecycle:\n  task_acceptance: queue\n  queue_depth: 0\n\
15579             {REGISTRY_BLOCK}"
15580        );
15581        let err = RuntimeManifest::from_yaml_str(&input).unwrap_err();
15582        assert!(
15583            matches!(err, RuntimeManifestError::RegistryBlock),
15584            "{err:?}"
15585        );
15586    }
15587
15588    #[test]
15589    fn registry_block_refusal_applies_whatever_the_block_holds() {
15590        for block in ["registry: local\n", "registry: {}\n", "registry: []\n"] {
15591            let input = format!("name: cap\nversion: 0.0.1\n{block}");
15592            assert!(
15593                matches!(
15594                    RuntimeManifest::from_yaml_str(&input),
15595                    Err(RuntimeManifestError::RegistryBlock)
15596                ),
15597                "{block}"
15598            );
15599        }
15600    }
15601
15602    #[test]
15603    fn a_manifest_without_a_registry_block_parses_unchanged() {
15604        let manifest =
15605            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
15606        assert_eq!(manifest.name, "cap");
15607        assert_eq!(manifest.version, "0.0.1");
15608    }
15609}
15610
15611#[cfg(test)]
15612mod scoped_name_tests {
15613    use super::*;
15614
15615    fn name_refusal(yaml: &str) -> (String, String, Option<String>) {
15616        match RuntimeManifest::from_yaml_str(yaml) {
15617            Err(RuntimeManifestError::InvalidArtifactName {
15618                field,
15619                message,
15620                hint,
15621            }) => (field, message, hint),
15622            other => panic!("expected InvalidArtifactName, got {other:?}"),
15623        }
15624    }
15625
15626    #[test]
15627    fn scoped_name_a_bare_artifact_entry_is_refused_with_the_scoped_forms() {
15628        let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: tool-editor\n    \
15629                    version: 0.1.0\n    runtime: tool\n";
15630        let (field, message, hint) = name_refusal(yaml);
15631        assert_eq!(field, "artifacts[0].name");
15632        assert_eq!(
15633            message,
15634            "'tool-editor' is not a full artifact name: an artifact is named <scope>/<name>"
15635        );
15636        assert_eq!(
15637            hint.as_deref(),
15638            Some(
15639                "name it with its scope: 'murmur/tool-editor' for a Murmur default artifact, \
15640                 '<owner>/tool-editor' for one published from github.com/<owner>, or \
15641                 'local/tool-editor' for one installed from a .mur.zip file"
15642            )
15643        );
15644        let rendered = RuntimeManifest::from_yaml_str(yaml)
15645            .unwrap_err()
15646            .to_string();
15647        assert!(
15648            rendered.starts_with("murmur.yaml: invalid artifact name in 'artifacts[0].name': "),
15649            "{rendered}"
15650        );
15651    }
15652
15653    #[test]
15654    fn scoped_name_a_source_entry_keeps_its_bare_name() {
15655        let bare = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: style\n    \
15656                    source: ./skills/style\n    runtime: skill\n";
15657        RuntimeManifest::from_yaml_str(bare).expect("a source: entry is named by its bare name");
15658
15659        let (field, message, hint) = name_refusal(&bare.replace("name: style", "name: ana/style"));
15660        assert_eq!(field, "artifacts[0].name");
15661        assert!(
15662            message
15663                .starts_with("'ana/style' declares 'source:', so it is named by its bare name; "),
15664            "{message}"
15665        );
15666        assert_eq!(hint.as_deref(), Some("drop the scope: name it 'style'"));
15667    }
15668
15669    #[test]
15670    fn scoped_name_spawn_allow_refuses_a_bare_entry_and_a_shared_bare_part() {
15671        let with_allow = |allow: &str| {
15672            format!("name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    allow: [{allow}]\n")
15673        };
15674        let manifest = RuntimeManifest::from_yaml_str(&with_allow("local/worker, ana/reviewer"))
15675            .expect("full names in two scopes with distinct bare parts parse");
15676        assert_eq!(
15677            manifest.capabilities.unwrap().spawn.unwrap().allow,
15678            vec!["local/worker".to_string(), "ana/reviewer".to_string()]
15679        );
15680
15681        let (field, message, hint) = name_refusal(&with_allow("worker"));
15682        assert_eq!(field, "capabilities.spawn.allow[0]");
15683        assert!(
15684            message.contains("'worker' is not a full artifact name"),
15685            "{message}"
15686        );
15687        assert!(hint.unwrap().contains("local/worker"));
15688
15689        let (field, message, hint) = name_refusal(&with_allow("local/worker, ana/worker"));
15690        assert_eq!(field, "capabilities.spawn.allow[1]");
15691        assert_eq!(
15692            message,
15693            "'local/worker' and 'ana/worker' share the name 'worker', which is how delegate-task \
15694             offers a capsule to the model"
15695        );
15696        assert_eq!(
15697            hint.as_deref(),
15698            Some("keep one of 'local/worker' and 'ana/worker' in capabilities.spawn.allow")
15699        );
15700    }
15701
15702    #[test]
15703    fn spawn_workdir_snapshot_lowers_beside_allow_and_is_empty_when_absent() {
15704        let yaml = "name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    \
15705                    allow: [local/reviewer, local/worker]\n    \
15706                    workdir_snapshot: [local/reviewer]\n";
15707        let spawn = RuntimeManifest::from_yaml_str(yaml)
15708            .expect("a snapshot entry that is also in allow parses")
15709            .capabilities
15710            .unwrap()
15711            .spawn
15712            .unwrap();
15713        assert_eq!(
15714            spawn,
15715            SpawnCapabilities {
15716                allow: vec!["local/reviewer".to_string(), "local/worker".to_string()],
15717                workdir_snapshot: vec!["local/reviewer".to_string()],
15718            }
15719        );
15720
15721        let absent = "name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    \
15722                      allow: [local/reviewer]\n";
15723        let spawn = RuntimeManifest::from_yaml_str(absent)
15724            .unwrap()
15725            .capabilities
15726            .unwrap()
15727            .spawn
15728            .unwrap();
15729        assert!(spawn.workdir_snapshot.is_empty());
15730    }
15731
15732    #[test]
15733    fn spawn_workdir_snapshot_refuses_an_entry_outside_allow_a_bare_name_and_a_duplicate() {
15734        let with = |snapshot: &str| {
15735            format!(
15736                "name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    \
15737                 allow: [local/reviewer, local/worker]\n    workdir_snapshot: [{snapshot}]\n"
15738            )
15739        };
15740
15741        let (field, message, hint) = name_refusal(&with("local/reviewer, local/other"));
15742        assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
15743        assert_eq!(
15744            message,
15745            "'local/other' is not in capabilities.spawn.allow, so this capsule cannot delegate \
15746             to it"
15747        );
15748        assert!(
15749            hint.as_deref()
15750                .is_some_and(|hint| hint.contains("capabilities.spawn.allow")),
15751            "{hint:?}"
15752        );
15753
15754        let (field, message, _) = name_refusal(&with("reviewer"));
15755        assert_eq!(field, "capabilities.spawn.workdir_snapshot[0]");
15756        assert!(
15757            message.contains("'reviewer' is not a full artifact name"),
15758            "{message}"
15759        );
15760
15761        let (field, message, hint) = name_refusal(&with("local/reviewer, local/reviewer"));
15762        assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
15763        assert_eq!(message, "'local/reviewer' is listed more than once");
15764        assert_eq!(
15765            hint.as_deref(),
15766            Some("keep one 'local/reviewer' in capabilities.spawn.workdir_snapshot")
15767        );
15768    }
15769
15770    #[test]
15771    fn scoped_name_a_reference_by_bare_part_names_the_declared_entry() {
15772        let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    \
15773                    version: 0.1.0\n    runtime: driver\n    gateway:\n      \
15774                    endpoint: https://api.anthropic.com\n      api_key: k\ninference:\n  \
15775                    transport: http\n  model: m\n  driver:\n    artifact: driver-anthropic\n";
15776        let message = RuntimeManifest::from_yaml_str(yaml)
15777            .unwrap_err()
15778            .to_string();
15779        assert!(
15780            message.contains(
15781                "artifact 'driver-anthropic' is not declared in artifacts: (the declared \
15782                 'murmur/driver-anthropic' has that name; refer to it as \
15783                 'murmur/driver-anthropic')"
15784            ),
15785            "{message}"
15786        );
15787    }
15788}
15789
15790#[cfg(test)]
15791mod inference_retry_tests {
15792    use super::*;
15793
15794    const HTTP: &str = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/drv\n    \
15795                        version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: \
15796                        http://127.0.0.1:8080\n      api_key: sk-test\ninference:\n  transport: \
15797                        http\n  model: m\n  driver:\n    artifact: local/drv\n";
15798    const PROCESS: &str = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/drv\n    \
15799                           version: 0.1.0\n    runtime: driver\ninference:\n  transport: \
15800                           process\n  driver:\n    artifact: local/drv\n";
15801
15802    fn retry(yaml: &str) -> InferenceRetry {
15803        RuntimeManifest::from_yaml_str(yaml)
15804            .unwrap()
15805            .inference
15806            .unwrap()
15807            .retry
15808    }
15809
15810    fn refusal(yaml: &str) -> (String, String) {
15811        match RuntimeManifest::from_yaml_str(yaml) {
15812            Err(RuntimeManifestError::InvalidInferenceConfig { field, message }) => {
15813                (field, message)
15814            }
15815            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
15816        }
15817    }
15818
15819    #[test]
15820    fn inference_retry_defaults_when_absent_and_per_key_when_partial() {
15821        assert_eq!(retry(HTTP), InferenceRetry::default());
15822        assert_eq!(
15823            InferenceRetry::default(),
15824            InferenceRetry {
15825                max_retries: 6,
15826                initial_delay_ms: 1_000,
15827                max_delay_ms: 60_000,
15828            }
15829        );
15830        assert_eq!(
15831            retry(&format!("{HTTP}  retry:\n    max_retries: 2\n")),
15832            InferenceRetry {
15833                max_retries: 2,
15834                ..InferenceRetry::default()
15835            }
15836        );
15837        assert_eq!(
15838            retry(&format!(
15839                "{HTTP}  retry:\n    initial_delay_ms: 10\n    max_delay_ms: 2000\n"
15840            )),
15841            InferenceRetry {
15842                max_retries: 6,
15843                initial_delay_ms: 10,
15844                max_delay_ms: 2000,
15845            }
15846        );
15847        assert_eq!(
15848            retry(&format!(
15849                "{HTTP}  retry:\n    max_retries: 0\n    initial_delay_ms: 60000\n    \
15850                 max_delay_ms: 3600000\n"
15851            )),
15852            InferenceRetry {
15853                max_retries: 0,
15854                initial_delay_ms: 60_000,
15855                max_delay_ms: 3_600_000,
15856            }
15857        );
15858        assert_eq!(retry(PROCESS), InferenceRetry::default());
15859    }
15860
15861    #[test]
15862    fn inference_retry_bounds_are_refused_naming_the_field() {
15863        for (block, field) in [
15864            ("max_retries: 21", "inference.retry.max_retries"),
15865            ("initial_delay_ms: 0", "inference.retry.initial_delay_ms"),
15866            (
15867                "initial_delay_ms: 60001",
15868                "inference.retry.initial_delay_ms",
15869            ),
15870            ("max_delay_ms: 0", "inference.retry.max_delay_ms"),
15871            ("max_delay_ms: 3600001", "inference.retry.max_delay_ms"),
15872            (
15873                "initial_delay_ms: 5000\n    max_delay_ms: 1000",
15874                "inference.retry.max_delay_ms",
15875            ),
15876        ] {
15877            let (got, message) = refusal(&format!("{HTTP}  retry:\n    {block}\n"));
15878            assert_eq!(got, field, "{block}: {message}");
15879        }
15880        let (_, message) = refusal(&format!(
15881            "{HTTP}  retry:\n    initial_delay_ms: 5000\n    max_delay_ms: 1000\n"
15882        ));
15883        assert!(message.contains("initial_delay_ms"), "{message}");
15884    }
15885
15886    #[test]
15887    fn inference_retry_is_refused_under_transport_process() {
15888        let (field, message) = refusal(&format!("{PROCESS}  retry:\n    max_retries: 2\n"));
15889        assert_eq!(field, "inference.retry");
15890        assert_eq!(
15891            message,
15892            "is not valid with transport: process; the harness retries its own provider calls"
15893        );
15894    }
15895
15896    #[test]
15897    fn inference_retry_unknown_key_is_reported_at_its_block() {
15898        let manifest = RuntimeManifest::from_yaml_str(&format!(
15899            "{HTTP}  retry:\n    max_retries: 2\n    jitter: full\n"
15900        ))
15901        .unwrap();
15902        let keys: Vec<(&str, &str)> = manifest
15903            .unknown_keys
15904            .iter()
15905            .map(|key| (key.key.as_str(), key.block_path.as_str()))
15906            .collect();
15907        assert_eq!(keys, vec![("jitter", "inference.retry")]);
15908    }
15909}
15910
15911#[cfg(test)]
15912mod host_paths_tests {
15913    use super::*;
15914
15915    const ENTRY: &str = "  - path: ~/.cargo\n    access: read-write\n";
15916
15917    /// The dotted field both entry points refuse `input` for; they must agree.
15918    fn refused_field(input: &str) -> String {
15919        let fields: Vec<String> = [
15920            RuntimeManifest::from_yaml_str(input),
15921            RuntimeManifest::from_yaml_str_without_secrets(input),
15922        ]
15923        .into_iter()
15924        .map(|result| match result {
15925            Err(RuntimeManifestError::HostPathsDeclared { field }) => field,
15926            other => panic!("expected HostPathsDeclared, got {other:?}"),
15927        })
15928        .collect();
15929        assert_eq!(
15930            fields[0], fields[1],
15931            "both entry points refuse the same field"
15932        );
15933        fields[0].clone()
15934    }
15935
15936    #[test]
15937    fn host_paths_is_refused_wherever_a_manifest_writes_it() {
15938        let top = format!("name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths:\n{ENTRY}");
15939        assert_eq!(refused_field(&top), "host_paths");
15940
15941        let capabilities = format!(
15942            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  host_paths:\n  {}",
15943            ENTRY.replace('\n', "\n  ").trim_end()
15944        );
15945        assert_eq!(refused_field(&capabilities), "capabilities.host_paths");
15946
15947        let filesystem = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  \
15948                          filesystem:\n    host_paths: []\n";
15949        assert_eq!(
15950            refused_field(filesystem),
15951            "capabilities.filesystem.host_paths"
15952        );
15953
15954        let artifact = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/tool-a\n    \
15955                        version: 0.1.0\n    runtime: tool\n  - name: murmur/tool-b\n    \
15956                        version: 0.1.0\n    runtime: tool\n    capabilities:\n      \
15957                        host_paths: []\n";
15958        assert_eq!(
15959            refused_field(artifact),
15960            "artifacts[1].capabilities.host_paths"
15961        );
15962    }
15963
15964    #[test]
15965    fn the_refusal_names_the_field_and_is_never_an_unknown_key_warning() {
15966        let input = "name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths: []\n";
15967        let error = RuntimeManifest::from_yaml_str(input).unwrap_err();
15968        assert!(error.to_string().contains("'host_paths'"), "{error}");
15969        assert!(error.to_string().contains("murmur.yaml"), "{error}");
15970    }
15971
15972    #[test]
15973    fn a_manifest_without_host_paths_parses_unchanged() {
15974        let input = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  filesystem:\n    \
15975                     workdir_exec: false\n";
15976        assert!(RuntimeManifest::from_yaml_str(input).is_ok());
15977    }
15978}