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 its `skill.md` content as the tool result (just-in-time
214///   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    /// points at a `skill.md` file or at a directory containing one (case-insensitive).
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` is used as the system
1228    /// 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.install` block — the operator's grant to pull artifacts into a running
1588/// session through `murmur:artifact-manager/manage.pull`. Read from the capsule-wide block only.
1589/// Each entry is an exact artifact name, a `<prefix>*` or `*`, as
1590/// [`crate::install_pattern::validate`] accepts it, kept verbatim in declaration order. At least
1591/// one list is non-empty: omitting the block, not declaring an empty one, is how a capsule grants
1592/// nothing.
1593#[derive(Debug, Clone, PartialEq, Eq)]
1594pub struct InstallCapabilities {
1595    /// Entries admitting skill artifacts (`skill.md`, no executable payload).
1596    pub skill: Vec<String>,
1597    /// Entries admitting tool artifacts.
1598    pub tool: Vec<String>,
1599    /// `capabilities.install.from`: where a pull may find what it installs.
1600    pub from: InstallFrom,
1601}
1602
1603/// `capabilities.install.from`: the places a pull is answered from. Absent is
1604/// [`InstallFrom::Stores`].
1605#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1606pub enum InstallFrom {
1607    /// `[stores]`: the project store, then the global store. A pull fetches nothing.
1608    #[default]
1609    Stores,
1610    /// `[stores, sources]`: the stores first, then the GitHub source of the artifact's scope,
1611    /// fetched by the runtime itself and written to no store.
1612    StoresAndSources,
1613}
1614
1615impl InstallFrom {
1616    /// Whether a pull may fetch from a scope's source.
1617    #[must_use]
1618    pub fn sources(self) -> bool {
1619        self == Self::StoresAndSources
1620    }
1621
1622    /// The list as a manifest writes it, without brackets: `stores` or `stores, sources`.
1623    #[must_use]
1624    pub fn as_list(self) -> &'static str {
1625        match self {
1626            Self::Stores => "stores",
1627            Self::StoresAndSources => "stores, sources",
1628        }
1629    }
1630}
1631
1632#[derive(Debug, Clone, PartialEq, Eq)]
1633pub struct ShellCapabilities {
1634    pub allow: Vec<String>,
1635    pub strip_env: Option<Vec<String>>,
1636    pub baseline_env: Option<Vec<String>>,
1637    /// Typed grants that widen an already-allowlisted `allow` binary's Landlock filesystem
1638    /// scope to the exact host directories its import machinery needs *outside* the workdir
1639    /// (a path-based interpreter like CPython, whose stdlib the `DT_NEEDED` closure cannot
1640    /// reach). Empty unless the manifest declares `capabilities.shell.interpreter_runtime`.
1641    /// This can only ever name specific directories with an explicit per-directory
1642    /// enumerability flag — it has no field that expands a whole install prefix.
1643    pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1644    /// Typed staged-runtime grants that name a pinned host runtime tree to bind-mount read-only
1645    /// into a `sealed` capsule's composed root. Empty unless the manifest declares
1646    /// `capabilities.shell.staged_runtime`. Mutually exclusive with [`Self::interpreter_runtime`]
1647    /// per binary: staging the tree *into* the root is what makes widening Landlock *out to* the
1648    /// host unnecessary, so declaring both for one binary is a contradiction, not a layering.
1649    pub staged_runtime: Vec<StagedRuntimeGrant>,
1650}
1651
1652/// One `capabilities.shell.interpreter_runtime` entry: an already-allowlisted binary plus the
1653/// exact host directories outside the workdir its import machinery must reach. Declaring one
1654/// couples the capsule to a specific host interpreter-version layout, so it fires `W-SEC-009`
1655/// at staging; it exists only to bridge until the staged runtime bind-mount ships.
1656#[derive(Debug, Clone, PartialEq, Eq)]
1657pub struct InterpreterRuntimeGrant {
1658    /// A binary that MUST already appear in this same block's `allow`. This mechanism can only
1659    /// narrow filesystem access alongside an exec grant that already exists — it never itself
1660    /// grants exec.
1661    pub binary: String,
1662    /// The host directories to grant. Never empty (a grant with no directories is rejected at
1663    /// parse time).
1664    pub dirs: Vec<InterpreterRuntimeDir>,
1665}
1666
1667/// One directory inside an [`InterpreterRuntimeGrant`], with its author-declared enumerability.
1668#[derive(Debug, Clone, PartialEq, Eq)]
1669pub struct InterpreterRuntimeDir {
1670    /// An absolute host path (must start with `/`) outside the workdir.
1671    pub path: String,
1672    /// `true` grants `Execute + ReadFile + ReadDir` — the directory's own entries can be
1673    /// enumerated (what CPython's `FileFinder` needs for a `sys.path` entry). `false` grants
1674    /// `Execute + ReadFile` only — files inside can still be opened by exact name, but the
1675    /// directory itself cannot be listed. Never inferred: the author must write it explicitly.
1676    pub list_dir: bool,
1677}
1678
1679/// One `capabilities.shell.staged_runtime` entry: an already-allowlisted binary, the absolute host
1680/// path of a pinned runtime tree that already exists on the launch host, and the `pin` string
1681/// identifying which build that tree is.
1682///
1683/// The mechanism this describes is the inverse of [`InterpreterRuntimeGrant`]. That one widens a
1684/// capsule's Landlock scope *outwards* so an interpreter can reach its stdlib where the host
1685/// happens to keep it — which couples the capsule to one host's directory layout and is why it
1686/// fires `W-SEC-009`. This one moves the tree *inwards*: the composed root of a `sealed` capsule
1687/// carries the runtime at the same absolute path, bind-mounted read-only, so nothing outside the
1688/// root has to stay reachable. Declaring it therefore requires an effective `sealed` floor — there
1689/// is no composed root to stage into below that — and it is refused alongside an
1690/// `interpreter_runtime` grant for the same binary.
1691///
1692/// `source_path` is deliberately a path to an *already-pinned tree* (a vendored toolchain
1693/// directory, a baked-in conda env), never a bare install prefix guessed at random: the runtime
1694/// does not resolve, discover or version-sniff it.
1695#[derive(Debug, Clone, PartialEq, Eq)]
1696pub struct StagedRuntimeGrant {
1697    /// A binary that MUST already appear in this same block's `allow`. Like
1698    /// [`InterpreterRuntimeGrant::binary`], this mechanism never itself grants exec — it only says
1699    /// where the runtime behind an existing exec grant comes from.
1700    pub binary: String,
1701    /// Absolute host path of the runtime tree to stage. Bind-mounted read-only at this same
1702    /// absolute path inside the composed root, so `sys.prefix`, shebangs and anything a previous
1703    /// turn recorded keep resolving.
1704    pub source_path: String,
1705    /// Non-empty, explicit identifier of *which build* the tree at `source_path` is. Never
1706    /// inferred from the tree's contents: it exists so a human can compare the declared pin across
1707    /// two hosts and confirm the same interpreter build shipped to both. The runtime treats it as
1708    /// an opaque string and does not parse it.
1709    pub pin: String,
1710}
1711
1712#[derive(Debug, Clone, PartialEq, Eq)]
1713pub struct SpawnCapabilities {
1714    pub allow: Vec<String>,
1715    /// `capabilities.spawn.workdir_snapshot`: the entries of [`Self::allow`] whose every
1716    /// delegation starts in a copy of this capsule's work folder as it stood when the delegation
1717    /// was made, rather than in an empty one. Full names, each also in `allow`; empty when the
1718    /// key is absent.
1719    pub workdir_snapshot: Vec<String>,
1720}
1721
1722/// Host environment variables a WASM guest may observe, from `capabilities.env`.
1723///
1724/// An empty `allow` is legitimate (a declared-but-empty block is a no-op), so unlike
1725/// `capabilities.shell.allow` this is not rejected at parse time.
1726#[derive(Debug, Clone, PartialEq, Eq, Default)]
1727pub struct EnvCapabilities {
1728    pub allow: Vec<String>,
1729}
1730
1731/// Per-guest execution limits from `capabilities.limits`.
1732///
1733/// Every field is `Option` because this type carries only what the manifest actually
1734/// declared — an omitted field stays `None` here and the runtime substitutes its own
1735/// default (see `capsule_runtime::limits::ExecutionLimits`). Keeping the "absent" state
1736/// distinct from "explicitly set" is what lets the runtime own the defaults in one place
1737/// instead of duplicating them into the manifest parser.
1738#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1739pub struct ResourceLimits {
1740    /// Cap on a guest's linear-memory growth, in bytes.
1741    pub memory_bytes: Option<usize>,
1742    /// Cap on a guest's table growth, in elements.
1743    pub table_elements: Option<usize>,
1744    /// Cap on the number of instances a single store may create.
1745    pub instances: Option<usize>,
1746    /// Wall-clock budget for a single guest invocation, in seconds.
1747    pub deadline_seconds: Option<u64>,
1748}
1749
1750/// Host-process (OS-level) resource bounds from `capabilities.resources`.
1751///
1752/// Distinct from [`ResourceLimits`] (`capabilities.limits`) in both mechanism and subject:
1753/// that block bounds a WASM *guest* inside its wasmtime store, this one bounds every OS
1754/// *subprocess* the runtime spawns — `rlimit(2)` ceilings applied before `execve`, a Linux
1755/// cgroup v2 scope around the whole process tree, and a periodic workdir-size check. A capsule
1756/// that cannot escape containment can still wedge its host by forking, allocating, or writing
1757/// without bound; this is the block that stops that.
1758///
1759/// Every field is `Option` for the same reason [`ResourceLimits`]'s are: this type carries only
1760/// what the manifest actually declared, and the runtime substitutes its own default for
1761/// anything omitted (see `capsule_runtime::resources::HostResourceLimits`). An omitted field —
1762/// or an omitted block — means defaults, never "unlimited".
1763#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1764pub struct ResourceCapabilities {
1765    /// `RLIMIT_NPROC` headroom for a spawned subprocess that has no cgroup scope: the uid's task
1766    /// count at that spawn plus this value, counted in threads on Linux and processes on macOS.
1767    /// No effect on Linux when the subprocess tree has a cgroup scope, which `cgroup_pids_max`
1768    /// bounds instead.
1769    pub max_processes: Option<u64>,
1770    /// `RLIMIT_NOFILE` hard ceiling on each spawned subprocess.
1771    pub max_open_files: Option<u64>,
1772    /// `RLIMIT_FSIZE` hard ceiling, in bytes, on each spawned subprocess.
1773    pub max_file_size_bytes: Option<u64>,
1774    /// CPU time, in CPU-seconds, after which `RLIMIT_CPU` sends each spawned subprocess
1775    /// `SIGXCPU`, with `SIGKILL` one second later.
1776    pub cpu_seconds: Option<u64>,
1777    /// `RLIMIT_AS` (Linux) / `RLIMIT_DATA` (macOS) hard ceiling, in bytes, on each spawned
1778    /// subprocess.
1779    pub memory_bytes: Option<u64>,
1780    /// cgroup v2 `memory.max`, in bytes — aggregate across the whole subprocess tree. Linux only.
1781    pub cgroup_memory_bytes: Option<u64>,
1782    /// cgroup v2 `pids.max` — aggregate across the whole subprocess tree. Linux only.
1783    pub cgroup_pids_max: Option<u64>,
1784    /// cgroup v2 `cpu.max` quota as a percentage of one core (200 = two cores). Linux only.
1785    pub cgroup_cpu_percent: Option<u32>,
1786    /// cgroup v2 `io.max` read+write bytes/sec on the workdir's backing device. Linux only,
1787    /// best-effort (the backing device cannot always be resolved).
1788    pub cgroup_io_bytes_per_sec: Option<u64>,
1789    /// Ceiling on total workdir size, in bytes, enforced by a periodic check on every platform.
1790    pub workdir_max_bytes: Option<u64>,
1791}
1792
1793/// Durable, capsule-scoped state for one tool, driver or hook: a host directory outside every
1794/// session workdir, preopened into the guest as `state/`.
1795///
1796/// Absent is deny — no declaration means no second preopen and no directory anywhere. The store
1797/// is keyed by capsule rather than by workdir precisely so it survives a launch that gets a fresh
1798/// session workdir, and so two capsules launched in the same directory never share one.
1799#[derive(Debug, Clone, PartialEq, Eq, Default)]
1800pub struct StateCapabilities {
1801    /// Directory name under `~/.murmur/state/`. `None` means "use the capsule name", which is
1802    /// what the overwhelming majority of declarations want.
1803    ///
1804    /// Read from the capsule operator's own manifest entry and never from the artifact's bundled
1805    /// manifest, so a registry-pulled tool cannot claim a store that already exists. The *shape*
1806    /// of the name is not checked here — a store name is a runtime concern, refused as
1807    /// `E-CAP-009`, exactly as `capabilities.filesystem.scope`'s shape is refused as `E-CAP-002`.
1808    pub store: Option<String>,
1809}
1810
1811// ── Containment class ─────────────────────────────────────────────────────────
1812
1813/// How strongly the host must contain a capsule's *subprocess* tree, declared as a floor
1814/// by the operator and satisfied (or not) by the host's kernel.
1815///
1816/// Declaration order is enforcement order — the `Ord` derive is load-bearing, so variants
1817/// must stay listed weakest-first. Combining floors from several sources is `max`, never
1818/// `min` (see [`effective_containment_floor`]): no source can lower what another raised.
1819///
1820/// The class an operator *declares* is a requirement. The class a host actually *achieves*
1821/// is derived only from a live kernel probe (`capsule_runtime::containment`), never from
1822/// this declaration.
1823#[derive(
1824    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1825)]
1826#[serde(rename_all = "snake_case")]
1827pub enum ContainmentClass {
1828    /// No containment at all: subprocesses run as the operator, with their `HOME`, their
1829    /// whole environment, the host filesystem and network, and no `no_new_privs`, so `sudo`
1830    /// works wherever the operator's sudoers allows it. Only the operator chooses it, with
1831    /// `mur run --unconfined` or `containment: unconfined` in `~/.murmur/config.yaml`; a
1832    /// manifest, a project config and `--containment` cannot name it, so it is absent from
1833    /// [`ContainmentClass::DECLARABLE`]. It meets no declared floor.
1834    Unconfined,
1835    /// No kernel mechanism required. Capability declarations are honored by the runtime's
1836    /// own dispatch layer and by convention; anything the host cannot mediate is warned
1837    /// about, not refused. Every host satisfies this, including macOS. The default.
1838    #[default]
1839    Advisory,
1840    /// Landlock filesystem mediation + seccomp exec/network allowlisting over the *host*
1841    /// filesystem: every path outside the workdir must be an explicit manifest grant.
1842    /// Requires Linux 5.13+ with a usable Landlock ABI.
1843    Scoped,
1844    /// A private filesystem root: mount-namespace + `pivot_root` isolation onto a composed
1845    /// root, so the host filesystem is not merely mediated but absent. Achievable on an
1846    /// uncontainerised Linux host with a usable Landlock ABI, unprivileged user namespaces,
1847    /// and — where AppArmor's `restrict_unprivileged_userns` is active — the shipped
1848    /// `mur-sealed` profile loaded. A host that cannot back it refuses the launch with a
1849    /// mechanism-specific reason rather than silently degrading to `Scoped` or `Advisory`.
1850    /// See `docs/content/reference/sealed-containment-manual-verification.md`.
1851    Sealed,
1852}
1853
1854impl ContainmentClass {
1855    /// The lowercase wire name, identical in the manifest, the workspace config, the
1856    /// `--containment` flag, `trace.jsonl`, and `--explain-scope` output.
1857    pub fn as_str(self) -> &'static str {
1858        match self {
1859            Self::Unconfined => "unconfined",
1860            Self::Advisory => "advisory",
1861            Self::Scoped => "scoped",
1862            Self::Sealed => "sealed",
1863        }
1864    }
1865
1866    /// Every value, weakest first.
1867    pub const ALL: [ContainmentClass; 4] =
1868        [Self::Unconfined, Self::Advisory, Self::Scoped, Self::Sealed];
1869
1870    /// The classes a manifest, a project config or `--containment` may name, weakest first —
1871    /// the single source for the "must be one of: …" text.
1872    pub const DECLARABLE: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1873}
1874
1875impl std::fmt::Display for ContainmentClass {
1876    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1877        f.write_str(self.as_str())
1878    }
1879}
1880
1881/// A string that is not a containment class name.
1882#[derive(Debug, Clone, PartialEq, Eq)]
1883pub struct ParseContainmentClassError {
1884    pub value: String,
1885}
1886
1887impl std::fmt::Display for ParseContainmentClassError {
1888    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1889        let names: Vec<&str> = ContainmentClass::DECLARABLE
1890            .iter()
1891            .map(|class| class.as_str())
1892            .collect();
1893        write!(
1894            f,
1895            "must be one of: {}; got '{}'",
1896            names.join(", "),
1897            self.value
1898        )
1899    }
1900}
1901
1902impl std::error::Error for ParseContainmentClassError {}
1903
1904impl std::str::FromStr for ContainmentClass {
1905    type Err = ParseContainmentClassError;
1906
1907    fn from_str(value: &str) -> Result<Self, Self::Err> {
1908        match value.trim() {
1909            "unconfined" => Ok(Self::Unconfined),
1910            "advisory" => Ok(Self::Advisory),
1911            "scoped" => Ok(Self::Scoped),
1912            "sealed" => Ok(Self::Sealed),
1913            other => Err(ParseContainmentClassError {
1914                value: other.to_string(),
1915            }),
1916        }
1917    }
1918}
1919
1920/// Why a manifest cannot declare `capabilities.containment: unconfined`.
1921pub 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";
1922
1923/// The one floor a single `mur run` invocation must clear: the *strongest* class any source
1924/// asked for, defaulting to [`ContainmentClass::Advisory`] when no source declares one.
1925///
1926/// Monotonic by construction — an absent source does not participate, and no present source
1927/// can lower what another raised. Argument order carries no precedence. The inputs are
1928/// declarable classes; the unconfined stance is resolved before a floor is computed, so it
1929/// never reaches this function.
1930pub fn effective_containment_floor(
1931    workspace_default: Option<ContainmentClass>,
1932    manifest_declared: Option<ContainmentClass>,
1933    cli_flag: Option<ContainmentClass>,
1934) -> ContainmentClass {
1935    [workspace_default, manifest_declared, cli_flag]
1936        .into_iter()
1937        .flatten()
1938        .max()
1939        .unwrap_or_default()
1940}
1941
1942#[derive(Debug, Clone, PartialEq, Eq)]
1943pub struct Capabilities {
1944    pub network: Option<NetworkCapabilities>,
1945    /// Which peers this capsule may redeem a peer-file handle against. `None` means no
1946    /// `fetch-peer-file` tool exists and nothing can be fetched — absent is deny.
1947    pub peer_fetch: Option<PeerFetchCapabilities>,
1948    pub filesystem: Option<FilesystemCapabilities>,
1949    pub shell: Option<ShellCapabilities>,
1950    pub spawn: Option<SpawnCapabilities>,
1951    pub env: Option<EnvCapabilities>,
1952    pub limits: Option<ResourceLimits>,
1953    pub resources: Option<ResourceCapabilities>,
1954    /// Durable state store for this artifact. `None` — the overwhelmingly common case — is deny:
1955    /// no `state/` preopen, and no directory created anywhere. See [`StateCapabilities`].
1956    pub state: Option<StateCapabilities>,
1957    /// Per-hook grant of the `murmur:task-io/read` host import. Only ever `Some` on a
1958    /// `runtime: hook` artifact entry — see [`TaskIoCapabilities`].
1959    pub task_io: Option<TaskIoCapabilities>,
1960    /// Per-hook grant of the `murmur:conversation/read` host import — see
1961    /// [`ConversationCapabilities`].
1962    pub conversation: Option<ConversationCapabilities>,
1963    /// Capsule-wide grant of the runtime-provided `submit-plan` tool. `None` is deny: the tool's
1964    /// synthetic manifest is not written, so it is absent from the model's inventory entirely.
1965    pub plan: Option<PlanCapabilities>,
1966    /// Capsule-wide grant to pull skills and tools at runtime. `None` is deny: every
1967    /// `manage.pull` is refused. See [`InstallCapabilities`].
1968    pub install: Option<InstallCapabilities>,
1969    /// Minimum containment class this capsule declares. `None` (the overwhelmingly common
1970    /// case) means the capsule states no requirement and inherits whatever the workspace
1971    /// config or `--containment` asks for, defaulting to `advisory`.
1972    pub containment: Option<ContainmentClass>,
1973}
1974
1975#[derive(Debug, Error)]
1976pub enum RuntimeManifestError {
1977    #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
1978    NotFound(String),
1979    #[error("{0}")]
1980    YamlSyntax(String),
1981    #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
1982    MissingField { field: String },
1983    #[error(
1984        "{}: invalid artifact declaration at index {index}: {message}",
1985        MANIFEST_FILENAME
1986    )]
1987    InvalidArtifact { index: usize, message: String },
1988    #[error(
1989        "{}: invalid inference config for '{field}': {message}",
1990        MANIFEST_FILENAME
1991    )]
1992    InvalidInferenceConfig { field: String, message: String },
1993    #[error(
1994        "{}: invalid capability config for '{field}': {message}",
1995        MANIFEST_FILENAME
1996    )]
1997    InvalidCapabilities { field: String, message: String },
1998    /// A `capabilities.shell.allow` entry that is not one bare binary name. `reason` completes
1999    /// "'<entry>' …".
2000    #[error(
2001        "{}: invalid capability config for 'capabilities.shell.allow[{index}]': '{}' {reason}; \
2002         an entry is a bare binary name",
2003        MANIFEST_FILENAME,
2004        entry.escape_debug()
2005    )]
2006    ShellAllowEntry {
2007        index: usize,
2008        entry: String,
2009        reason: &'static str,
2010    },
2011    #[error(
2012        "{}: invalid exports config for '{field}': {message}",
2013        MANIFEST_FILENAME
2014    )]
2015    InvalidExports { field: String, message: String },
2016    #[error(
2017        "{}: invalid control config for '{field}': {message}",
2018        MANIFEST_FILENAME
2019    )]
2020    InvalidControl { field: String, message: String },
2021    #[error(
2022        "{}: invalid network config for '{field}': {message}",
2023        MANIFEST_FILENAME
2024    )]
2025    InvalidNetworkConfig { field: String, message: String },
2026    /// A credential's `scopes` lists a retired door method name, one of [`RETIRED_DOOR_SCOPES`].
2027    /// `replacement` is the method name that replaces it.
2028    #[error(
2029        "{}: invalid network config for '{field}': '{scope}' is a retired name of a door \
2030         method, and the door answers only the names its card lists",
2031        MANIFEST_FILENAME
2032    )]
2033    RetiredDoorScope {
2034        field: String,
2035        scope: String,
2036        replacement: &'static str,
2037    },
2038    /// `lifecycle.task_acceptance: queue` with `lifecycle.queue_depth: 0`; see
2039    /// [`LifecycleConfig::queue_has_room`].
2040    #[error(
2041        "{}: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves task_acceptance: \
2042         queue no room for a waiting task",
2043        MANIFEST_FILENAME
2044    )]
2045    QueueWithoutRoom,
2046    /// A top-level `mur_version` key, whatever its value. The message names the replacement so a
2047    /// surface that prints only the message still points at it.
2048    #[error(
2049        "{}: 'mur_version' is not a top-level key — the mur release a deploy installs is pinned \
2050         as 'deploy.mur_version'",
2051        MANIFEST_FILENAME
2052    )]
2053    TopLevelMurVersion,
2054    #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
2055    InvalidTraceConfig { field: String, message: String },
2056    /// A `lifecycle` field holds a value the runtime cannot act on; `field` is its dotted path.
2057    #[error(
2058        "{}: invalid lifecycle config for '{field}': {message}",
2059        MANIFEST_FILENAME
2060    )]
2061    InvalidLifecycleConfig { field: String, message: String },
2062    /// A field naming an artifact to resolve does not hold a full `<scope>/<name>`, or a
2063    /// `source:` entry's name is not a bare name. `hint` names the form to write instead.
2064    #[error("{}: invalid artifact name in '{field}': {message}", MANIFEST_FILENAME)]
2065    InvalidArtifactName {
2066        field: String,
2067        message: String,
2068        hint: Option<String>,
2069    },
2070    /// The manifest declares a top-level [`REGISTRY_BLOCK_KEY`].
2071    #[error("{}", REGISTRY_BLOCK_MESSAGE)]
2072    RegistryBlock,
2073    /// The manifest names [`HOST_PATHS_KEY`] at the top level, under `capabilities`, under
2074    /// `capabilities.filesystem`, or under an artifact entry's `capabilities`; `field` is the
2075    /// dotted path. Only the operator's `~/.murmur/config.yaml` lends a host folder.
2076    #[error(
2077        "{}: '{field}' cannot be declared in a manifest — host folders are lent by the operator, \
2078         never asked for by a capsule",
2079        MANIFEST_FILENAME
2080    )]
2081    HostPathsDeclared { field: String },
2082    /// A `gateway:` names an upstream but neither binds a non-blank `api_key` nor declares
2083    /// `keyless: true`. Decided on what the manifest wrote, so it fires identically with and
2084    /// without secret resolution.
2085    #[error(
2086        "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
2087         gateway.endpoint '{endpoint}' but binds no credential",
2088        MANIFEST_FILENAME
2089    )]
2090    GatewayWithoutCredential {
2091        index: usize,
2092        name: String,
2093        endpoint: String,
2094    },
2095    #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
2096    Io {
2097        path: String,
2098        #[source]
2099        source: std::io::Error,
2100    },
2101}
2102
2103#[derive(Debug, Deserialize)]
2104struct RawRuntimeManifest {
2105    name: Option<String>,
2106    version: Option<String>,
2107    #[serde(default)]
2108    artifacts: Vec<RawArtifact>,
2109    #[serde(default)]
2110    capabilities: Option<RawCapabilities>,
2111    #[serde(default)]
2112    inference: Option<RawInferenceConfig>,
2113    #[serde(default)]
2114    context: Option<RawContextConfig>,
2115    #[serde(default)]
2116    observability: Option<RawObservabilityConfig>,
2117    #[serde(default)]
2118    trace: Option<RawTraceConfig>,
2119    #[serde(default)]
2120    network: Option<RawNetworkConfig>,
2121    #[serde(default)]
2122    lifecycle: Option<RawLifecycleConfig>,
2123    #[serde(default)]
2124    exports: Option<RawExports>,
2125    #[serde(default)]
2126    control: Option<RawControl>,
2127    #[serde(default)]
2128    deploy: Option<RawDeploy>,
2129    /// Captured only to refuse it, as [`RuntimeManifestError::TopLevelMurVersion`]: the pin is
2130    /// `deploy.mur_version`. Present-detection rather than a typed value, so a number, a blank
2131    /// string and a bare `mur_version:` are all refused.
2132    #[serde(default, deserialize_with = "deserialize_present")]
2133    mur_version: Option<serde_norway::Value>,
2134    /// Captured only to refuse it. `config:` is delivered to one artifact through that artifact's
2135    /// own grant, so a capsule-wide block reaches nothing; without this field it would be one of
2136    /// the unrecognized top-level keys this manifest silently ignores.
2137    #[serde(default, deserialize_with = "deserialize_present")]
2138    config: Option<serde_norway::Value>,
2139    /// Read by `mur build` and `mur publish` off this same file to select the packaging type.
2140    /// Declared here so a manifest that is both runnable and publishable does not report its own
2141    /// build-side keys as unrecognized; the run path takes no meaning from it and never inspects
2142    /// the value, so any shape parses.
2143    #[serde(default)]
2144    #[allow(dead_code)]
2145    runtime: Option<serde_norway::Value>,
2146    /// Read by `mur build` and `mur publish`, which accept only `wasm`. Ignored on the run path,
2147    /// and untyped for the same reason as `runtime:` above.
2148    #[serde(default)]
2149    #[allow(dead_code)]
2150    implementation: Option<serde_norway::Value>,
2151    /// Read by `mur build` and `mur publish` as the declared packaging type, which overrides the
2152    /// one derived from `runtime:`. Ignored on the run path, and untyped for
2153    /// the same reason as `runtime:` above.
2154    #[serde(default)]
2155    #[allow(dead_code)]
2156    execution: Option<serde_norway::Value>,
2157    /// Read by `mur build` as the list of companion files to pack beside the manifest. Ignored on
2158    /// the run path, and untyped for the same reason as `runtime:` above.
2159    #[serde(default)]
2160    #[allow(dead_code)]
2161    requires_files: Option<serde_norway::Value>,
2162    #[serde(flatten)]
2163    unknown: UnknownKeys,
2164}
2165
2166#[derive(Debug, Deserialize)]
2167struct RawDeploy {
2168    #[serde(default)]
2169    mur_version: Option<String>,
2170    #[serde(flatten)]
2171    unknown: UnknownKeys,
2172}
2173
2174#[derive(Debug, Deserialize)]
2175struct RawControl {
2176    /// Untyped, so a value that is not a list of strings is refused as an
2177    /// [`RuntimeManifestError::InvalidControl`] naming the field rather than as a serde type error
2178    /// naming a line number.
2179    #[serde(default)]
2180    settings: Option<serde_norway::Value>,
2181    #[serde(default)]
2182    secrets: Option<serde_norway::Value>,
2183    #[serde(default)]
2184    agent_settings: Option<serde_norway::Value>,
2185    #[serde(flatten)]
2186    unknown: UnknownKeys,
2187}
2188
2189#[derive(Debug, Deserialize)]
2190struct RawExports {
2191    #[serde(default)]
2192    files: Option<RawFileExport>,
2193    #[serde(default)]
2194    peer_files: Option<RawPeerFilesExport>,
2195    #[serde(default)]
2196    peer_tasks: Option<RawPeerTasksExport>,
2197    #[serde(flatten)]
2198    unknown: UnknownKeys,
2199}
2200
2201#[derive(Debug, Deserialize)]
2202struct RawPeerTasksExport {
2203    // `Option` so an omitted `accept` is distinguishable from an explicit `false`: the omission
2204    // is refused rather than defaulted, because consent is never inferred.
2205    #[serde(default)]
2206    accept: Option<bool>,
2207    #[serde(flatten)]
2208    unknown: UnknownKeys,
2209}
2210
2211#[derive(Debug, Deserialize)]
2212struct RawPeerFilesExport {
2213    #[serde(default)]
2214    root: Option<String>,
2215    /// Untyped for the same reason as [`RawFileExport::max_bytes`]: `30m` is a YAML string and a
2216    /// bare `1800` is a YAML integer, and a wrong-shaped value has to reach the parser to be
2217    /// reported as an [`RuntimeManifestError::InvalidExports`] naming the field.
2218    #[serde(default)]
2219    max_ttl: Option<serde_norway::Value>,
2220    #[serde(default)]
2221    max_bytes: Option<serde_norway::Value>,
2222    #[serde(flatten)]
2223    unknown: UnknownKeys,
2224}
2225
2226#[derive(Debug, Deserialize)]
2227struct RawFileExport {
2228    #[serde(default)]
2229    root: Option<String>,
2230    #[serde(default)]
2231    mode: Option<String>,
2232    /// Deliberately untyped: `max_bytes` accepts both a bare integer and a suffixed string, and
2233    /// a wrong-shaped value has to reach [`parse_exports`] to be reported as an
2234    /// [`RuntimeManifestError::InvalidExports`] naming the field rather than as a serde type
2235    /// error naming a line number.
2236    #[serde(default)]
2237    max_bytes: Option<serde_norway::Value>,
2238    #[serde(flatten)]
2239    unknown: UnknownKeys,
2240}
2241
2242#[derive(Debug, Deserialize)]
2243struct RawLifecycleConfig {
2244    #[serde(default)]
2245    task_acceptance: Option<TaskAcceptance>,
2246    #[serde(default)]
2247    after_task: Option<AfterTask>,
2248    #[serde(default)]
2249    queue_depth: Option<usize>,
2250    #[serde(default)]
2251    input_timeout_secs: Option<u64>,
2252    #[serde(default)]
2253    conversation: Option<ConversationMode>,
2254    #[serde(default)]
2255    max_task_reopens: Option<u32>,
2256    #[serde(default)]
2257    shell_grace_secs: Option<u64>,
2258    #[serde(default)]
2259    delegation_deadline_secs: Option<u64>,
2260    #[serde(default)]
2261    deadline_notice_secs: Option<u64>,
2262    #[serde(default)]
2263    keep_working: Option<RawKeepWorking>,
2264    #[serde(default)]
2265    done_check: Option<RawDoneCheck>,
2266    #[serde(flatten)]
2267    unknown: UnknownKeys,
2268}
2269
2270#[derive(Debug, Deserialize)]
2271struct RawDoneCheck {
2272    #[serde(default)]
2273    command: Option<String>,
2274    #[serde(default)]
2275    limit: Option<u32>,
2276    #[serde(default)]
2277    timeout_secs: Option<u64>,
2278    #[serde(flatten)]
2279    unknown: UnknownKeys,
2280}
2281
2282#[derive(Debug, Deserialize)]
2283struct RawKeepWorking {
2284    #[serde(default)]
2285    message: Option<String>,
2286    #[serde(default)]
2287    limit: Option<u32>,
2288    #[serde(flatten)]
2289    unknown: UnknownKeys,
2290}
2291
2292#[derive(Debug, Deserialize)]
2293struct RawNetworkConfig {
2294    internal_port: Option<u16>,
2295    #[serde(default)]
2296    authentication: Option<RawNetworkAuthentication>,
2297    #[serde(flatten)]
2298    unknown: UnknownKeys,
2299}
2300
2301#[derive(Debug, Deserialize)]
2302struct RawNetworkAuthentication {
2303    #[serde(default)]
2304    scheme: Option<String>,
2305    /// Keyed by credential name. A `BTreeMap`, so the lowered credentials come out sorted by name
2306    /// whatever order the manifest wrote them in.
2307    #[serde(default)]
2308    credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
2309    #[serde(flatten)]
2310    unknown: UnknownKeys,
2311}
2312
2313#[derive(Debug, Deserialize)]
2314struct RawDoorCredential {
2315    /// Untyped, so a value that is not a list of strings is refused as an
2316    /// [`RuntimeManifestError::InvalidNetworkConfig`] naming the field rather than as a serde type
2317    /// error naming a line number.
2318    #[serde(default)]
2319    scopes: Option<serde_norway::Value>,
2320    #[serde(flatten)]
2321    unknown: UnknownKeys,
2322}
2323
2324#[derive(Debug, Deserialize)]
2325struct RawContextConfig {
2326    max_tokens: Option<u32>,
2327    /// `on` | `off`. Kept as a raw `String` so an unrecognized value reports as an invalid
2328    /// value for this key rather than as a serde variant error against the whole block.
2329    #[serde(default)]
2330    record: Option<String>,
2331    #[serde(default)]
2332    record_store: Option<String>,
2333    #[serde(default)]
2334    seed_budget: Option<f32>,
2335    #[serde(default)]
2336    seed_overflow_margin: Option<f32>,
2337    /// Untyped so an empty block, a null block and an unknown key inside it are each reported
2338    /// as an invalid `context.retain` rather than as a serde message about a struct field.
2339    #[serde(default, deserialize_with = "present_yaml_value")]
2340    retain: Option<serde_norway::Value>,
2341    #[serde(default)]
2342    images: Option<RawContextImages>,
2343    #[serde(flatten)]
2344    unknown: UnknownKeys,
2345}
2346
2347/// Untyped values, so `10MB` or `-1` reaches [`parse_context_images`] and is reported as an
2348/// invalid `context.images.*` naming the key rather than as a serde type error.
2349#[derive(Debug, Deserialize)]
2350struct RawContextImages {
2351    #[serde(default)]
2352    keep: Option<serde_norway::Value>,
2353    #[serde(default)]
2354    max_bytes: Option<serde_norway::Value>,
2355    #[serde(default)]
2356    max_per_turn: Option<serde_norway::Value>,
2357    #[serde(flatten)]
2358    unknown: UnknownKeys,
2359}
2360
2361#[derive(Debug, Deserialize)]
2362struct RawObservabilityConfig {
2363    #[serde(default)]
2364    otel_endpoint: Option<String>,
2365    #[serde(default)]
2366    eval: Option<RawEvalConfig>,
2367    #[serde(flatten)]
2368    unknown: UnknownKeys,
2369}
2370
2371#[derive(Debug, Deserialize)]
2372struct RawTraceConfig {
2373    /// Untyped so an unparseable mode reaches [`resolve_trace_capture`] and is reported as a
2374    /// [`RuntimeManifestError::InvalidTraceConfig`] naming the field and the accepted values,
2375    /// rather than as a serde message about an unknown variant.
2376    #[serde(default)]
2377    capture: Option<String>,
2378    /// Untyped for the same reason as `context.retain` — see [`RawContextConfig::retain`].
2379    #[serde(default, deserialize_with = "present_yaml_value")]
2380    retain: Option<serde_norway::Value>,
2381    #[serde(flatten)]
2382    unknown: UnknownKeys,
2383}
2384
2385#[derive(Debug, Deserialize)]
2386struct RawEvalConfig {
2387    #[serde(default)]
2388    dataset_id: Option<String>,
2389    #[serde(default)]
2390    scorers: Vec<RawScorerConfig>,
2391    #[serde(flatten)]
2392    unknown: UnknownKeys,
2393}
2394
2395#[derive(Debug, Deserialize)]
2396struct RawScorerConfig {
2397    #[serde(rename = "type")]
2398    scorer_type: String,
2399    name: Option<String>,
2400    max: Option<u64>,
2401    expected: Option<Vec<String>>,
2402    #[serde(flatten)]
2403    unknown: UnknownKeys,
2404}
2405
2406#[derive(Debug, Deserialize)]
2407struct RawArtifact {
2408    name: Option<String>,
2409    version: Option<String>,
2410    runtime: Option<String>,
2411    #[serde(default)]
2412    source: Option<String>,
2413    #[serde(default)]
2414    local_source: Option<bool>,
2415    #[serde(default)]
2416    prompt_payload: Option<bool>,
2417    #[serde(default)]
2418    capabilities: Option<RawCapabilities>,
2419    #[serde(default)]
2420    on_overflow: Option<String>,
2421    /// Untyped, and deserialized through [`deserialize_present`] rather than plain `Option`, so
2422    /// `config:` written with nothing under it arrives as `Some(Value::Null)` instead of
2423    /// collapsing into the absent case. The two mean different things: absent grants no variable,
2424    /// while an empty declaration is a written statement that carries nothing and is refused.
2425    #[serde(default, deserialize_with = "deserialize_present")]
2426    config: Option<serde_norway::Value>,
2427    /// `Some(None)` is `gateway:` written with nothing under it, refused as a block with no
2428    /// endpoint rather than collapsed into the absent case.
2429    #[serde(default, deserialize_with = "deserialize_present")]
2430    gateway: Option<Option<RawArtifactGateway>>,
2431    #[serde(flatten)]
2432    unknown: UnknownKeys,
2433}
2434
2435#[derive(Debug, Deserialize)]
2436struct RawArtifactGateway {
2437    #[serde(default)]
2438    endpoint: Option<String>,
2439    /// `Some(None)` is `api_key:` written as YAML null: present for the contradiction with
2440    /// `keyless: true`, and no credential for the missing-credential refusal.
2441    #[serde(default, deserialize_with = "deserialize_present")]
2442    api_key: Option<Option<String>>,
2443    /// Untyped, so a non-boolean is refused as an invalid declaration naming the field.
2444    #[serde(default)]
2445    keyless: Option<serde_norway::Value>,
2446    #[serde(flatten)]
2447    unknown: UnknownKeys,
2448}
2449
2450/// Deserialize a present key into `Some(value)` even when its value is YAML null, leaving `None`
2451/// to mean the key was absent and `#[serde(default)]` supplied it.
2452///
2453/// serde's own `Option` impl maps a null value to `None`, which erases exactly the distinction
2454/// `config:` rests on.
2455fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
2456where
2457    T: Deserialize<'de>,
2458    D: serde::Deserializer<'de>,
2459{
2460    T::deserialize(deserializer).map(Some)
2461}
2462
2463#[derive(Debug, Deserialize)]
2464struct RawCapabilities {
2465    #[serde(default)]
2466    network: Option<RawNetworkCapabilities>,
2467    #[serde(default)]
2468    peer_fetch: Option<RawPeerFetchCapabilities>,
2469    #[serde(default)]
2470    filesystem: Option<RawFilesystemCapabilities>,
2471    #[serde(default)]
2472    shell: Option<RawShellCapabilities>,
2473    #[serde(default)]
2474    spawn: Option<RawSpawnCapabilities>,
2475    #[serde(default)]
2476    env: Option<RawEnvCapabilities>,
2477    #[serde(default)]
2478    limits: Option<RawResourceLimits>,
2479    #[serde(default)]
2480    resources: Option<RawResourceCapabilities>,
2481    #[serde(default)]
2482    state: Option<RawStateCapabilities>,
2483    #[serde(default)]
2484    task_io: Option<RawTaskIoCapabilities>,
2485    #[serde(default)]
2486    conversation: Option<RawConversationCapabilities>,
2487    #[serde(default)]
2488    plan: Option<RawPlanCapabilities>,
2489    #[serde(default)]
2490    install: Option<RawInstallCapabilities>,
2491    /// Kept as a raw `String` rather than a `ContainmentClass` so a typo reports through
2492    /// `InvalidCapabilities` like every other bad capability value, instead of a bare serde
2493    /// "unknown variant" error attributed to the whole `capabilities:` block.
2494    #[serde(default)]
2495    containment: Option<String>,
2496    #[serde(flatten)]
2497    unknown: UnknownKeys,
2498}
2499
2500#[derive(Debug, Deserialize)]
2501struct RawTaskIoCapabilities {
2502    // `Option` so an omitted `read` is distinguishable from an explicit `false`: the omission
2503    // is rejected outright rather than defaulted, because a capability is never inferred.
2504    #[serde(default)]
2505    read: Option<bool>,
2506    #[serde(flatten)]
2507    unknown: UnknownKeys,
2508}
2509
2510#[derive(Debug, Deserialize)]
2511struct RawConversationCapabilities {
2512    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2513    // than defaulted.
2514    #[serde(default)]
2515    read: Option<bool>,
2516    #[serde(flatten)]
2517    unknown: UnknownKeys,
2518}
2519
2520#[derive(Debug, Deserialize)]
2521struct RawPlanCapabilities {
2522    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2523    // than defaulted.
2524    #[serde(default)]
2525    submit: Option<bool>,
2526    #[serde(flatten)]
2527    unknown: UnknownKeys,
2528}
2529
2530#[derive(Debug, Deserialize)]
2531struct RawInstallCapabilities {
2532    #[serde(default)]
2533    skill: Vec<String>,
2534    #[serde(default)]
2535    tool: Vec<String>,
2536    #[serde(default)]
2537    from: Option<Vec<String>>,
2538    #[serde(flatten)]
2539    unknown: UnknownKeys,
2540}
2541
2542#[derive(Debug, Deserialize)]
2543struct RawSpawnCapabilities {
2544    #[serde(default)]
2545    allow: Vec<String>,
2546    #[serde(default)]
2547    workdir_snapshot: Vec<String>,
2548    #[serde(flatten)]
2549    unknown: UnknownKeys,
2550}
2551
2552#[derive(Debug, Deserialize)]
2553struct RawEnvCapabilities {
2554    #[serde(default)]
2555    allow: Vec<String>,
2556    #[serde(flatten)]
2557    unknown: UnknownKeys,
2558}
2559
2560#[derive(Debug, Deserialize)]
2561struct RawResourceLimits {
2562    #[serde(default)]
2563    memory_bytes: Option<usize>,
2564    #[serde(default)]
2565    table_elements: Option<usize>,
2566    #[serde(default)]
2567    instances: Option<usize>,
2568    #[serde(default)]
2569    deadline_seconds: Option<u64>,
2570    #[serde(flatten)]
2571    unknown: UnknownKeys,
2572}
2573
2574#[derive(Debug, Deserialize)]
2575struct RawStateCapabilities {
2576    #[serde(default)]
2577    store: Option<String>,
2578    #[serde(flatten)]
2579    unknown: UnknownKeys,
2580}
2581
2582#[derive(Debug, Deserialize)]
2583struct RawResourceCapabilities {
2584    #[serde(default)]
2585    max_processes: Option<u64>,
2586    #[serde(default)]
2587    max_open_files: Option<u64>,
2588    #[serde(default)]
2589    max_file_size_bytes: Option<u64>,
2590    #[serde(default)]
2591    cpu_seconds: Option<u64>,
2592    #[serde(default)]
2593    memory_bytes: Option<u64>,
2594    #[serde(default)]
2595    cgroup_memory_bytes: Option<u64>,
2596    #[serde(default)]
2597    cgroup_pids_max: Option<u64>,
2598    #[serde(default)]
2599    cgroup_cpu_percent: Option<u32>,
2600    #[serde(default)]
2601    cgroup_io_bytes_per_sec: Option<u64>,
2602    #[serde(default)]
2603    workdir_max_bytes: Option<u64>,
2604    #[serde(flatten)]
2605    unknown: UnknownKeys,
2606}
2607
2608#[derive(Debug, Deserialize)]
2609struct RawNetworkCapabilities {
2610    #[serde(default)]
2611    allow: Vec<String>,
2612    #[serde(default)]
2613    unix_sockets: bool,
2614    #[serde(flatten)]
2615    unknown: UnknownKeys,
2616}
2617
2618#[derive(Debug, Deserialize)]
2619struct RawPeerFetchCapabilities {
2620    #[serde(default)]
2621    allow: Vec<String>,
2622    #[serde(flatten)]
2623    unknown: UnknownKeys,
2624}
2625
2626#[derive(Debug, Deserialize)]
2627struct RawFilesystemCapabilities {
2628    #[serde(default)]
2629    scope: Option<String>,
2630    /// Same shape and defaulting convention as `RawNetworkCapabilities::unix_sockets`: a plain
2631    /// `bool` that is always present after parsing and defaults to `false`, never an `Option`.
2632    /// "The key is absent" and "the key says `false`" are the same declaration.
2633    #[serde(default)]
2634    workdir_exec: bool,
2635    /// Always a list after parsing, never an `Option`: an absent key and an empty list are the
2636    /// same declaration — nothing is protected.
2637    #[serde(default)]
2638    read_only: Vec<String>,
2639    /// Same defaulting convention as `workdir_exec`.
2640    #[serde(default)]
2641    records: bool,
2642    #[serde(flatten)]
2643    unknown: UnknownKeys,
2644}
2645
2646#[derive(Debug, Deserialize)]
2647struct RawShellCapabilities {
2648    #[serde(default)]
2649    allow: Vec<String>,
2650    #[serde(default)]
2651    strip_env: Option<Vec<String>>,
2652    #[serde(default)]
2653    baseline_env: Option<Vec<String>>,
2654    #[serde(default)]
2655    interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
2656    #[serde(default)]
2657    staged_runtime: Vec<RawStagedRuntimeGrant>,
2658    #[serde(flatten)]
2659    unknown: UnknownKeys,
2660}
2661
2662#[derive(Debug, Deserialize)]
2663struct RawInterpreterRuntimeGrant {
2664    #[serde(default)]
2665    binary: Option<String>,
2666    #[serde(default)]
2667    dirs: Vec<RawInterpreterRuntimeDir>,
2668    #[serde(flatten)]
2669    unknown: UnknownKeys,
2670}
2671
2672#[derive(Debug, Deserialize)]
2673struct RawInterpreterRuntimeDir {
2674    #[serde(default)]
2675    path: Option<String>,
2676    // `Option` so an omitted `list_dir` is distinguishable from an explicit `false`: the
2677    // omission is rejected outright rather than defaulted, because enumerability is never
2678    // inferred.
2679    #[serde(default)]
2680    list_dir: Option<bool>,
2681    #[serde(flatten)]
2682    unknown: UnknownKeys,
2683}
2684
2685#[derive(Debug, Deserialize)]
2686struct RawStagedRuntimeGrant {
2687    #[serde(default)]
2688    binary: Option<String>,
2689    #[serde(default)]
2690    source_path: Option<String>,
2691    // `Option` so an omitted `pin` reaches `parse_staged_runtime` and is rejected there with a
2692    // field-naming message, rather than defaulting to an empty string that would silently mean
2693    // "unpinned" — the one thing this field exists to make impossible.
2694    #[serde(default)]
2695    pin: Option<String>,
2696    #[serde(flatten)]
2697    unknown: UnknownKeys,
2698}
2699
2700#[derive(Debug, Deserialize)]
2701struct RawInferenceConfig {
2702    transport: Option<String>,
2703    /// Removed: the driver's upstream is `gateway.endpoint` on its artifact entry. Deserialized
2704    /// only so `parse_inference` can refuse it by name, naming where it moved.
2705    #[serde(default, deserialize_with = "deserialize_present")]
2706    endpoint: Option<serde_norway::Value>,
2707    model: Option<String>,
2708    /// Removed: the driver's key is `gateway.api_key` on its artifact entry. Deserialized only so
2709    /// `parse_inference` can refuse it by name, naming where it moved.
2710    #[serde(default, deserialize_with = "deserialize_present")]
2711    api_key: Option<serde_norway::Value>,
2712    #[serde(default)]
2713    command: Option<String>,
2714    #[serde(default)]
2715    system_prompt: Option<String>,
2716    #[serde(default)]
2717    system_prompt_file: Option<String>,
2718    #[serde(default)]
2719    system_prompt_artifact: Option<String>,
2720    #[serde(default)]
2721    instruction_files: Option<Vec<String>>,
2722    #[serde(default)]
2723    driver: Option<RawInferenceDriver>,
2724    #[serde(default)]
2725    compaction: Option<RawCompactionConfig>,
2726    #[serde(default)]
2727    max_turns: Option<u32>,
2728    /// The `inference` spelling of the reopen budget is not a live field. It is deserialized
2729    /// only so `parse_inference` can refuse it; without it serde would ignore the key.
2730    #[serde(default)]
2731    max_task_reopens: Option<u32>,
2732    #[serde(default)]
2733    max_tokens: Option<u32>,
2734    #[serde(default)]
2735    max_session_tokens: Option<u64>,
2736    #[serde(default)]
2737    tool_refresh: Option<String>,
2738    #[serde(default)]
2739    alternates: Option<Vec<RawInferenceAlternate>>,
2740    #[serde(default)]
2741    retry: Option<RawInferenceRetry>,
2742    #[serde(flatten)]
2743    unknown: UnknownKeys,
2744}
2745
2746#[derive(Debug, Deserialize)]
2747struct RawInferenceRetry {
2748    #[serde(default)]
2749    max_retries: Option<u32>,
2750    #[serde(default)]
2751    initial_delay_ms: Option<u32>,
2752    #[serde(default)]
2753    max_delay_ms: Option<u32>,
2754    #[serde(flatten)]
2755    unknown: UnknownKeys,
2756}
2757
2758#[derive(Debug, Deserialize)]
2759struct RawInferenceAlternate {
2760    #[serde(default)]
2761    name: Option<String>,
2762    #[serde(default)]
2763    model: Option<String>,
2764    #[serde(default)]
2765    driver: Option<RawInferenceDriver>,
2766    #[serde(flatten)]
2767    unknown: UnknownKeys,
2768}
2769
2770#[derive(Debug, Deserialize)]
2771struct RawCompactionConfig {
2772    threshold: Option<f32>,
2773    model: Option<String>,
2774    system_prompt: Option<String>,
2775    system_prompt_file: Option<String>,
2776    dump_summaries: Option<bool>,
2777    max_tokens: Option<u32>,
2778    #[serde(default)]
2779    driver_config: Option<serde_norway::Value>,
2780    #[serde(flatten)]
2781    unknown: UnknownKeys,
2782}
2783
2784#[derive(Debug, Deserialize)]
2785struct RawInferenceDriver {
2786    artifact: Option<String>,
2787    #[serde(default)]
2788    config: Option<serde_norway::Value>,
2789    #[serde(flatten)]
2790    unknown: UnknownKeys,
2791}
2792
2793/// The keys one `Raw*` block carried that no field of it claims.
2794///
2795/// A `BTreeMap` so the capture order is the manifest's key order sorted, which keeps the
2796/// `W-SEC-019` lines for one manifest identical on every run. The values are discarded: a key this
2797/// build does not recognize is reported by name and by containing block, and printing whatever an
2798/// operator wrote under it would put manifest contents into a diagnostic that has no use for them.
2799type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2800
2801/// A `Raw*` deserialization block that captures the keys this build does not recognize.
2802///
2803/// Implemented for every `Raw*` struct in this file, which is asserted structurally by
2804/// `every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names` rather than left to
2805/// review: a manifest key added without its block learning about it is exactly the defect
2806/// `W-SEC-019` exists to report.
2807trait RawBlock {
2808    /// Every key serde accepts on this block, in declaration order and under the name serde
2809    /// matches (so a `#[serde(rename)]` field appears under its renamed spelling). This is the
2810    /// candidate set [`crate::unknown_manifest_keys::nearest_known_key`] suggests from.
2811    const KNOWN_KEYS: &'static [&'static str];
2812
2813    fn unknown_keys(&self) -> &UnknownKeys;
2814
2815    /// Descends into the blocks this one owns, in declaration order, so the reported paths read
2816    /// down the manifest the way the document does.
2817    ///
2818    /// Defaulted to a no-op, which is correct for a block that owns no other block. A block that
2819    /// does own one must override this: `every_raw_struct_descends_into_the_blocks_it_owns` reads
2820    /// this file's own text and fails, naming the struct and the field, if it does not — an
2821    /// unwalked block reports none of its keys, silently.
2822    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2823        let _ = (path, out);
2824    }
2825}
2826
2827impl RawBlock for RawRuntimeManifest {
2828    const KNOWN_KEYS: &'static [&'static str] = &[
2829        "name",
2830        "version",
2831        "artifacts",
2832        "capabilities",
2833        "inference",
2834        "context",
2835        "observability",
2836        "trace",
2837        "network",
2838        "lifecycle",
2839        "exports",
2840        "control",
2841        "deploy",
2842        "mur_version",
2843        "config",
2844        "runtime",
2845        "implementation",
2846        "execution",
2847        "requires_files",
2848    ];
2849    fn unknown_keys(&self) -> &UnknownKeys {
2850        &self.unknown
2851    }
2852
2853    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2854        let artifacts = child_path(path, "artifacts");
2855        for (index, artifact) in self.artifacts.iter().enumerate() {
2856            collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2857        }
2858        if let Some(capabilities) = &self.capabilities {
2859            collect_block(capabilities, &child_path(path, "capabilities"), out);
2860        }
2861        if let Some(inference) = &self.inference {
2862            collect_block(inference, &child_path(path, "inference"), out);
2863        }
2864        if let Some(context) = &self.context {
2865            collect_block(context, &child_path(path, "context"), out);
2866        }
2867        if let Some(observability) = &self.observability {
2868            collect_block(observability, &child_path(path, "observability"), out);
2869        }
2870        if let Some(trace) = &self.trace {
2871            collect_block(trace, &child_path(path, "trace"), out);
2872        }
2873        if let Some(network) = &self.network {
2874            collect_block(network, &child_path(path, "network"), out);
2875        }
2876        if let Some(lifecycle) = &self.lifecycle {
2877            collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2878        }
2879        if let Some(exports) = &self.exports {
2880            collect_block(exports, &child_path(path, "exports"), out);
2881        }
2882        if let Some(control) = &self.control {
2883            collect_block(control, &child_path(path, "control"), out);
2884        }
2885        if let Some(deploy) = &self.deploy {
2886            collect_block(deploy, &child_path(path, "deploy"), out);
2887        }
2888    }
2889}
2890
2891impl RawBlock for RawDeploy {
2892    const KNOWN_KEYS: &'static [&'static str] = &["mur_version"];
2893    fn unknown_keys(&self) -> &UnknownKeys {
2894        &self.unknown
2895    }
2896}
2897
2898impl RawBlock for RawControl {
2899    const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
2900    fn unknown_keys(&self) -> &UnknownKeys {
2901        &self.unknown
2902    }
2903}
2904
2905impl RawBlock for RawExports {
2906    const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files", "peer_tasks"];
2907    fn unknown_keys(&self) -> &UnknownKeys {
2908        &self.unknown
2909    }
2910
2911    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2912        if let Some(files) = &self.files {
2913            collect_block(files, &child_path(path, "files"), out);
2914        }
2915        if let Some(peer_files) = &self.peer_files {
2916            collect_block(peer_files, &child_path(path, "peer_files"), out);
2917        }
2918        if let Some(peer_tasks) = &self.peer_tasks {
2919            collect_block(peer_tasks, &child_path(path, "peer_tasks"), out);
2920        }
2921    }
2922}
2923
2924impl RawBlock for RawPeerTasksExport {
2925    const KNOWN_KEYS: &'static [&'static str] = &["accept"];
2926    fn unknown_keys(&self) -> &UnknownKeys {
2927        &self.unknown
2928    }
2929}
2930
2931impl RawBlock for RawPeerFilesExport {
2932    const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2933    fn unknown_keys(&self) -> &UnknownKeys {
2934        &self.unknown
2935    }
2936}
2937
2938impl RawBlock for RawFileExport {
2939    const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2940    fn unknown_keys(&self) -> &UnknownKeys {
2941        &self.unknown
2942    }
2943}
2944
2945/// Resolves `lifecycle.keep_working`, refusing a missing or blank `message` and a missing or zero
2946/// `limit`. The message keeps the operator's bytes as written: only the blank check trims.
2947fn parse_keep_working(raw: RawKeepWorking) -> Result<KeepWorking, RuntimeManifestError> {
2948    let invalid = |field: &str, message: &str| RuntimeManifestError::InvalidLifecycleConfig {
2949        field: format!("lifecycle.keep_working.{field}"),
2950        message: message.to_string(),
2951    };
2952    let message = match raw.message {
2953        None => return Err(invalid("message", "is required")),
2954        Some(message) if message.trim().is_empty() => {
2955            return Err(invalid("message", "must not be blank"))
2956        }
2957        Some(message) => message,
2958    };
2959    let limit = match raw.limit {
2960        None => return Err(invalid("limit", "is required")),
2961        Some(0) => {
2962            return Err(invalid(
2963                "limit",
2964                "must be at least 1; omit lifecycle.keep_working to turn the option off",
2965            ))
2966        }
2967        Some(limit) => limit,
2968    };
2969    Ok(KeepWorking { message, limit })
2970}
2971
2972/// Resolves `lifecycle.done_check`, refusing a missing, blank or wordless `command`, a command
2973/// whose program `shell_allow` (`capabilities.shell.allow`) does not list, a missing `limit` and a
2974/// zero `timeout_secs`. The command keeps the operator's bytes as written.
2975fn parse_done_check(
2976    raw: RawDoneCheck,
2977    shell_allow: &[String],
2978) -> Result<DoneCheck, RuntimeManifestError> {
2979    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidLifecycleConfig {
2980        field: format!("lifecycle.done_check.{field}"),
2981        message,
2982    };
2983    let command = match raw.command {
2984        None => return Err(invalid("command", "is required".into())),
2985        Some(command) if command.trim().is_empty() => {
2986            return Err(invalid("command", "must not be blank".into()))
2987        }
2988        Some(command) => command,
2989    };
2990    let Some(program) = split_shell_words(&command).into_iter().next() else {
2991        return Err(invalid("command", "must name a program".into()));
2992    };
2993    if !shell_allow.contains(&program) {
2994        return Err(invalid(
2995            "command",
2996            format!("runs '{program}', which is not in capabilities.shell.allow"),
2997        ));
2998    }
2999    let Some(limit) = raw.limit else {
3000        return Err(invalid("limit", "is required".into()));
3001    };
3002    let timeout_secs = match raw.timeout_secs {
3003        None => DoneCheck::DEFAULT_TIMEOUT_SECS,
3004        Some(0) => return Err(invalid("timeout_secs", "must be at least 1".into())),
3005        Some(secs) => secs,
3006    };
3007    Ok(DoneCheck {
3008        command,
3009        limit,
3010        timeout_secs,
3011    })
3012}
3013
3014impl RawBlock for RawLifecycleConfig {
3015    const KNOWN_KEYS: &'static [&'static str] = &[
3016        "task_acceptance",
3017        "after_task",
3018        "queue_depth",
3019        "input_timeout_secs",
3020        "conversation",
3021        "max_task_reopens",
3022        "shell_grace_secs",
3023        "delegation_deadline_secs",
3024        "deadline_notice_secs",
3025        "keep_working",
3026        "done_check",
3027    ];
3028    fn unknown_keys(&self) -> &UnknownKeys {
3029        &self.unknown
3030    }
3031
3032    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3033        if let Some(keep_working) = &self.keep_working {
3034            collect_block(keep_working, &child_path(path, "keep_working"), out);
3035        }
3036        if let Some(done_check) = &self.done_check {
3037            collect_block(done_check, &child_path(path, "done_check"), out);
3038        }
3039    }
3040}
3041
3042impl RawBlock for RawDoneCheck {
3043    const KNOWN_KEYS: &'static [&'static str] = &["command", "limit", "timeout_secs"];
3044    fn unknown_keys(&self) -> &UnknownKeys {
3045        &self.unknown
3046    }
3047}
3048
3049impl RawBlock for RawKeepWorking {
3050    const KNOWN_KEYS: &'static [&'static str] = &["message", "limit"];
3051    fn unknown_keys(&self) -> &UnknownKeys {
3052        &self.unknown
3053    }
3054}
3055
3056impl RawBlock for RawNetworkConfig {
3057    const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
3058    fn unknown_keys(&self) -> &UnknownKeys {
3059        &self.unknown
3060    }
3061
3062    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3063        if let Some(authentication) = &self.authentication {
3064            collect_block(authentication, &child_path(path, "authentication"), out);
3065        }
3066    }
3067}
3068
3069impl RawBlock for RawNetworkAuthentication {
3070    const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
3071    fn unknown_keys(&self) -> &UnknownKeys {
3072        &self.unknown
3073    }
3074
3075    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3076        let credentials_path = child_path(path, "credentials");
3077        for (name, credential) in self.credentials.iter().flatten() {
3078            collect_block(credential, &child_path(&credentials_path, name), out);
3079        }
3080    }
3081}
3082
3083impl RawBlock for RawDoorCredential {
3084    const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
3085    fn unknown_keys(&self) -> &UnknownKeys {
3086        &self.unknown
3087    }
3088}
3089
3090impl RawBlock for RawContextConfig {
3091    const KNOWN_KEYS: &'static [&'static str] = &[
3092        "max_tokens",
3093        "record",
3094        "record_store",
3095        "seed_budget",
3096        "seed_overflow_margin",
3097        "retain",
3098        "images",
3099    ];
3100    fn unknown_keys(&self) -> &UnknownKeys {
3101        &self.unknown
3102    }
3103
3104    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3105        if let Some(images) = &self.images {
3106            collect_block(images, &child_path(path, "images"), out);
3107        }
3108    }
3109}
3110
3111impl RawBlock for RawContextImages {
3112    const KNOWN_KEYS: &'static [&'static str] = &["keep", "max_bytes", "max_per_turn"];
3113    fn unknown_keys(&self) -> &UnknownKeys {
3114        &self.unknown
3115    }
3116}
3117
3118impl RawBlock for RawObservabilityConfig {
3119    const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
3120    fn unknown_keys(&self) -> &UnknownKeys {
3121        &self.unknown
3122    }
3123
3124    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3125        if let Some(eval) = &self.eval {
3126            collect_block(eval, &child_path(path, "eval"), out);
3127        }
3128    }
3129}
3130
3131impl RawBlock for RawTraceConfig {
3132    const KNOWN_KEYS: &'static [&'static str] = &["capture", "retain"];
3133    fn unknown_keys(&self) -> &UnknownKeys {
3134        &self.unknown
3135    }
3136}
3137
3138impl RawBlock for RawEvalConfig {
3139    const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
3140    fn unknown_keys(&self) -> &UnknownKeys {
3141        &self.unknown
3142    }
3143
3144    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3145        let scorers = child_path(path, "scorers");
3146        for (index, scorer) in self.scorers.iter().enumerate() {
3147            collect_block(scorer, &format!("{scorers}[{index}]"), out);
3148        }
3149    }
3150}
3151
3152impl RawBlock for RawScorerConfig {
3153    const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
3154    fn unknown_keys(&self) -> &UnknownKeys {
3155        &self.unknown
3156    }
3157}
3158
3159impl RawBlock for RawArtifact {
3160    const KNOWN_KEYS: &'static [&'static str] = &[
3161        "name",
3162        "version",
3163        "runtime",
3164        "source",
3165        "local_source",
3166        "prompt_payload",
3167        "capabilities",
3168        "on_overflow",
3169        "config",
3170        "gateway",
3171    ];
3172    fn unknown_keys(&self) -> &UnknownKeys {
3173        &self.unknown
3174    }
3175
3176    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3177        if let Some(capabilities) = &self.capabilities {
3178            collect_block(capabilities, &child_path(path, "capabilities"), out);
3179        }
3180        if let Some(Some(gateway)) = &self.gateway {
3181            collect_block(gateway, &child_path(path, "gateway"), out);
3182        }
3183    }
3184}
3185
3186impl RawBlock for RawArtifactGateway {
3187    const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
3188    fn unknown_keys(&self) -> &UnknownKeys {
3189        &self.unknown
3190    }
3191}
3192
3193impl RawBlock for RawCapabilities {
3194    const KNOWN_KEYS: &'static [&'static str] = &[
3195        "network",
3196        "peer_fetch",
3197        "filesystem",
3198        "shell",
3199        "spawn",
3200        "env",
3201        "limits",
3202        "resources",
3203        "state",
3204        "task_io",
3205        "conversation",
3206        "plan",
3207        "install",
3208        "containment",
3209    ];
3210    fn unknown_keys(&self) -> &UnknownKeys {
3211        &self.unknown
3212    }
3213
3214    /// Reached from both [`RawRuntimeManifest`] and [`RawArtifact`], so the capsule-wide block and
3215    /// a per-artifact one report through identical code and differ only in the `path` they are
3216    /// handed — `capabilities.shell` against `artifacts[0].capabilities.shell`.
3217    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3218        if let Some(network) = &self.network {
3219            collect_block(network, &child_path(path, "network"), out);
3220        }
3221        if let Some(peer_fetch) = &self.peer_fetch {
3222            collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
3223        }
3224        if let Some(filesystem) = &self.filesystem {
3225            collect_block(filesystem, &child_path(path, "filesystem"), out);
3226        }
3227        if let Some(shell) = &self.shell {
3228            collect_block(shell, &child_path(path, "shell"), out);
3229        }
3230        if let Some(spawn) = &self.spawn {
3231            collect_block(spawn, &child_path(path, "spawn"), out);
3232        }
3233        if let Some(env) = &self.env {
3234            collect_block(env, &child_path(path, "env"), out);
3235        }
3236        if let Some(limits) = &self.limits {
3237            collect_block(limits, &child_path(path, "limits"), out);
3238        }
3239        if let Some(resources) = &self.resources {
3240            collect_block(resources, &child_path(path, "resources"), out);
3241        }
3242        if let Some(state) = &self.state {
3243            collect_block(state, &child_path(path, "state"), out);
3244        }
3245        if let Some(task_io) = &self.task_io {
3246            collect_block(task_io, &child_path(path, "task_io"), out);
3247        }
3248        if let Some(conversation) = &self.conversation {
3249            collect_block(conversation, &child_path(path, "conversation"), out);
3250        }
3251        if let Some(plan) = &self.plan {
3252            collect_block(plan, &child_path(path, "plan"), out);
3253        }
3254        if let Some(install) = &self.install {
3255            collect_block(install, &child_path(path, "install"), out);
3256        }
3257    }
3258}
3259
3260impl RawBlock for RawTaskIoCapabilities {
3261    const KNOWN_KEYS: &'static [&'static str] = &["read"];
3262    fn unknown_keys(&self) -> &UnknownKeys {
3263        &self.unknown
3264    }
3265}
3266
3267impl RawBlock for RawConversationCapabilities {
3268    const KNOWN_KEYS: &'static [&'static str] = &["read"];
3269    fn unknown_keys(&self) -> &UnknownKeys {
3270        &self.unknown
3271    }
3272}
3273
3274impl RawBlock for RawPlanCapabilities {
3275    const KNOWN_KEYS: &'static [&'static str] = &["submit"];
3276    fn unknown_keys(&self) -> &UnknownKeys {
3277        &self.unknown
3278    }
3279}
3280
3281impl RawBlock for RawInstallCapabilities {
3282    const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool", "from"];
3283    fn unknown_keys(&self) -> &UnknownKeys {
3284        &self.unknown
3285    }
3286}
3287
3288impl RawBlock for RawSpawnCapabilities {
3289    const KNOWN_KEYS: &'static [&'static str] = &["allow", "workdir_snapshot"];
3290    fn unknown_keys(&self) -> &UnknownKeys {
3291        &self.unknown
3292    }
3293}
3294
3295impl RawBlock for RawEnvCapabilities {
3296    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3297    fn unknown_keys(&self) -> &UnknownKeys {
3298        &self.unknown
3299    }
3300}
3301
3302impl RawBlock for RawResourceLimits {
3303    const KNOWN_KEYS: &'static [&'static str] = &[
3304        "memory_bytes",
3305        "table_elements",
3306        "instances",
3307        "deadline_seconds",
3308    ];
3309    fn unknown_keys(&self) -> &UnknownKeys {
3310        &self.unknown
3311    }
3312}
3313
3314impl RawBlock for RawStateCapabilities {
3315    const KNOWN_KEYS: &'static [&'static str] = &["store"];
3316    fn unknown_keys(&self) -> &UnknownKeys {
3317        &self.unknown
3318    }
3319}
3320
3321impl RawBlock for RawResourceCapabilities {
3322    const KNOWN_KEYS: &'static [&'static str] = &[
3323        "max_processes",
3324        "max_open_files",
3325        "max_file_size_bytes",
3326        "cpu_seconds",
3327        "memory_bytes",
3328        "cgroup_memory_bytes",
3329        "cgroup_pids_max",
3330        "cgroup_cpu_percent",
3331        "cgroup_io_bytes_per_sec",
3332        "workdir_max_bytes",
3333    ];
3334    fn unknown_keys(&self) -> &UnknownKeys {
3335        &self.unknown
3336    }
3337}
3338
3339impl RawBlock for RawNetworkCapabilities {
3340    const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
3341    fn unknown_keys(&self) -> &UnknownKeys {
3342        &self.unknown
3343    }
3344}
3345
3346impl RawBlock for RawPeerFetchCapabilities {
3347    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3348    fn unknown_keys(&self) -> &UnknownKeys {
3349        &self.unknown
3350    }
3351}
3352
3353impl RawBlock for RawFilesystemCapabilities {
3354    const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only", "records"];
3355    fn unknown_keys(&self) -> &UnknownKeys {
3356        &self.unknown
3357    }
3358}
3359
3360impl RawBlock for RawShellCapabilities {
3361    const KNOWN_KEYS: &'static [&'static str] = &[
3362        "allow",
3363        "strip_env",
3364        "baseline_env",
3365        "interpreter_runtime",
3366        "staged_runtime",
3367    ];
3368    fn unknown_keys(&self) -> &UnknownKeys {
3369        &self.unknown
3370    }
3371
3372    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3373        let interpreter_runtime = child_path(path, "interpreter_runtime");
3374        for (index, grant) in self.interpreter_runtime.iter().enumerate() {
3375            collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
3376        }
3377        let staged_runtime = child_path(path, "staged_runtime");
3378        for (index, grant) in self.staged_runtime.iter().enumerate() {
3379            collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
3380        }
3381    }
3382}
3383
3384impl RawBlock for RawInterpreterRuntimeGrant {
3385    const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
3386    fn unknown_keys(&self) -> &UnknownKeys {
3387        &self.unknown
3388    }
3389
3390    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3391        let dirs = child_path(path, "dirs");
3392        for (index, dir) in self.dirs.iter().enumerate() {
3393            collect_block(dir, &format!("{dirs}[{index}]"), out);
3394        }
3395    }
3396}
3397
3398impl RawBlock for RawInterpreterRuntimeDir {
3399    const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
3400    fn unknown_keys(&self) -> &UnknownKeys {
3401        &self.unknown
3402    }
3403}
3404
3405impl RawBlock for RawStagedRuntimeGrant {
3406    const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
3407    fn unknown_keys(&self) -> &UnknownKeys {
3408        &self.unknown
3409    }
3410}
3411
3412impl RawBlock for RawInferenceConfig {
3413    const KNOWN_KEYS: &'static [&'static str] = &[
3414        "transport",
3415        "endpoint",
3416        "model",
3417        "api_key",
3418        "command",
3419        "system_prompt",
3420        "system_prompt_file",
3421        "system_prompt_artifact",
3422        "instruction_files",
3423        "driver",
3424        "compaction",
3425        "max_turns",
3426        "max_task_reopens",
3427        "max_tokens",
3428        "max_session_tokens",
3429        "tool_refresh",
3430        "alternates",
3431        "retry",
3432    ];
3433    fn unknown_keys(&self) -> &UnknownKeys {
3434        &self.unknown
3435    }
3436
3437    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3438        if let Some(driver) = &self.driver {
3439            collect_block(driver, &child_path(path, "driver"), out);
3440        }
3441        if let Some(compaction) = &self.compaction {
3442            collect_block(compaction, &child_path(path, "compaction"), out);
3443        }
3444        if let Some(retry) = &self.retry {
3445            collect_block(retry, &child_path(path, "retry"), out);
3446        }
3447        if let Some(alternates) = &self.alternates {
3448            let alternates_path = child_path(path, "alternates");
3449            for (index, alternate) in alternates.iter().enumerate() {
3450                collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
3451            }
3452        }
3453    }
3454}
3455
3456impl RawBlock for RawInferenceRetry {
3457    const KNOWN_KEYS: &'static [&'static str] =
3458        &["max_retries", "initial_delay_ms", "max_delay_ms"];
3459    fn unknown_keys(&self) -> &UnknownKeys {
3460        &self.unknown
3461    }
3462}
3463
3464impl RawBlock for RawInferenceAlternate {
3465    const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
3466    fn unknown_keys(&self) -> &UnknownKeys {
3467        &self.unknown
3468    }
3469
3470    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3471        if let Some(driver) = &self.driver {
3472            collect_block(driver, &child_path(path, "driver"), out);
3473        }
3474    }
3475}
3476
3477impl RawBlock for RawCompactionConfig {
3478    const KNOWN_KEYS: &'static [&'static str] = &[
3479        "threshold",
3480        "model",
3481        "system_prompt",
3482        "system_prompt_file",
3483        "dump_summaries",
3484        "max_tokens",
3485        "driver_config",
3486    ];
3487    fn unknown_keys(&self) -> &UnknownKeys {
3488        &self.unknown
3489    }
3490}
3491
3492impl RawBlock for RawInferenceDriver {
3493    const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
3494    fn unknown_keys(&self) -> &UnknownKeys {
3495        &self.unknown
3496    }
3497}
3498
3499/// Walks a parsed raw manifest and reports every key no block of it claimed, each block's own
3500/// keys before the blocks it owns, in the order the blocks appear in the manifest's own type.
3501///
3502/// Called once, immediately after `serde_norway` returns and before any field is moved out, so the
3503/// result covers the whole document rather than the parts validation happens to reach. Reporting
3504/// only; nothing here can refuse a manifest.
3505fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
3506    let mut out = Vec::new();
3507    collect_block(raw, "", &mut out);
3508    out
3509}
3510
3511/// One block's captured keys, each paired with the nearest key that block does recognize, then
3512/// the same for every block it owns.
3513///
3514/// `path` is empty for the manifest root, which is what
3515/// [`crate::unknown_manifest_keys::UnknownManifestKey`] renders as "at the top level".
3516fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
3517    for key in block.unknown_keys().keys() {
3518        out.push(UnknownManifestKey {
3519            key: key.clone(),
3520            block_path: path.to_string(),
3521            nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
3522        });
3523    }
3524    block.walk_children(path, out);
3525}
3526
3527/// The dotted path of `field` inside the block at `parent`, built in one place so no
3528/// [`RawBlock::walk_children`] has to reproduce the rule that the manifest root has no prefix.
3529/// An indexed child appends its own `[index]` to the result.
3530fn child_path(parent: &str, field: &str) -> String {
3531    if parent.is_empty() {
3532        return field.to_string();
3533    }
3534    format!("{parent}.{field}")
3535}
3536
3537/// Read a manifest file's text, mapping a missing file to
3538/// [`RuntimeManifestError::NotFound`] and any other read failure to
3539/// [`RuntimeManifestError::Io`].
3540///
3541/// For a caller that needs the manifest text as well as the parsed manifest — `mur doctor` reads
3542/// the references a manifest makes out of the same bytes it parses, so the file is opened once.
3543pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
3544    fs::read_to_string(path).map_err(|source| {
3545        if source.kind() == std::io::ErrorKind::NotFound {
3546            return RuntimeManifestError::NotFound(path.display().to_string());
3547        }
3548
3549        RuntimeManifestError::Io {
3550            path: path.display().to_string(),
3551            source,
3552        }
3553    })
3554}
3555
3556#[must_use = "validated runtime manifest is required before run"]
3557pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
3558    let content = read_runtime_manifest_text(path)?;
3559
3560    RuntimeManifest::from_yaml_str(&content)
3561}
3562
3563/// One `${VAR}` reference a manifest makes, and the field that makes it.
3564#[derive(Debug, Clone, PartialEq, Eq)]
3565pub struct ReferencedEnvVariable {
3566    /// The manifest field holding the reference, e.g. `artifacts.murmur-driver-anthropic.gateway.api_key`.
3567    pub field: String,
3568    /// The variable the reference names.
3569    pub variable: String,
3570}
3571
3572impl ReferencedEnvVariable {
3573    /// Whether the reference is a gateway's `api_key` — a credential the runtime also answers
3574    /// from the global config's `credentials:` map, not only from the environment.
3575    pub fn is_gateway_credential(&self) -> bool {
3576        self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
3577    }
3578}
3579
3580/// The narrow view of a manifest this scan needs: the fields whose value may be an `${ENV_REF}`,
3581/// and nothing else. Every other key is ignored, so a manifest this build would refuse for an
3582/// unrelated reason still yields its references.
3583///
3584/// Deliberately not one of the `Raw*` structs: those carry the unknown-key overflow map that
3585/// makes `warn_on_unknown_manifest_keys` complete, and this one is a second, partial reader of a
3586/// document that reader has already covered.
3587#[derive(Deserialize)]
3588struct ReferencedManifestFields {
3589    #[serde(default)]
3590    artifacts: Vec<ReferencedArtifactFields>,
3591}
3592
3593#[derive(Deserialize)]
3594struct ReferencedArtifactFields {
3595    #[serde(default)]
3596    name: Option<String>,
3597    #[serde(default)]
3598    gateway: Option<ReferencedGatewayFields>,
3599}
3600
3601#[derive(Deserialize)]
3602struct ReferencedGatewayFields {
3603    #[serde(default)]
3604    api_key: Option<String>,
3605}
3606
3607/// Every environment variable `manifest_yaml` references through an `${ENV_REF}`, sorted by
3608/// variable name and deduplicated.
3609///
3610/// Never fails and never reads the environment: unparseable YAML, an absent field and a literal
3611/// (non-reference) value all yield an empty list. What counts as a reference is decided by the
3612/// same `parse_env_reference` the resolving parse applies, so a name reported here is exactly a
3613/// name [`RuntimeManifest::from_yaml_str`] would have looked up.
3614pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
3615    let Ok(raw) = serde_norway::from_str::<ReferencedManifestFields>(manifest_yaml) else {
3616        return Vec::new();
3617    };
3618
3619    let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
3620    for artifact in raw.artifacts {
3621        let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
3622            continue;
3623        };
3624        if let Some(variable) = parse_env_reference(api_key.trim()) {
3625            let name = artifact.name.unwrap_or_default();
3626            referenced.push(ReferencedEnvVariable {
3627                field: format!("artifacts.{}.gateway.api_key", name.trim()),
3628                variable: variable.to_string(),
3629            });
3630        }
3631    }
3632
3633    referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
3634    referenced.dedup();
3635    referenced
3636}
3637
3638/// Whether a parse keeps what the manifest says about a secret it references.
3639///
3640/// The two [`RuntimeManifest`] constructors differ by this value and nothing else, so validation
3641/// cannot drift between a manifest `mur run` accepts and one a referee accepts. Neither reads the
3642/// process environment.
3643#[derive(Clone, Copy, PartialEq, Eq)]
3644enum SecretResolution {
3645    /// Every `gateway.api_key` is kept as an [`ApiKeyReference`], unresolved.
3646    Resolve,
3647    /// Every `gateway.api_key` is left unread. Each parsed [`ArtifactGateway::api_key`] is `None`
3648    /// whatever the manifest wrote.
3649    Skip,
3650}
3651
3652impl RuntimeManifest {
3653    pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
3654        Self::parse(input, SecretResolution::Resolve)
3655    }
3656
3657    /// Parse a manifest without resolving any secret it references.
3658    ///
3659    /// Validation is identical to [`RuntimeManifest::from_yaml_str`] on every block, `artifacts`
3660    /// included, with one difference: every resulting [`ArtifactGateway::api_key`] is `None`,
3661    /// whether the manifest wrote an `${ENV_REF}` or a literal key.
3662    ///
3663    /// For a caller that reads a foreign capsule's manifest for its capability policy alone —
3664    /// `mur-roost` refereeing a delegation — and is entitled to none of the credentials that
3665    /// manifest names.
3666    pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
3667        Self::parse(input, SecretResolution::Skip)
3668    }
3669
3670    fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
3671        let raw: RawRuntimeManifest = serde_norway::from_str(input).map_err(|err| {
3672            if let Some(location) = err.location() {
3673                RuntimeManifestError::YamlSyntax(format!(
3674                    "{}: YAML syntax error at line {}, column {}: {}",
3675                    MANIFEST_FILENAME,
3676                    location.line(),
3677                    location.column(),
3678                    err
3679                ))
3680            } else {
3681                RuntimeManifestError::YamlSyntax(format!(
3682                    "{MANIFEST_FILENAME}: YAML syntax error: {err}"
3683                ))
3684            }
3685        })?;
3686
3687        // The key is not a field of `RawRuntimeManifest`, so it lands in the unknown-key map.
3688        if raw.unknown.contains_key(REGISTRY_BLOCK_KEY) {
3689            return Err(RuntimeManifestError::RegistryBlock);
3690        }
3691        // Neither is `host_paths`, in any block a capsule could write it in.
3692        check_no_host_paths(&raw)?;
3693
3694        // Collected before any field is moved out of `raw`, so the walk sees the whole document
3695        // rather than the part validation happens to reach before its first refusal.
3696        let unknown_keys = collect_unknown_keys(&raw);
3697
3698        if raw.mur_version.is_some() {
3699            return Err(RuntimeManifestError::TopLevelMurVersion);
3700        }
3701
3702        let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
3703            RuntimeManifestError::MissingField {
3704                field: "name".to_string(),
3705            }
3706        })?;
3707
3708        let version = raw
3709            .version
3710            .filter(|s| !s.trim().is_empty())
3711            .ok_or_else(|| RuntimeManifestError::MissingField {
3712                field: "version".to_string(),
3713            })?;
3714
3715        // Read off the raw entries because a parse that skips secrets keeps no `ApiKeyReference`,
3716        // and `control.secrets` is checked against what the manifest wrote either way.
3717        let gateway_references = raw_gateway_references(&raw.artifacts);
3718
3719        let artifacts = raw
3720            .artifacts
3721            .into_iter()
3722            .enumerate()
3723            .map(|(index, artifact)| {
3724                let name = artifact
3725                    .name
3726                    .filter(|s| !s.trim().is_empty())
3727                    .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3728                        index,
3729                        message: "missing required field 'name'".to_string(),
3730                    })?;
3731
3732                let source = artifact.source.filter(|s| !s.trim().is_empty());
3733                check_entry_name(index, &name, source.is_some())?;
3734
3735                let runtime = match artifact.runtime.as_deref() {
3736                    None | Some("tool") => ArtifactRuntime::Tool,
3737                    Some("wasm") | Some("native") => {
3738                        return Err(RuntimeManifestError::InvalidArtifact {
3739                            index,
3740                            message: format!(
3741                                "use 'runtime: tool' for a tool (got '{}')",
3742                                artifact.runtime.as_deref().unwrap_or("")
3743                            ),
3744                        });
3745                    }
3746                    Some("driver") => ArtifactRuntime::Driver,
3747                    Some("hook") => ArtifactRuntime::Hook,
3748                    Some("skill") => ArtifactRuntime::Skill,
3749                    Some(other) => {
3750                        return Err(RuntimeManifestError::InvalidArtifact {
3751                            index,
3752                            message: format!(
3753                                "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
3754                            ),
3755                        });
3756                    }
3757                };
3758
3759                // Capabilities are keyed on declared properties, not on the role. When the
3760                // property is absent we derive it from the role, which reproduces the previous
3761                // skill-only gate exactly and leaves every existing manifest unchanged.
3762                let local_source = artifact
3763                    .local_source
3764                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3765                let prompt_payload = artifact
3766                    .prompt_payload
3767                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3768
3769                // `source:` requires the artifact to declare `local_source: true`.
3770                if source.is_some() && !local_source {
3771                    return Err(RuntimeManifestError::InvalidArtifact {
3772                        index,
3773                        message: format!(
3774                            "artifact '{name}' declares 'source:' but does not declare \
3775                             'local_source: true' (runtime: {})",
3776                            runtime.as_str()
3777                        ),
3778                    });
3779                }
3780
3781                // When `source:` is present, `version:` is optional — substitute "local".
3782                // If both are set, `version:` is ignored and a warning is printed to stderr.
3783                let version = if source.is_some() {
3784                    if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
3785                        crate::warn_line(format_args!(
3786                            "warning: artifact '{name}' declares both 'source:' and \
3787                             'version: {explicit}'; version is ignored for local-source skills \
3788                             (using 'local')"
3789                        ));
3790                    }
3791                    "local".to_string()
3792                } else {
3793                    artifact
3794                        .version
3795                        .filter(|s| !s.trim().is_empty())
3796                        .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3797                            index,
3798                            message: "missing required field 'version'".to_string(),
3799                        })?
3800                };
3801
3802                // Per-artifact `capabilities:` is enforced on every role that actually
3803                // executes — `hook` (default-deny grant) and `tool`/`driver` (narrowing
3804                // below the capsule ceiling). A `skill` has no execution surface, so the
3805                // block would be silently ignored there; that reads like a scoped artifact
3806                // when it is not, hence a parse error rather than a no-op.
3807                let capabilities = match artifact.capabilities {
3808                    None => None,
3809                    Some(raw_caps) => {
3810                        // The install grant is held by the session, not by one artifact: a
3811                        // per-artifact block would reach no pull at all.
3812                        if raw_caps.install.is_some() {
3813                            return Err(RuntimeManifestError::InvalidArtifact {
3814                                index,
3815                                message: format!(
3816                                    "artifact '{name}' declares 'capabilities.install:'; the \
3817                                     grant is capsule-wide and is only recognized in the \
3818                                     top-level capabilities block"
3819                                ),
3820                            });
3821                        }
3822                        if runtime == ArtifactRuntime::Skill {
3823                            return Err(RuntimeManifestError::InvalidArtifact {
3824                                index,
3825                                message: format!(
3826                                    "artifact '{name}' declares per-artifact 'capabilities:' but \
3827                                     has 'runtime: {}'; per-artifact capabilities are only \
3828                                     recognized on 'runtime: hook', 'runtime: tool', and \
3829                                     'runtime: driver' entries — use the capsule-wide top-level \
3830                                     'capabilities:' block instead",
3831                                    runtime.as_str()
3832                                ),
3833                            });
3834                        }
3835                        // `task_io` grants the `murmur:task-io/read` host import, which only
3836                        // hook components can be handed. On a tool or driver entry nothing
3837                        // would enforce it, and a silently-inert grant reads like a scoped
3838                        // artifact — the same reason `on_overflow:` below is a parse error off
3839                        // a hook.
3840                        if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
3841                            return Err(RuntimeManifestError::InvalidArtifact {
3842                                index,
3843                                message: format!(
3844                                    "artifact '{name}' declares 'capabilities.task_io:' but has \
3845                                     'runtime: {}'; the key grants the murmur:task-io/read host \
3846                                     import and is only recognized on 'runtime: hook' entries",
3847                                    runtime.as_str()
3848                                ),
3849                            });
3850                        }
3851                        // `filesystem.records` replaces a hook's workdir scope with the records
3852                        // directory's `hooks/`. Only a hook is preopened that way, and a hook has
3853                        // one `"."`, so the two declarations cannot both hold.
3854                        if let Some(filesystem) = raw_caps
3855                            .filesystem
3856                            .as_ref()
3857                            .filter(|filesystem| filesystem.records)
3858                        {
3859                            if runtime != ArtifactRuntime::Hook {
3860                                return Err(RuntimeManifestError::InvalidArtifact {
3861                                    index,
3862                                    message: format!(
3863                                        "artifact '{name}' declares \
3864                                         'capabilities.filesystem.records: true' but has \
3865                                         'runtime: {}'; the key gives a hook the records \
3866                                         directory's hooks/ and is only recognized on \
3867                                         'runtime: hook' entries",
3868                                        runtime.as_str()
3869                                    ),
3870                                });
3871                            }
3872                            if filesystem
3873                                .scope
3874                                .as_deref()
3875                                .is_some_and(|scope| !scope.trim().is_empty())
3876                            {
3877                                return Err(RuntimeManifestError::InvalidArtifact {
3878                                    index,
3879                                    message: format!(
3880                                        "artifact '{name}' declares both \
3881                                         'capabilities.filesystem.records: true' and \
3882                                         'capabilities.filesystem.scope'; a hook's \".\" is \
3883                                         either the records directory's hooks/ or a workdir \
3884                                         scope — declare one"
3885                                    ),
3886                                });
3887                            }
3888                        }
3889                        // `conversation` grants the `murmur:conversation/read` host import, on the
3890                        // same terms as `task_io` above: only a hook component's world can import
3891                        // it, so on any other role the grant would be silently inert.
3892                        if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
3893                            return Err(RuntimeManifestError::InvalidArtifact {
3894                                index,
3895                                message: format!(
3896                                    "artifact '{name}' declares 'capabilities.conversation:' but \
3897                                     has 'runtime: {}'; the key grants the \
3898                                     murmur:conversation/read host import and is only recognized \
3899                                     on 'runtime: hook' entries",
3900                                    runtime.as_str()
3901                                ),
3902                            });
3903                        }
3904                        parse_capabilities(Some(raw_caps))?
3905                    }
3906                };
3907
3908                // `on_overflow:` governs an async hook's job queue, which only exists for
3909                // `runtime: hook`. On any other role it would be silently ignored, so it is
3910                // a parse error for the same reason per-artifact `capabilities:` is on a
3911                // skill. It stays legal on a *blocking* hook entry (simply inert): the
3912                // execution mode lives in the artifact's own manifest and is unknown here.
3913                let on_overflow = match artifact.on_overflow.as_deref() {
3914                    None => HookOverflowPolicy::default(),
3915                    Some(raw) => {
3916                        if runtime != ArtifactRuntime::Hook {
3917                            return Err(RuntimeManifestError::InvalidArtifact {
3918                                index,
3919                                message: format!(
3920                                    "artifact '{name}' declares 'on_overflow:' but has \
3921                                     'runtime: {}'; the key governs an async hook's job queue \
3922                                     and is only recognized on 'runtime: hook' entries",
3923                                    runtime.as_str()
3924                                ),
3925                            });
3926                        }
3927                        match raw {
3928                            "drop" => HookOverflowPolicy::Drop,
3929                            "block" => HookOverflowPolicy::Block,
3930                            other => {
3931                                return Err(RuntimeManifestError::InvalidArtifact {
3932                                    index,
3933                                    message: format!(
3934                                        "artifact '{name}' declares unknown on_overflow \
3935                                         '{other}'; expected: drop, block"
3936                                    ),
3937                                });
3938                            }
3939                        }
3940                    }
3941                };
3942
3943                // `config:` reaches an artifact through that artifact's own grant, and a skill
3944                // holds no grant and runs no component — nothing would ever deliver it. Refused
3945                // rather than ignored, for the reason the two gates above are: a key that reads
3946                // like configuration and is silently dropped is worse than one that is refused.
3947                // The block's *shape* is the runtime's business, not this parser's; only the
3948                // role is decided here.
3949                if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
3950                    return Err(RuntimeManifestError::InvalidArtifact {
3951                        index,
3952                        message: format!(
3953                            "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
3954                             is recognized only on 'runtime: hook', 'runtime: tool', and \
3955                             'runtime: driver' entries",
3956                            runtime.as_str()
3957                        ),
3958                    });
3959                }
3960
3961                let gateway = match artifact.gateway {
3962                    None => None,
3963                    Some(raw_gateway) => {
3964                        if runtime == ArtifactRuntime::Skill {
3965                            return Err(RuntimeManifestError::InvalidArtifact {
3966                                index,
3967                                message: format!(
3968                                    "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
3969                                     the key is recognized only on 'runtime: hook', \
3970                                     'runtime: tool', and 'runtime: driver' entries",
3971                                    runtime.as_str()
3972                                ),
3973                            });
3974                        }
3975                        Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
3976                    }
3977                };
3978
3979                Ok(RuntimeArtifact {
3980                    name,
3981                    version,
3982                    runtime,
3983                    source,
3984                    local_source,
3985                    prompt_payload,
3986                    capabilities,
3987                    on_overflow,
3988                    config: artifact.config,
3989                    gateway,
3990                })
3991            })
3992            .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
3993
3994        // The capsule-wide block reaches capsule, tool and driver components, none of which
3995        // can be handed the murmur:task-io/read import. Rejected rather than ignored, on the
3996        // same terms as declaring it on a non-hook artifact entry.
3997        if raw
3998            .capabilities
3999            .as_ref()
4000            .is_some_and(|caps| caps.task_io.is_some())
4001        {
4002            return Err(RuntimeManifestError::InvalidCapabilities {
4003                field: "capabilities.task_io".to_string(),
4004                message: "is recognized only on 'runtime: hook' artifact entries, not in the \
4005                          capsule-wide capabilities block — the grant is per-hook"
4006                    .to_string(),
4007            });
4008        }
4009        // `filesystem.records` names a hook's preopen, and the capsule-wide block preopens nothing
4010        // for any hook. Refused rather than ignored, on the `task_io` block's terms.
4011        if raw
4012            .capabilities
4013            .as_ref()
4014            .and_then(|caps| caps.filesystem.as_ref())
4015            .is_some_and(|filesystem| filesystem.records)
4016        {
4017            return Err(RuntimeManifestError::InvalidCapabilities {
4018                field: "capabilities.filesystem.records".to_string(),
4019                message: "is recognized only on 'runtime: hook' artifact entries, not in the \
4020                          capsule-wide capabilities block — the grant is per-hook"
4021                    .to_string(),
4022            });
4023        }
4024        // Config is delivered on one artifact's own grant, so there is no capsule-wide form of
4025        // it: the capsule's guest holds no artifact grant, and a top-level block would reach no
4026        // component at all. Refused rather than ignored, on the same terms as the capsule-wide
4027        // `task_io` block above.
4028        if raw.config.is_some() {
4029            return Err(RuntimeManifestError::InvalidCapabilities {
4030                field: "config".to_string(),
4031                message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
4032                          'runtime: driver' entry that reads it — there is no capsule-wide \
4033                          config block"
4034                    .to_string(),
4035            });
4036        }
4037        let capabilities = parse_capabilities(raw.capabilities)?;
4038        let inference = parse_inference(raw.inference)?;
4039        refuse_install_under_process_transport(capabilities.as_ref(), inference.as_ref())?;
4040        validate_driver_gateways(&artifacts, inference.as_ref())?;
4041        validate_driver_choices(&artifacts, inference.as_ref())?;
4042        validate_process_driver_entry(&artifacts, inference.as_ref())?;
4043
4044        // Validate system_prompt_artifact: must name a declared artifact whose payload may be
4045        // bound as the system prompt (`prompt_payload`, defaulted from the role when absent).
4046        if let Some(ref sp_art) = inference
4047            .as_ref()
4048            .and_then(|i| i.system_prompt_artifact.clone())
4049        {
4050            let matching = artifacts.iter().find(|a| &a.name == sp_art);
4051            match matching {
4052                None => {
4053                    return Err(RuntimeManifestError::InvalidInferenceConfig {
4054                        field: "inference.system_prompt_artifact".to_string(),
4055                        message: not_declared_message(&artifacts, sp_art),
4056                    });
4057                }
4058                Some(a) if !a.prompt_payload => {
4059                    return Err(RuntimeManifestError::InvalidInferenceConfig {
4060                        field: "inference.system_prompt_artifact".to_string(),
4061                        message: format!(
4062                            "artifact '{sp_art}' (runtime: {}) does not declare \
4063                             'prompt_payload: true', which system_prompt_artifact requires",
4064                            a.runtime.as_str()
4065                        ),
4066                    });
4067                }
4068                Some(_) => {} // valid
4069            }
4070        }
4071        let context = parse_context(raw.context)?;
4072        let observability = parse_observability(raw.observability);
4073        let trace = raw
4074            .trace
4075            .map(|raw_trace| {
4076                let capture = resolve_trace_capture(raw_trace.capture.as_deref())?;
4077                let retain = parse_trace_retain(raw_trace.retain)?;
4078                Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
4079            })
4080            .transpose()?;
4081        let network = raw
4082            .network
4083            .map(|n| {
4084                Ok::<_, RuntimeManifestError>(NetworkConfig {
4085                    internal_port: n.internal_port,
4086                    authentication: parse_network_authentication(n.authentication)?,
4087                })
4088            })
4089            .transpose()?;
4090        refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
4091        let lifecycle = raw
4092            .lifecycle
4093            .map(|raw_lc| {
4094                let defaults = LifecycleConfig::default();
4095                let lifecycle = LifecycleConfig {
4096                    task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
4097                    after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
4098                    queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
4099                    input_timeout_secs: raw_lc.input_timeout_secs,
4100                    conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
4101                    max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
4102                    shell_grace_secs: raw_lc.shell_grace_secs,
4103                    delegation_deadline_secs: raw_lc
4104                        .delegation_deadline_secs
4105                        .unwrap_or(defaults.delegation_deadline_secs),
4106                    deadline_notice_secs: raw_lc.deadline_notice_secs,
4107                    keep_working: raw_lc.keep_working.map(parse_keep_working).transpose()?,
4108                    done_check: raw_lc
4109                        .done_check
4110                        .map(|raw| {
4111                            let shell_allow = capabilities
4112                                .as_ref()
4113                                .and_then(|capabilities| capabilities.shell.as_ref())
4114                                .map_or(&[][..], |shell| shell.allow.as_slice());
4115                            parse_done_check(raw, shell_allow)
4116                        })
4117                        .transpose()?,
4118                };
4119                if lifecycle.queue_has_room() {
4120                    Ok(lifecycle)
4121                } else {
4122                    Err(RuntimeManifestError::QueueWithoutRoom)
4123                }
4124            })
4125            .transpose()?;
4126
4127        let exports = parse_exports(raw.exports)?;
4128        let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
4129        validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
4130
4131        Ok(Self {
4132            name,
4133            version,
4134            artifacts,
4135            capabilities,
4136            inference,
4137            context,
4138            observability,
4139            trace,
4140            network,
4141            lifecycle,
4142            exports,
4143            control,
4144            deploy: raw.deploy.map(|deploy| DeployConfig {
4145                mur_version: deploy.mur_version.filter(|s| !s.trim().is_empty()),
4146            }),
4147            unknown_keys,
4148        })
4149    }
4150}
4151
4152/// Every artifact entry whose `gateway.api_key` is written as `${NAME}`, as `(artifact, NAME)`.
4153fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
4154    artifacts
4155        .iter()
4156        .filter_map(|artifact| {
4157            let name = artifact.name.as_deref()?.trim();
4158            let written = artifact
4159                .gateway
4160                .as_ref()?
4161                .as_ref()?
4162                .api_key
4163                .as_ref()?
4164                .as_ref()?;
4165            let variable = parse_env_reference(written.trim())?;
4166            Some((name.to_string(), variable.to_string()))
4167        })
4168        .collect()
4169}
4170
4171/// Lowers the top-level `control:` block. A block that declares nothing is no block.
4172///
4173/// `gateway_references` is every `(artifact, NAME)` pair an entry's `gateway.api_key: ${NAME}`
4174/// wrote. A secret must back one of them, and must not back the configured `transport: http`
4175/// driver's: that credential is resolved at staging and keys every turn, so a session launched
4176/// without it could make no inference call at all.
4177fn parse_control(
4178    raw: Option<RawControl>,
4179    gateway_references: &[(String, String)],
4180    inference: Option<&InferenceConfig>,
4181) -> Result<Option<ControlConfig>, RuntimeManifestError> {
4182    let Some(raw) = raw else {
4183        return Ok(None);
4184    };
4185    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
4186        field: field.to_string(),
4187        message,
4188    };
4189    let settings_written = control_string_list("control.settings", raw.settings)?;
4190    let secrets_written = control_string_list("control.secrets", raw.secrets)?;
4191    let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
4192    if settings_written.is_empty()
4193        && secrets_written.is_empty()
4194        && agent_settings_written.is_empty()
4195    {
4196        return Ok(None);
4197    }
4198    let Some(inference) = inference else {
4199        return Err(refuse(
4200            "control",
4201            "is declared on a capsule with no inference: block; the control surface is served on \
4202             an agent capsule's A2A listener, and a script capsule opens none"
4203                .to_string(),
4204        ));
4205    };
4206
4207    let mut settings = Vec::new();
4208    for entry in settings_written {
4209        let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
4210            let controllable: Vec<&str> = ControllableSetting::ALL
4211                .iter()
4212                .map(|setting| setting.wire_name())
4213                .collect();
4214            return Err(refuse(
4215                "control.settings",
4216                format!(
4217                    "'{entry}' is not a controllable setting; the controllable settings are: {}",
4218                    controllable.join(", ")
4219                ),
4220            ));
4221        };
4222        if settings.contains(&setting) {
4223            return Err(refuse(
4224                "control.settings",
4225                format!("'{entry}' is listed more than once"),
4226            ));
4227        }
4228        match setting {
4229            ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
4230                return Err(refuse(
4231                    "control.settings",
4232                    format!(
4233                        "'{entry}' is controllable only under inference.transport: http; a \
4234                         transport: {} harness sets its own output cap",
4235                        inference.transport
4236                    ),
4237                ));
4238            }
4239            ControllableSetting::InferenceMaxTokens => {}
4240            ControllableSetting::InferenceDriver => {
4241                check_driver_setting(inference).map_err(|message| {
4242                    refuse("control.settings", format!("'{entry}' {message}"))
4243                })?;
4244            }
4245        }
4246        settings.push(setting);
4247    }
4248
4249    let mut agent_settings = Vec::new();
4250    for entry in agent_settings_written {
4251        let Some(setting) = ControllableSetting::from_wire_name(&entry)
4252            .filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
4253        else {
4254            let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
4255                .iter()
4256                .map(|setting| setting.wire_name())
4257                .collect();
4258            return Err(refuse(
4259                "control.agent_settings",
4260                format!(
4261                    "'{entry}' is not a setting the agent may change; control.agent_settings \
4262                     accepts only: {}",
4263                    settable.join(", ")
4264                ),
4265            ));
4266        };
4267        if agent_settings.contains(&setting) {
4268            return Err(refuse(
4269                "control.agent_settings",
4270                format!("'{entry}' is listed more than once"),
4271            ));
4272        }
4273        check_driver_setting(inference)
4274            .map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
4275        agent_settings.push(setting);
4276    }
4277
4278    let inference_driver = (inference.transport == "http")
4279        .then(|| {
4280            inference
4281                .driver
4282                .as_ref()
4283                .map(|driver| driver.artifact.as_str())
4284        })
4285        .flatten();
4286    let mut secrets: Vec<String> = Vec::new();
4287    for entry in secrets_written {
4288        if !is_valid_env_variable(&entry) {
4289            return Err(refuse(
4290                "control.secrets",
4291                format!(
4292                    "'{entry}' is not a credential name; a name is uppercase letters, digits and \
4293                     underscores, and does not start with a digit"
4294                ),
4295            ));
4296        }
4297        if secrets.contains(&entry) {
4298            return Err(refuse(
4299                "control.secrets",
4300                format!("'{entry}' is listed more than once"),
4301            ));
4302        }
4303        let backing: Vec<&str> = gateway_references
4304            .iter()
4305            .filter(|(_, variable)| *variable == entry)
4306            .map(|(artifact, _)| artifact.as_str())
4307            .collect();
4308        if backing.is_empty() {
4309            return Err(refuse(
4310                "control.secrets",
4311                format!(
4312                    "'{entry}' is not referenced by any artifact's gateway.api_key as \
4313                     ${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
4314                ),
4315            ));
4316        }
4317        if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
4318            return Err(refuse(
4319                "control.secrets",
4320                format!(
4321                    "'{entry}' keys the configured inference driver's gateway \
4322                     (artifacts.{driver}.gateway.api_key); the inference credential is resolved \
4323                     at launch and cannot be supplied by a controller"
4324                ),
4325            ));
4326        }
4327        secrets.push(entry);
4328    }
4329
4330    Ok(Some(ControlConfig {
4331        settings,
4332        secrets,
4333        agent_settings,
4334    }))
4335}
4336
4337/// Why `inference.driver` cannot be listed for `inference`, or `Ok` when it can: only under
4338/// `transport: http`, and only with at least one alternate to select.
4339fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
4340    if inference.transport != "http" {
4341        return Err(format!(
4342            "is controllable only under inference.transport: http; a transport: {} harness owns \
4343             the model it runs",
4344            inference.transport
4345        ));
4346    }
4347    if inference.alternates.is_empty() {
4348        return Err(
4349            "selects among inference.alternates, and this capsule declares none; declare an \
4350             alternate to switch to"
4351                .to_string(),
4352        );
4353    }
4354    Ok(())
4355}
4356
4357/// `value` as a list of strings, trimmed, for `field`. Absent and YAML null are the empty list.
4358fn control_string_list(
4359    field: &str,
4360    value: Option<serde_norway::Value>,
4361) -> Result<Vec<String>, RuntimeManifestError> {
4362    let refuse = || RuntimeManifestError::InvalidControl {
4363        field: field.to_string(),
4364        message: "must be a list of names".to_string(),
4365    };
4366    let items = match value {
4367        None | Some(serde_norway::Value::Null) => return Ok(Vec::new()),
4368        Some(serde_norway::Value::Sequence(items)) => items,
4369        Some(_) => return Err(refuse()),
4370    };
4371    items
4372        .into_iter()
4373        .map(|item| match item {
4374            serde_norway::Value::String(name) => Ok(name.trim().to_string()),
4375            _ => Err(refuse()),
4376        })
4377        .collect()
4378}
4379
4380/// Lowers `network.authentication`. An absent or null block is a public door.
4381fn parse_network_authentication(
4382    raw: Option<RawNetworkAuthentication>,
4383) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
4384    let Some(raw) = raw else {
4385        return Ok(None);
4386    };
4387    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
4388        field: field.to_string(),
4389        message,
4390    };
4391    let schemes: Vec<&str> = AuthenticationScheme::ALL
4392        .iter()
4393        .map(|scheme| scheme.wire_name())
4394        .collect();
4395    let scheme = match raw.scheme.as_deref().map(str::trim) {
4396        None | Some("") => {
4397            return Err(refuse(
4398                "network.authentication.scheme",
4399                format!("is required; the schemes are: {}", schemes.join(", ")),
4400            ))
4401        }
4402        Some(written) => AuthenticationScheme::ALL
4403            .iter()
4404            .copied()
4405            .find(|scheme| scheme.wire_name() == written)
4406            .ok_or_else(|| {
4407                refuse(
4408                    "network.authentication.scheme",
4409                    format!(
4410                        "'{written}' is not a door authentication scheme; the schemes are: {}",
4411                        schemes.join(", ")
4412                    ),
4413                )
4414            })?,
4415    };
4416
4417    let mut credentials = Vec::new();
4418    for (name, credential) in raw.credentials.unwrap_or_default() {
4419        if name == OPERATOR_CREDENTIAL {
4420            return Err(refuse(
4421                "network.authentication.credentials",
4422                format!(
4423                    "'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
4424                     token, which holds every scope"
4425                ),
4426            ));
4427        }
4428        if !is_valid_credential_name(&name) {
4429            return Err(refuse(
4430                "network.authentication.credentials",
4431                format!(
4432                    "'{name}' is not a credential name; a name is a lowercase letter followed by \
4433                     at most {} lowercase letters, digits, '_' or '-'",
4434                    MAX_CREDENTIAL_NAME_LEN - 1
4435                ),
4436            ));
4437        }
4438        let field = format!("network.authentication.credentials.{name}.scopes");
4439        let scopes = door_scope_list(&field, credential.scopes)?;
4440        credentials.push(DoorCredential { name, scopes });
4441    }
4442
4443    Ok(Some(NetworkAuthentication {
4444        scheme,
4445        credentials,
4446    }))
4447}
4448
4449/// `^[a-z][a-z0-9_-]{0,31}$`.
4450fn is_valid_credential_name(name: &str) -> bool {
4451    let mut chars = name.chars();
4452    chars.next().is_some_and(|first| first.is_ascii_lowercase())
4453        && name.len() <= MAX_CREDENTIAL_NAME_LEN
4454        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
4455}
4456
4457/// One credential's `scopes`: required, non-empty, each in [`DOOR_SCOPES`] and listed once.
4458fn door_scope_list(
4459    field: &str,
4460    value: Option<serde_norway::Value>,
4461) -> Result<Vec<String>, RuntimeManifestError> {
4462    let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
4463        field: field.to_string(),
4464        message,
4465    };
4466    let items = match value {
4467        None | Some(serde_norway::Value::Null) => {
4468            return Err(refuse(
4469                "is required: a credential reaches only the scopes it lists".to_string(),
4470            ))
4471        }
4472        Some(serde_norway::Value::Sequence(items)) => items,
4473        Some(_) => return Err(refuse("must be a list of scopes".to_string())),
4474    };
4475    if items.is_empty() {
4476        return Err(refuse(
4477            "lists no scope; a credential must reach at least one".to_string(),
4478        ));
4479    }
4480    let mut scopes: Vec<String> = Vec::new();
4481    for item in items {
4482        let serde_norway::Value::String(scope) = item else {
4483            return Err(refuse("must be a list of scopes".to_string()));
4484        };
4485        let scope = scope.trim().to_string();
4486        if let Some(&(_, replacement)) = RETIRED_DOOR_SCOPES
4487            .iter()
4488            .find(|(retired, _)| *retired == scope)
4489        {
4490            return Err(RuntimeManifestError::RetiredDoorScope {
4491                field: field.to_string(),
4492                scope,
4493                replacement,
4494            });
4495        }
4496        if !DOOR_SCOPES.contains(&scope.as_str()) {
4497            return Err(refuse(format!(
4498                "'{scope}' is not a door scope; the scopes are: {}",
4499                DOOR_SCOPES.join(", ")
4500            )));
4501        }
4502        if scopes.contains(&scope) {
4503            return Err(refuse(format!("'{scope}' is listed more than once")));
4504        }
4505        scopes.push(scope);
4506    }
4507    Ok(scopes)
4508}
4509
4510/// Refuses `network.authentication` on a capsule that declares `capabilities.spawn.allow`.
4511///
4512/// A delegated child posts its completion to its parent's door, and nothing issues a child a token
4513/// for that door, so an authenticated parent would refuse every completion it asked for.
4514fn refuse_authenticated_delegating_parent(
4515    network: Option<&NetworkConfig>,
4516    capabilities: Option<&Capabilities>,
4517) -> Result<(), RuntimeManifestError> {
4518    let authenticated = network.is_some_and(|network| network.authentication.is_some());
4519    let delegates = capabilities
4520        .and_then(|capabilities| capabilities.spawn.as_ref())
4521        .is_some_and(|spawn| !spawn.allow.is_empty());
4522    if authenticated && delegates {
4523        return Err(RuntimeManifestError::InvalidNetworkConfig {
4524            field: "network.authentication".to_string(),
4525            message: "cannot be declared together with capabilities.spawn.allow: a delegated \
4526                      child posts its outcome to this door, and a delegated child cannot yet be \
4527                      issued a token for this door"
4528                .to_string(),
4529        });
4530    }
4531    Ok(())
4532}
4533
4534/// Lowers the top-level `exports:` block, rejecting anything the runtime would otherwise have to
4535/// decide about later.
4536///
4537/// The `root` rules are checked here and never again by a caller: a gateway that "cleans up" a
4538/// path is a gateway that has taken over the containment boundary, so the only legal answer to a
4539/// root naming somewhere outside the workdir is a refusal.
4540fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
4541    let Some(raw_exports) = raw else {
4542        return Ok(None);
4543    };
4544    let files = raw_exports.files.map(parse_file_export).transpose()?;
4545    let peer_files = raw_exports
4546        .peer_files
4547        .map(parse_peer_files_export)
4548        .transpose()?;
4549    let peer_tasks = raw_exports
4550        .peer_tasks
4551        .map(|raw| {
4552            raw.accept
4553                .map(|accept| PeerTasksExport { accept })
4554                .ok_or_else(|| RuntimeManifestError::InvalidExports {
4555                    field: "exports.peer_tasks.accept".to_string(),
4556                    message: "must be set explicitly to true or false — consent to peer tasks is \
4557                              never inferred"
4558                        .to_string(),
4559                })
4560        })
4561        .transpose()?;
4562    Ok(Some(Exports {
4563        files,
4564        peer_files,
4565        peer_tasks,
4566    }))
4567}
4568
4569/// Lowers `exports.peer_files`, on exactly the same terms as [`parse_file_export`]: a root that
4570/// could name somewhere outside the workdir is refused here and never repaired later.
4571///
4572/// `max_ttl` is left as the operator declared it, including absent. Whether absent is legal
4573/// depends on `lifecycle.after_task`, which is a different block of the same manifest and a
4574/// different kind of question — one about what survives teardown rather than about whether a
4575/// value is well-formed. The runtime asks it, at launch, and refuses with `E-CAP-008`.
4576fn parse_peer_files_export(
4577    raw: RawPeerFilesExport,
4578) -> Result<PeerFilesExport, RuntimeManifestError> {
4579    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4580        field: field.to_string(),
4581        message,
4582    };
4583
4584    let root = raw
4585        .root
4586        .map(|root| root.trim().to_string())
4587        .filter(|root| !root.is_empty())
4588        .ok_or_else(|| {
4589            invalid(
4590                "exports.peer_files.root",
4591                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4592            )
4593        })?;
4594    check_export_root_shape("exports.peer_files.root", &root)?;
4595
4596    let max_ttl_secs = match raw.max_ttl {
4597        None => None,
4598        Some(value) => {
4599            let text = scalar_text(&value).ok_or_else(|| {
4600                invalid(
4601                    "exports.peer_files.max_ttl",
4602                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
4603                )
4604            })?;
4605            let parsed = parse_duration_secs(&text)
4606                .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
4607            if parsed == 0 {
4608                return Err(invalid(
4609                    "exports.peer_files.max_ttl",
4610                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
4611                ));
4612            }
4613            Some(parsed)
4614        }
4615    };
4616
4617    let max_bytes = match raw.max_bytes {
4618        None => DEFAULT_PEER_FILES_MAX_BYTES,
4619        Some(value) => {
4620            let text = scalar_text(&value).ok_or_else(|| {
4621                invalid(
4622                    "exports.peer_files.max_bytes",
4623                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4624                )
4625            })?;
4626            let parsed = parse_byte_size(&text)
4627                .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
4628            if parsed == 0 {
4629                return Err(invalid(
4630                    "exports.peer_files.max_bytes",
4631                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4632                ));
4633            }
4634            parsed
4635        }
4636    };
4637
4638    Ok(PeerFilesExport {
4639        root,
4640        max_ttl_secs,
4641        max_bytes,
4642    })
4643}
4644
4645/// A YAML scalar as the text the byte-size and duration parsers expect, or `None` for a mapping
4646/// or a sequence — a shape neither parser could report on without quoting the whole node.
4647/// `Some` for a key that is present, whatever its value — including an explicit null.
4648///
4649/// The plain `Option<serde_norway::Value>` a `#[serde(default)]` field gets collapses `retain:`
4650/// with nothing under it into the same `None` an absent key produces, and those two say opposite
4651/// things: absent means "keep everything", and empty is refused.
4652fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_norway::Value>, D::Error>
4653where
4654    D: serde::Deserializer<'de>,
4655{
4656    serde_norway::Value::deserialize(deserializer).map(Some)
4657}
4658
4659fn scalar_text(value: &serde_norway::Value) -> Option<String> {
4660    match value {
4661        serde_norway::Value::String(text) => Some(text.clone()),
4662        serde_norway::Value::Number(number) => Some(number.to_string()),
4663        _ => None,
4664    }
4665}
4666
4667/// Why a manifest path that must stay inside the work folder is refused.
4668enum RelativePathRefusal {
4669    ParentDir,
4670    Absolute,
4671}
4672
4673/// The shape rule shared by every manifest path resolved beneath the accessible workdir:
4674/// relative, no `..`, no absolute prefix. `.` components are allowed.
4675fn relative_path_refusal(path: &str) -> Option<RelativePathRefusal> {
4676    Path::new(path)
4677        .components()
4678        .find_map(|component| match component {
4679            std::path::Component::Normal(_) | std::path::Component::CurDir => None,
4680            std::path::Component::ParentDir => Some(RelativePathRefusal::ParentDir),
4681            std::path::Component::RootDir | std::path::Component::Prefix(_) => {
4682                Some(RelativePathRefusal::Absolute)
4683            }
4684        })
4685}
4686
4687/// The shared `root` shape rule: relative, no `..`, no absolute prefix. Both export blocks
4688/// resolve their root against the same accessible workdir, so both refuse the same shapes with
4689/// the same sentence.
4690fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
4691    let message = match relative_path_refusal(root) {
4692        None => return Ok(()),
4693        Some(RelativePathRefusal::ParentDir) => {
4694            format!("'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}")
4695        }
4696        Some(RelativePathRefusal::Absolute) => {
4697            format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}")
4698        }
4699    };
4700    Err(RuntimeManifestError::InvalidExports {
4701        field: field.to_string(),
4702        message,
4703    })
4704}
4705
4706const INSTRUCTION_FILE_ACCEPTED_FORM: &str = "must be a relative path inside the work folder";
4707
4708/// Checks every `inference.instruction_files` entry and keeps the list as written, in order. The
4709/// runtime resolves each entry beneath the accessible work folder at staging, so a shape that
4710/// could only name a path outside it is refused here, before anything is staged.
4711fn parse_instruction_files(entries: Vec<String>) -> Result<Vec<String>, RuntimeManifestError> {
4712    for (index, entry) in entries.iter().enumerate() {
4713        let problem = if entry.trim().is_empty() {
4714            Some("is empty")
4715        } else {
4716            match relative_path_refusal(entry) {
4717                None => None,
4718                Some(RelativePathRefusal::ParentDir) => Some("contains a '..' component"),
4719                Some(RelativePathRefusal::Absolute) => Some("is absolute"),
4720            }
4721        };
4722        if let Some(problem) = problem {
4723            return Err(RuntimeManifestError::InvalidInferenceConfig {
4724                field: format!("inference.instruction_files[{index}]"),
4725                message: format!("'{entry}' {problem}; {INSTRUCTION_FILE_ACCEPTED_FORM}"),
4726            });
4727        }
4728    }
4729    Ok(entries)
4730}
4731
4732fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
4733    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4734        field: field.to_string(),
4735        message,
4736    };
4737
4738    let root = raw
4739        .root
4740        .map(|root| root.trim().to_string())
4741        .filter(|root| !root.is_empty())
4742        .ok_or_else(|| {
4743            invalid(
4744                "exports.files.root",
4745                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4746            )
4747        })?;
4748    check_export_root_shape("exports.files.root", &root)?;
4749
4750    let mode = match raw.mode.as_deref().map(str::trim) {
4751        None | Some("") => {
4752            return Err(invalid(
4753                "exports.files.mode",
4754                "is required and must be 'read-only'".to_string(),
4755            ));
4756        }
4757        Some("read-only") => ExportMode::ReadOnly,
4758        Some(other) => {
4759            return Err(invalid(
4760                "exports.files.mode",
4761                format!("'{other}' must be 'read-only'"),
4762            ));
4763        }
4764    };
4765
4766    let max_bytes = match raw.max_bytes {
4767        None => DEFAULT_EXPORT_MAX_BYTES,
4768        Some(value) => {
4769            let Some(text) = scalar_text(&value) else {
4770                return Err(invalid(
4771                    "exports.files.max_bytes",
4772                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4773                ));
4774            };
4775            let parsed = parse_byte_size(&text)
4776                .map_err(|message| invalid("exports.files.max_bytes", message))?;
4777            if parsed == 0 {
4778                return Err(invalid(
4779                    "exports.files.max_bytes",
4780                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4781                ));
4782            }
4783            parsed
4784        }
4785    };
4786
4787    Ok(FileExport {
4788        root,
4789        mode,
4790        max_bytes,
4791    })
4792}
4793
4794/// The accepted spelling of `exports.files.root`, stated once so every rejection says the same
4795/// thing about what a legal root looks like.
4796const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
4797
4798/// The accepted spelling of `capabilities.peer_fetch.allow`, stated once.
4799pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
4800
4801/// Structural check on one network destination: an `http`/`https` URL with no path, query or
4802/// fragment, or a bare `host[:port]`.
4803///
4804/// Deliberately shape-only. The authoritative matcher is the runtime's network-rule parser, which
4805/// this crate cannot see; what this catches is the class of entry that could never become a rule
4806/// at all, at the line that wrote it rather than at launch.
4807fn check_network_destination_shape(entry: &str) -> Result<(), String> {
4808    let trimmed = entry.trim();
4809    if trimmed.is_empty() {
4810        return Err(format!(
4811            "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4812        ));
4813    }
4814
4815    if trimmed.contains("://") {
4816        let url = Url::parse(trimmed).map_err(|error| {
4817            format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
4818        })?;
4819        let scheme = url.scheme().to_ascii_lowercase();
4820        if scheme != "http" && scheme != "https" {
4821            return Err(format!(
4822                "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
4823                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4824            ));
4825        }
4826        if url.host_str().is_none_or(str::is_empty) {
4827            return Err(format!(
4828                "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4829            ));
4830        }
4831        if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
4832            return Err(format!(
4833                "'{entry}' must not include a path, query or fragment; \
4834                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4835            ));
4836        }
4837        return Ok(());
4838    }
4839
4840    if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
4841        return Err(format!(
4842            "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4843        ));
4844    }
4845    // Split from the right so an IPv6 literal's own colons are not mistaken for a port separator.
4846    if let Some((host, port)) = trimmed.rsplit_once(':') {
4847        if !host.ends_with(']') {
4848            if host.is_empty() {
4849                return Err(format!(
4850                    "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4851                ));
4852            }
4853            if port.parse::<u16>().is_err() {
4854                return Err(format!(
4855                    "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4856                ));
4857            }
4858        }
4859    }
4860    Ok(())
4861}
4862
4863/// The `~/.murmur/config.yaml` key that lends contained capsules a host folder, refused wherever a
4864/// manifest writes it.
4865pub const HOST_PATHS_KEY: &str = "host_paths";
4866
4867/// Refuses [`HOST_PATHS_KEY`] at the top level, under `capabilities` and `capabilities.filesystem`,
4868/// and under each artifact entry's `capabilities` and `capabilities.filesystem`. No field claims
4869/// the key, so it is found in each block's unknown-key map before any warning is built from it.
4870fn check_no_host_paths(raw: &RawRuntimeManifest) -> Result<(), RuntimeManifestError> {
4871    fn in_capabilities(capabilities: Option<&RawCapabilities>, prefix: &str) -> Option<String> {
4872        let capabilities = capabilities?;
4873        if capabilities.unknown.contains_key(HOST_PATHS_KEY) {
4874            return Some(format!("{prefix}.{HOST_PATHS_KEY}"));
4875        }
4876        capabilities
4877            .filesystem
4878            .as_ref()
4879            .filter(|filesystem| filesystem.unknown.contains_key(HOST_PATHS_KEY))
4880            .map(|_| format!("{prefix}.filesystem.{HOST_PATHS_KEY}"))
4881    }
4882    let field = if raw.unknown.contains_key(HOST_PATHS_KEY) {
4883        Some(HOST_PATHS_KEY.to_string())
4884    } else {
4885        in_capabilities(raw.capabilities.as_ref(), "capabilities").or_else(|| {
4886            raw.artifacts
4887                .iter()
4888                .enumerate()
4889                .find_map(|(index, artifact)| {
4890                    in_capabilities(
4891                        artifact.capabilities.as_ref(),
4892                        &format!("artifacts[{index}].capabilities"),
4893                    )
4894                })
4895        })
4896    };
4897    match field {
4898        Some(field) => Err(RuntimeManifestError::HostPathsDeclared { field }),
4899        None => Ok(()),
4900    }
4901}
4902
4903fn parse_capabilities(
4904    raw: Option<RawCapabilities>,
4905) -> Result<Option<Capabilities>, RuntimeManifestError> {
4906    let Some(raw_caps) = raw else {
4907        return Ok(None);
4908    };
4909
4910    let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
4911        allow: raw_network.allow,
4912        unix_sockets: raw_network.unix_sockets,
4913    });
4914
4915    let peer_fetch = raw_caps
4916        .peer_fetch
4917        .map(|raw_peer_fetch| {
4918            if raw_peer_fetch.allow.is_empty() {
4919                return Err(RuntimeManifestError::InvalidCapabilities {
4920                    field: "capabilities.peer_fetch.allow".to_string(),
4921                    message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
4922                });
4923            }
4924            for entry in &raw_peer_fetch.allow {
4925                check_network_destination_shape(entry).map_err(|message| {
4926                    RuntimeManifestError::InvalidCapabilities {
4927                        field: "capabilities.peer_fetch.allow".to_string(),
4928                        message,
4929                    }
4930                })?;
4931            }
4932            Ok(PeerFetchCapabilities {
4933                allow: raw_peer_fetch.allow,
4934            })
4935        })
4936        .transpose()?;
4937
4938    let filesystem = raw_caps
4939        .filesystem
4940        .map(|raw_filesystem| FilesystemCapabilities {
4941            scope: raw_filesystem.scope.and_then(|scope| {
4942                let trimmed = scope.trim();
4943                (!trimmed.is_empty()).then(|| trimmed.to_string())
4944            }),
4945            // Copied straight across: a bool has no empty/whitespace case to normalise, and there
4946            // is nothing to validate here — the *consequence* of declaring it (no `scoped`, and a
4947            // refusal when `capabilities.containment: scoped` is also declared) belongs to the
4948            // runtime's containment layer, not to manifest parsing.
4949            workdir_exec: raw_filesystem.workdir_exec,
4950            // Normalised on exactly the terms `scope` above is: trimmed, and an entry left empty
4951            // by that trim is dropped rather than carried as a rule covering nothing. Shape is
4952            // not judged here either — an absolute or escaping entry is the runtime's refusal.
4953            read_only: raw_filesystem
4954                .read_only
4955                .into_iter()
4956                .filter_map(|entry| {
4957                    let trimmed = entry.trim();
4958                    (!trimmed.is_empty()).then(|| trimmed.to_string())
4959                })
4960                .collect(),
4961            // Where it is recognized is decided by the caller, which knows the entry's role.
4962            records: raw_filesystem.records,
4963        });
4964
4965    let shell = raw_caps
4966        .shell
4967        .map(|raw_shell| {
4968            if raw_shell.allow.is_empty() {
4969                return Err(RuntimeManifestError::InvalidCapabilities {
4970                    field: "capabilities.shell.allow".to_string(),
4971                    message: "must contain at least one binary".to_string(),
4972                });
4973            }
4974            for (index, entry) in raw_shell.allow.iter().enumerate() {
4975                if let Some(reason) = shell_allow_entry_error(entry) {
4976                    return Err(RuntimeManifestError::ShellAllowEntry {
4977                        index,
4978                        entry: entry.clone(),
4979                        reason,
4980                    });
4981                }
4982            }
4983
4984            let interpreter_runtime =
4985                parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
4986            // Parsed after `interpreter_runtime` because the mutual-exclusion rule needs the
4987            // already-validated grant list to test against.
4988            let staged_runtime = parse_staged_runtime(
4989                &raw_shell.allow,
4990                &interpreter_runtime,
4991                raw_shell.staged_runtime,
4992            )?;
4993
4994            Ok(ShellCapabilities {
4995                allow: raw_shell.allow,
4996                strip_env: raw_shell.strip_env,
4997                baseline_env: raw_shell.baseline_env,
4998                interpreter_runtime,
4999                staged_runtime,
5000            })
5001        })
5002        .transpose()?;
5003
5004    let spawn = raw_caps
5005        .spawn
5006        .map(|raw_spawn| parse_spawn_allow(raw_spawn.allow, raw_spawn.workdir_snapshot))
5007        .transpose()?;
5008
5009    let env = raw_caps.env.map(|raw_env| EnvCapabilities {
5010        allow: raw_env.allow,
5011    });
5012
5013    let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
5014
5015    let resources = raw_caps
5016        .resources
5017        .map(parse_resource_capabilities)
5018        .transpose()?;
5019
5020    let state = raw_caps.state.map(parse_state_capabilities);
5021
5022    let task_io = raw_caps
5023        .task_io
5024        .map(|raw_task_io| {
5025            raw_task_io
5026                .read
5027                .map(|read| TaskIoCapabilities { read })
5028                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5029                    field: "capabilities.task_io.read".to_string(),
5030                    message: "must be set explicitly to true or false — a capability is never \
5031                              inferred"
5032                        .to_string(),
5033                })
5034        })
5035        .transpose()?;
5036
5037    let conversation = raw_caps
5038        .conversation
5039        .map(|raw_conversation| {
5040            raw_conversation
5041                .read
5042                .map(|read| ConversationCapabilities { read })
5043                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5044                    field: "capabilities.conversation.read".to_string(),
5045                    message: "must be set explicitly to true or false — a capability is never \
5046                              inferred"
5047                        .to_string(),
5048                })
5049        })
5050        .transpose()?;
5051
5052    let plan = raw_caps
5053        .plan
5054        .map(|raw_plan| {
5055            raw_plan
5056                .submit
5057                .map(|submit| PlanCapabilities { submit })
5058                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5059                    field: "capabilities.plan.submit".to_string(),
5060                    message: "must be set explicitly to true or false — a capability is never \
5061                              inferred"
5062                        .to_string(),
5063                })
5064        })
5065        .transpose()?;
5066
5067    let install = raw_caps.install.map(parse_install).transpose()?;
5068
5069    let containment = raw_caps
5070        .containment
5071        .as_deref()
5072        .map(str::trim)
5073        .filter(|value| !value.is_empty())
5074        .map(|value| {
5075            let class = value.parse::<ContainmentClass>().map_err(|error| {
5076                RuntimeManifestError::InvalidCapabilities {
5077                    field: "capabilities.containment".to_string(),
5078                    message: error.to_string(),
5079                }
5080            })?;
5081            if class == ContainmentClass::Unconfined {
5082                return Err(RuntimeManifestError::InvalidCapabilities {
5083                    field: "capabilities.containment".to_string(),
5084                    message: UNCONFINED_NOT_DECLARABLE.to_string(),
5085                });
5086            }
5087            Ok(class)
5088        })
5089        .transpose()?;
5090
5091    Ok(Some(Capabilities {
5092        network,
5093        peer_fetch,
5094        filesystem,
5095        shell,
5096        spawn,
5097        env,
5098        limits,
5099        resources,
5100        state,
5101        task_io,
5102        conversation,
5103        plan,
5104        install,
5105        containment,
5106    }))
5107}
5108
5109/// Lower `capabilities.spawn.allow`: every entry a full name, no two sharing a bare part —
5110/// `delegate-task` offers the model the bare parts, so each must pick out one capsule — and
5111/// `capabilities.spawn.workdir_snapshot`: every entry a full name, listed once and also in `allow`.
5112fn parse_spawn_allow(
5113    allow: Vec<String>,
5114    workdir_snapshot: Vec<String>,
5115) -> Result<SpawnCapabilities, RuntimeManifestError> {
5116    let mut parsed: Vec<ArtifactName> = Vec::with_capacity(allow.len());
5117    for (index, entry) in allow.iter().enumerate() {
5118        let name = ArtifactName::parse(entry).map_err(|error| {
5119            RuntimeManifestError::InvalidArtifactName {
5120                field: format!("capabilities.spawn.allow[{index}]"),
5121                message: error.to_string(),
5122                hint: error.hint(),
5123            }
5124        })?;
5125        if let Some(earlier) = parsed
5126            .iter()
5127            .find(|earlier| earlier.name() == name.name() && **earlier != name)
5128        {
5129            return Err(RuntimeManifestError::InvalidArtifactName {
5130                field: format!("capabilities.spawn.allow[{index}]"),
5131                message: format!(
5132                    "'{earlier}' and '{name}' share the name '{}', which is how delegate-task \
5133                     offers a capsule to the model",
5134                    name.name()
5135                ),
5136                hint: Some(format!(
5137                    "keep one of '{earlier}' and '{name}' in capabilities.spawn.allow"
5138                )),
5139            });
5140        }
5141        parsed.push(name);
5142    }
5143    let mut snapshotted: Vec<ArtifactName> = Vec::with_capacity(workdir_snapshot.len());
5144    for (index, entry) in workdir_snapshot.iter().enumerate() {
5145        let field = format!("capabilities.spawn.workdir_snapshot[{index}]");
5146        let name = ArtifactName::parse(entry).map_err(|error| {
5147            RuntimeManifestError::InvalidArtifactName {
5148                field: field.clone(),
5149                message: error.to_string(),
5150                hint: error.hint(),
5151            }
5152        })?;
5153        if snapshotted.contains(&name) {
5154            return Err(RuntimeManifestError::InvalidArtifactName {
5155                field,
5156                message: format!("'{name}' is listed more than once"),
5157                hint: Some(format!(
5158                    "keep one '{name}' in capabilities.spawn.workdir_snapshot"
5159                )),
5160            });
5161        }
5162        if !parsed.contains(&name) {
5163            return Err(RuntimeManifestError::InvalidArtifactName {
5164                field,
5165                message: format!(
5166                    "'{name}' is not in capabilities.spawn.allow, so this capsule cannot \
5167                     delegate to it"
5168                ),
5169                hint: Some(format!(
5170                    "add '{name}' to capabilities.spawn.allow, or remove it from \
5171                     capabilities.spawn.workdir_snapshot"
5172                )),
5173            });
5174        }
5175        snapshotted.push(name);
5176    }
5177    Ok(SpawnCapabilities {
5178        allow,
5179        workdir_snapshot,
5180    })
5181}
5182
5183/// Refuse an `artifacts[index].name` of the wrong form: a full name for an entry that resolves,
5184/// a bare name for a `source:` entry, which is never resolved or locked.
5185fn check_entry_name(
5186    index: usize,
5187    name: &str,
5188    has_source: bool,
5189) -> Result<(), RuntimeManifestError> {
5190    let field = format!("artifacts[{index}].name");
5191    if !has_source {
5192        return ArtifactName::parse(name).map(|_| ()).map_err(|error| {
5193            RuntimeManifestError::InvalidArtifactName {
5194                field,
5195                message: error.to_string(),
5196                hint: error.hint(),
5197            }
5198        });
5199    }
5200    if let Some((_, bare)) = name.split_once('/') {
5201        return Err(RuntimeManifestError::InvalidArtifactName {
5202            field,
5203            message: format!(
5204                "'{name}' declares 'source:', so it is named by its bare name; a scope says \
5205                 where a resolved artifact comes from, and a source: entry is never resolved"
5206            ),
5207            hint: Some(format!("drop the scope: name it '{bare}'")),
5208        });
5209    }
5210    match artifact_name_format_error(name) {
5211        Some(reason) => Err(RuntimeManifestError::InvalidArtifactName {
5212            field,
5213            message: format!("'{name}' is not a valid artifact name: it {reason}"),
5214            hint: None,
5215        }),
5216        None => Ok(()),
5217    }
5218}
5219
5220/// The refusal of a reference to an artifact `artifacts:` does not declare. When exactly one
5221/// declared entry has `name` as its bare part, the message names that entry, the reference the
5222/// operator most likely meant.
5223fn not_declared_message(artifacts: &[RuntimeArtifact], name: &str) -> String {
5224    let mut same_bare = artifacts
5225        .iter()
5226        .filter(|artifact| artifact.name != name && artifact.tool_name() == tool_name_of(name));
5227    match (same_bare.next(), same_bare.next()) {
5228        (Some(only), None) => format!(
5229            "artifact '{name}' is not declared in artifacts: (the declared '{}' has that name; \
5230             refer to it as '{}')",
5231            only.name, only.name
5232        ),
5233        _ => format!("artifact '{name}' is not declared in artifacts:"),
5234    }
5235}
5236
5237/// Lower `capabilities.install`, refusing a block that names nothing and any entry outside
5238/// [`crate::install_pattern`]'s grammar.
5239fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
5240    if raw.skill.is_empty() && raw.tool.is_empty() {
5241        return Err(RuntimeManifestError::InvalidCapabilities {
5242            field: "capabilities.install".to_string(),
5243            message: "must name at least one 'skill' or 'tool' entry — a capability is never \
5244                      inferred; omit the block to grant no install at all"
5245                .to_string(),
5246        });
5247    }
5248    for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
5249        for (index, entry) in entries.iter().enumerate() {
5250            crate::install_pattern::validate(entry).map_err(|error| {
5251                RuntimeManifestError::InvalidArtifactName {
5252                    field: format!("capabilities.install.{key}[{index}]"),
5253                    message: format!(
5254                        "entry {entry:?} is invalid ({}); expected {}",
5255                        error.reason,
5256                        crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
5257                    ),
5258                    hint: error.hint,
5259                }
5260            })?;
5261        }
5262    }
5263    let from = match raw.from {
5264        None => InstallFrom::Stores,
5265        Some(list) => parse_install_from(&list)?,
5266    };
5267    Ok(InstallCapabilities {
5268        skill: raw.skill,
5269        tool: raw.tool,
5270        from,
5271    })
5272}
5273
5274/// Refuse `capabilities.install` on a `transport: process` capsule: the pull and remove tools are
5275/// offered through the http agent loop's tool array, which that transport never sends.
5276fn refuse_install_under_process_transport(
5277    capabilities: Option<&Capabilities>,
5278    inference: Option<&InferenceConfig>,
5279) -> Result<(), RuntimeManifestError> {
5280    let installs = capabilities.is_some_and(|capabilities| capabilities.install.is_some());
5281    let process = inference.is_some_and(|inference| inference.transport == "process");
5282    if installs && process {
5283        return Err(RuntimeManifestError::InvalidCapabilities {
5284            field: "capabilities.install".to_string(),
5285            message: "is not valid with transport: process; the harness owns its tool list, so a \
5286                      pulled artifact could never reach it"
5287                .to_string(),
5288        });
5289    }
5290    Ok(())
5291}
5292
5293/// The values `capabilities.install.from` accepts, as its refusals name them.
5294const INSTALL_FROM_ACCEPTED: &str = "[stores] or [stores, sources]";
5295
5296/// Lower `capabilities.install.from`: `stores` is required, `sources` optional, each at most
5297/// once and in either order.
5298fn parse_install_from(list: &[String]) -> Result<InstallFrom, RuntimeManifestError> {
5299    let refuse = |message: String| RuntimeManifestError::InvalidCapabilities {
5300        field: "capabilities.install.from".to_string(),
5301        message: format!("{message}; expected {INSTALL_FROM_ACCEPTED}"),
5302    };
5303    if list.is_empty() {
5304        return Err(refuse("is empty".to_string()));
5305    }
5306    let (mut stores, mut sources) = (false, false);
5307    for entry in list {
5308        let seen = match entry.as_str() {
5309            "stores" => &mut stores,
5310            "sources" => &mut sources,
5311            other => return Err(refuse(format!("names {other:?}, which is not a place"))),
5312        };
5313        if *seen {
5314            return Err(refuse(format!("names {entry:?} twice")));
5315        }
5316        *seen = true;
5317    }
5318    if !stores {
5319        return Err(refuse(
5320            "does not name stores, which every pull is answered from first".to_string(),
5321        ));
5322    }
5323    Ok(if sources {
5324        InstallFrom::StoresAndSources
5325    } else {
5326        InstallFrom::Stores
5327    })
5328}
5329
5330/// Lower `capabilities.state`. Infallible: the only field is a name, and a name's *shape* is a
5331/// runtime question (does it resolve to one directory under `~/.murmur/state/`?) rather than a
5332/// manifest-parse one — `capsule_runtime::state_store::validate_store_name` answers it and refuses
5333/// with `E-CAP-009`.
5334///
5335/// Surrounding whitespace is trimmed but an emptied value is *kept*, unlike
5336/// `capabilities.filesystem.scope`, which normalises empty to absent. `store: ""` is a written
5337/// declaration that names nothing, and silently defaulting it to the capsule name would hand a
5338/// store to someone who asked for a different one; it reaches the runtime and is refused there.
5339fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
5340    StateCapabilities {
5341        store: raw.store.map(|store| store.trim().to_string()),
5342    }
5343}
5344
5345/// Why `entry` is not a `capabilities.shell.allow` entry, which must be one path component: the
5346/// binary's name, looked up on `PATH`, and the name of the tool the model calls. `None` for a
5347/// bare binary name.
5348fn shell_allow_entry_error(entry: &str) -> Option<&'static str> {
5349    if entry.is_empty() {
5350        Some("is empty")
5351    } else if entry.contains('\0') {
5352        Some("contains a NUL byte")
5353    } else if entry.contains('/') {
5354        Some("is a path")
5355    } else if entry == "." || entry == ".." {
5356        Some("names a directory")
5357    } else if entry.chars().any(char::is_whitespace) {
5358        Some("contains whitespace")
5359    } else {
5360        None
5361    }
5362}
5363
5364/// Lower and validate `capabilities.shell.interpreter_runtime`. Every rejection is an
5365/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending value, matching the rest
5366/// of this file's "structurally present but semantically invalid" reporting. Nothing here can
5367/// expand a whole install prefix: each grant names specific directories, and each directory
5368/// carries an explicit `list_dir` the author had to write — enumerability is never inferred.
5369fn parse_interpreter_runtime(
5370    allow: &[String],
5371    raw_grants: Vec<RawInterpreterRuntimeGrant>,
5372) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
5373    let mut grants = Vec::with_capacity(raw_grants.len());
5374    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5375        let base = format!("capabilities.shell.interpreter_runtime[{index}]");
5376
5377        let binary = raw_grant
5378            .binary
5379            .filter(|b| !b.trim().is_empty())
5380            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5381                field: format!("{base}.binary"),
5382                message: "must name a binary".to_string(),
5383            })?;
5384
5385        // The whole point of this mechanism: it narrows filesystem access alongside an exec
5386        // grant that already exists. It can never itself grant exec, so the binary must already
5387        // be allowlisted in this same block's `allow`.
5388        if !allow.contains(&binary) {
5389            return Err(RuntimeManifestError::InvalidCapabilities {
5390                field: format!("{base}.binary"),
5391                message: format!(
5392                    "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
5393                     only narrow filesystem access for an already-allowlisted binary, never \
5394                     grant exec"
5395                ),
5396            });
5397        }
5398
5399        if raw_grant.dirs.is_empty() {
5400            return Err(RuntimeManifestError::InvalidCapabilities {
5401                field: format!("{base}.dirs"),
5402                message: "must name at least one directory".to_string(),
5403            });
5404        }
5405
5406        let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
5407        for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
5408            let dir_base = format!("{base}.dirs[{dir_index}]");
5409
5410            let path = raw_dir
5411                .path
5412                .filter(|p| !p.trim().is_empty())
5413                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5414                    field: format!("{dir_base}.path"),
5415                    message: "must name a host directory".to_string(),
5416                })?;
5417            if !path.starts_with('/') {
5418                return Err(RuntimeManifestError::InvalidCapabilities {
5419                    field: format!("{dir_base}.path"),
5420                    message: format!(
5421                        "'{path}' must be an absolute host path (start with '/') — these are \
5422                         filesystem paths outside the workdir"
5423                    ),
5424                });
5425            }
5426
5427            let list_dir =
5428                raw_dir
5429                    .list_dir
5430                    .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5431                        field: format!("{dir_base}.list_dir"),
5432                        message: format!(
5433                            "'{path}' must set list_dir explicitly to true or false — \
5434                         enumerability is never inferred"
5435                        ),
5436                    })?;
5437
5438            dirs.push(InterpreterRuntimeDir { path, list_dir });
5439        }
5440
5441        grants.push(InterpreterRuntimeGrant { binary, dirs });
5442    }
5443
5444    Ok(grants)
5445}
5446
5447/// Lower and validate `capabilities.shell.staged_runtime`, given this same block's `allow` list
5448/// and its already-validated `interpreter_runtime` grants. Every rejection is a
5449/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending field, matching
5450/// [`parse_interpreter_runtime`] and the rest of this file.
5451///
5452/// Four rules, each of them a thing that would otherwise be silently wrong at launch rather than
5453/// loudly wrong here:
5454///
5455///   1. `binary` must already be in `allow` — staging a runtime tree never itself grants exec.
5456///   2. `binary` must not also carry an `interpreter_runtime` grant — the two are alternatives,
5457///      and declaring both means the author expects a host-widening grant that a composed root
5458///      makes both unnecessary and (once Landlock re-installs inside the root) meaningless.
5459///   3. `source_path` must be absolute — it is a host path outside the workdir, resolved by the
5460///      launch host and not relative to anything the runtime knows.
5461///   4. `pin` must be present and non-empty — it is the value a human compares across two hosts,
5462///      so an absent one defeats the field's only purpose.
5463///
5464/// Nothing here touches the filesystem: whether `source_path` exists on *this* host is a launch
5465/// -time fact, not a manifest-validity one, and a manifest must stay parseable on a machine that
5466/// will never run it (`mur build` on a laptop for a Linux fleet).
5467fn parse_staged_runtime(
5468    allow: &[String],
5469    interpreter_runtime: &[InterpreterRuntimeGrant],
5470    raw_grants: Vec<RawStagedRuntimeGrant>,
5471) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
5472    let mut grants = Vec::with_capacity(raw_grants.len());
5473    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5474        let base = format!("capabilities.shell.staged_runtime[{index}]");
5475
5476        let binary = raw_grant
5477            .binary
5478            .filter(|b| !b.trim().is_empty())
5479            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5480                field: format!("{base}.binary"),
5481                message: "must name a binary".to_string(),
5482            })?;
5483
5484        if !allow.contains(&binary) {
5485            return Err(RuntimeManifestError::InvalidCapabilities {
5486                field: format!("{base}.binary"),
5487                message: format!(
5488                    "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
5489                     stage the runtime tree behind an already-allowlisted binary, never grant exec"
5490                ),
5491            });
5492        }
5493
5494        if interpreter_runtime
5495            .iter()
5496            .any(|grant| grant.binary == binary)
5497        {
5498            return Err(RuntimeManifestError::InvalidCapabilities {
5499                field: format!("{base}.binary"),
5500                message: format!(
5501                    "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
5502                     are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
5503                     tree into the capsule's own root, which is what makes widening the capsule's \
5504                     host filesystem scope unnecessary rather than something to add to it. Remove \
5505                     the interpreter_runtime grant for '{binary}'"
5506                ),
5507            });
5508        }
5509
5510        let source_path = raw_grant
5511            .source_path
5512            .filter(|p| !p.trim().is_empty())
5513            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5514                field: format!("{base}.source_path"),
5515                message: "must name the host directory holding the pinned runtime tree".to_string(),
5516            })?;
5517        if !source_path.starts_with('/') {
5518            return Err(RuntimeManifestError::InvalidCapabilities {
5519                field: format!("{base}.source_path"),
5520                message: format!(
5521                    "'{source_path}' must be an absolute host path (start with '/') — \
5522                     staged-runtime sources are host filesystem paths outside the workdir"
5523                ),
5524            });
5525        }
5526
5527        let pin = raw_grant
5528            .pin
5529            .map(|p| p.trim().to_string())
5530            .filter(|p| !p.is_empty())
5531            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5532                field: format!("{base}.pin"),
5533                message: format!(
5534                    "'{source_path}' must declare a non-empty pin identifying which build this \
5535                     tree is — it is never inferred, because it is the value a human compares \
5536                     across two hosts to confirm the same runtime shipped to both"
5537                ),
5538            })?;
5539
5540        grants.push(StagedRuntimeGrant {
5541            binary,
5542            source_path,
5543            pin,
5544        });
5545    }
5546
5547    Ok(grants)
5548}
5549
5550/// Lower `capabilities.limits`, rejecting values that can only ever produce a guest that
5551/// traps on its first instruction. A zero cap is always a manifest authoring mistake, and
5552/// catching it here means `mur run` fails at parse time with a field name rather than
5553/// surfacing much later as an opaque wasm trap.
5554fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
5555    let reject_zero_usize =
5556        |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
5557            match value {
5558                Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5559                    field: format!("capabilities.limits.{field}"),
5560                    message: "must be greater than zero".to_string(),
5561                }),
5562                _ => Ok(()),
5563            }
5564        };
5565
5566    reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
5567    reject_zero_usize(raw.table_elements, "table_elements")?;
5568    reject_zero_usize(raw.instances, "instances")?;
5569    if raw.deadline_seconds == Some(0) {
5570        return Err(RuntimeManifestError::InvalidCapabilities {
5571            field: "capabilities.limits.deadline_seconds".to_string(),
5572            message: "must be greater than zero".to_string(),
5573        });
5574    }
5575
5576    Ok(ResourceLimits {
5577        memory_bytes: raw.memory_bytes,
5578        table_elements: raw.table_elements,
5579        instances: raw.instances,
5580        deadline_seconds: raw.deadline_seconds,
5581    })
5582}
5583
5584/// Lower and validate `capabilities.resources`. Mirrors [`parse_resource_limits`]: nothing is
5585/// defaulted here (the runtime owns the defaults, so "omitted" must stay distinguishable from
5586/// "declared"), and a declared `0` is rejected outright on every field — a zero ceiling is
5587/// never what an author means, and letting it through would turn a typo into a subprocess that
5588/// cannot fork, open a file, or run for a single CPU-second.
5589fn parse_resource_capabilities(
5590    raw: RawResourceCapabilities,
5591) -> Result<ResourceCapabilities, RuntimeManifestError> {
5592    let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
5593        match value {
5594            Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5595                field: format!("capabilities.resources.{field}"),
5596                message: "must be greater than zero".to_string(),
5597            }),
5598            _ => Ok(()),
5599        }
5600    };
5601
5602    reject_zero(raw.max_processes, "max_processes")?;
5603    reject_zero(raw.max_open_files, "max_open_files")?;
5604    reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
5605    reject_zero(raw.cpu_seconds, "cpu_seconds")?;
5606    reject_zero(raw.memory_bytes, "memory_bytes")?;
5607    reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
5608    reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
5609    reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
5610    reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
5611    reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
5612
5613    Ok(ResourceCapabilities {
5614        max_processes: raw.max_processes,
5615        max_open_files: raw.max_open_files,
5616        max_file_size_bytes: raw.max_file_size_bytes,
5617        cpu_seconds: raw.cpu_seconds,
5618        memory_bytes: raw.memory_bytes,
5619        cgroup_memory_bytes: raw.cgroup_memory_bytes,
5620        cgroup_pids_max: raw.cgroup_pids_max,
5621        cgroup_cpu_percent: raw.cgroup_cpu_percent,
5622        cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
5623        workdir_max_bytes: raw.workdir_max_bytes,
5624    })
5625}
5626
5627/// Why a `transport: process` manifest with no `inference.driver` is refused. Names one driver
5628/// artifact by way of example: this is the one message a reader reaches with nothing to go on, and
5629/// an abstract instruction to "name a process driver" leaves them no way to find one.
5630const PROCESS_DRIVER_REQUIRED: &str =
5631    "missing required field; a transport: process capsule must name the process driver that \
5632     knows how to drive its harness. inference.command no longer selects a harness — it only \
5633     overrides the binary the driver names. Add inference.driver.artifact naming a process \
5634     driver, for example murmur/driver-claude-code";
5635
5636fn parse_inference(
5637    raw: Option<RawInferenceConfig>,
5638) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
5639    let Some(raw) = raw else {
5640        return Ok(None);
5641    };
5642
5643    let transport = raw
5644        .transport
5645        .map(|s| s.trim().to_string())
5646        .filter(|s| !s.is_empty())
5647        .unwrap_or_else(|| "http".to_string());
5648
5649    let compaction = parse_compaction(raw.compaction)?;
5650    let system_prompt = optional_trimmed_string(raw.system_prompt);
5651    let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
5652    let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
5653    let instruction_files = raw
5654        .instruction_files
5655        .map(parse_instruction_files)
5656        .transpose()?;
5657
5658    let prompt_sources_set = [
5659        system_prompt.is_some(),
5660        system_prompt_file.is_some(),
5661        system_prompt_artifact.is_some(),
5662    ]
5663    .iter()
5664    .filter(|&&b| b)
5665    .count();
5666    if prompt_sources_set > 1 {
5667        return Err(RuntimeManifestError::InvalidInferenceConfig {
5668            field: "inference.system_prompt".to_string(),
5669            message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
5670                      may be set"
5671                .to_string(),
5672        });
5673    }
5674
5675    if raw.max_turns == Some(0) {
5676        return Err(RuntimeManifestError::InvalidInferenceConfig {
5677            field: "inference.max_turns".to_string(),
5678            message: "must be greater than 0".to_string(),
5679        });
5680    }
5681    let max_turns = raw.max_turns;
5682
5683    // The reopen budget is a lifecycle knob, read from `lifecycle.max_task_reopens`. Nothing
5684    // here consumes the `inference` spelling, so it would be silently inert — reject it rather
5685    // than let it look effective.
5686    if raw.max_task_reopens.is_some() {
5687        return Err(RuntimeManifestError::InvalidInferenceConfig {
5688            field: "inference.max_task_reopens".to_string(),
5689            message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
5690                .to_string(),
5691        });
5692    }
5693
5694    // Left to the unknown-key pass, either field would only draw a warning and the capsule would
5695    // launch with no upstream or credential bound, so each is refused by name, pointing at the
5696    // driver entry's `gateway:` block. Nothing translates it.
5697    for (present, removed, moved_to) in [
5698        (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
5699        (raw.api_key.is_some(), "api_key", "gateway.api_key"),
5700    ] {
5701        if !present {
5702            continue;
5703        }
5704        let entry = raw
5705            .driver
5706            .as_ref()
5707            .and_then(|driver| driver.artifact.as_deref())
5708            .map(str::trim)
5709            .filter(|name| !name.is_empty())
5710            .map(|name| format!("on the artifacts: entry '{name}'"))
5711            .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
5712        return Err(RuntimeManifestError::InvalidInferenceConfig {
5713            field: format!("inference.{removed}"),
5714            message: format!("was removed; set {moved_to} {entry} instead"),
5715        });
5716    }
5717
5718    match transport.as_str() {
5719        "http" => {
5720            let model = required_inference_field(raw.model, "model")?;
5721
5722            let driver_raw =
5723                raw.driver
5724                    .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5725                        field: "inference.driver".to_string(),
5726                        message: "missing required field".to_string(),
5727                    })?;
5728            let driver = parse_inference_driver(driver_raw)?;
5729
5730            // Advisory only: catch an authoring typo at parse time rather than as a confusing
5731            // provider 400. Large values are deliberately NOT clamped — a ceiling here would
5732            // block a model whose real limit is higher than anything we could hard-code.
5733            if raw.max_tokens == Some(0) {
5734                return Err(RuntimeManifestError::InvalidInferenceConfig {
5735                    field: "inference.max_tokens".to_string(),
5736                    message: "must be greater than 0".to_string(),
5737                });
5738            }
5739            if raw.max_session_tokens == Some(0) {
5740                return Err(RuntimeManifestError::InvalidInferenceConfig {
5741                    field: "inference.max_session_tokens".to_string(),
5742                    message: "must be greater than 0".to_string(),
5743                });
5744            }
5745            let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
5746            let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
5747            let retry = parse_inference_retry(raw.retry)?;
5748
5749            Ok(Some(InferenceConfig {
5750                transport,
5751                model,
5752                driver: Some(driver),
5753                command: None,
5754                compaction,
5755                system_prompt,
5756                system_prompt_file,
5757                system_prompt_artifact,
5758                instruction_files,
5759                max_turns,
5760                max_tokens: raw.max_tokens,
5761                max_session_tokens: raw.max_session_tokens,
5762                tool_refresh,
5763                alternates,
5764                retry,
5765            }))
5766        }
5767        "process" => {
5768            // The CLI subprocess path never builds a driver payload, so this value would be
5769            // silently inert here — reject it rather than let it look effective.
5770            if raw.max_tokens.is_some() {
5771                return Err(RuntimeManifestError::InvalidInferenceConfig {
5772                    field: "inference.max_tokens".to_string(),
5773                    message: "is not valid with transport: process".to_string(),
5774                });
5775            }
5776            // The harness owns its tool list and runs its own compaction, so neither trigger has a
5777            // turn boundary here to act on.
5778            if raw.tool_refresh.is_some() {
5779                return Err(RuntimeManifestError::InvalidInferenceConfig {
5780                    field: "inference.tool_refresh".to_string(),
5781                    message: "is not valid with transport: process".to_string(),
5782                });
5783            }
5784            // The harness runs its own compaction, so no `on-compaction` call is made here for
5785            // either key to shape.
5786            for (present, key) in [
5787                (
5788                    compaction.as_ref().is_some_and(|c| c.max_tokens.is_some()),
5789                    "max_tokens",
5790                ),
5791                (
5792                    compaction
5793                        .as_ref()
5794                        .is_some_and(|c| c.driver_config.is_some()),
5795                    "driver_config",
5796                ),
5797            ] {
5798                if present {
5799                    return Err(RuntimeManifestError::InvalidInferenceConfig {
5800                        field: format!("inference.compaction.{key}"),
5801                        message: "is not valid with transport: process".to_string(),
5802                    });
5803                }
5804            }
5805            // The harness owns the conversation and the model it runs, so there is no agent-loop
5806            // call for a switch to reroute.
5807            if raw.alternates.is_some() {
5808                return Err(RuntimeManifestError::InvalidInferenceConfig {
5809                    field: "inference.alternates".to_string(),
5810                    message: "is not valid with transport: process; the harness owns the \
5811                              conversation and the model it runs"
5812                        .to_string(),
5813                });
5814            }
5815            if raw.retry.is_some() {
5816                return Err(RuntimeManifestError::InvalidInferenceConfig {
5817                    field: "inference.retry".to_string(),
5818                    message: "is not valid with transport: process; the harness retries its own \
5819                              provider calls"
5820                        .to_string(),
5821                });
5822            }
5823
5824            // Valid here: the harness's own reported token counts are what this transport's
5825            // turns are metered against, and a driver that reports none is refused at load
5826            // rather than run for ever beneath a ceiling nothing could reach.
5827            if raw.max_session_tokens == Some(0) {
5828                return Err(RuntimeManifestError::InvalidInferenceConfig {
5829                    field: "inference.max_session_tokens".to_string(),
5830                    message: "must be greater than 0".to_string(),
5831                });
5832            }
5833
5834            let driver = raw.driver.map(parse_inference_driver).transpose()?;
5835            let command = optional_trimmed_string(raw.command);
5836            // The driver is what knows how to drive a harness CLI: its flags, its output format,
5837            // its version probe. `command` only says which executable to run instead of the one
5838            // the driver names, so it selects nothing on its own.
5839            if driver.is_none() {
5840                return Err(RuntimeManifestError::InvalidInferenceConfig {
5841                    field: "inference.driver.artifact".to_string(),
5842                    message: PROCESS_DRIVER_REQUIRED.to_string(),
5843                });
5844            }
5845            // model is OPTIONAL for transport: process — an empty string leaves the choice of
5846            // model to the harness, which has an account default of its own. The driver is handed
5847            // `none` rather than an empty string, so it never passes a model flag with no value.
5848            let model = optional_trimmed_string(raw.model).unwrap_or_default();
5849
5850            Ok(Some(InferenceConfig {
5851                transport,
5852                model,
5853                driver,
5854                command,
5855                compaction,
5856                system_prompt,
5857                system_prompt_file,
5858                system_prompt_artifact,
5859                instruction_files,
5860                max_turns,
5861                max_tokens: None,
5862                max_session_tokens: raw.max_session_tokens,
5863                tool_refresh: ToolRefresh::default(),
5864                alternates: Vec::new(),
5865                retry: InferenceRetry::default(),
5866            }))
5867        }
5868        other => Err(RuntimeManifestError::InvalidInferenceConfig {
5869            field: "inference.transport".to_string(),
5870            message: format!("unknown value '{other}'"),
5871        }),
5872    }
5873}
5874
5875/// Lowers `inference.retry` under `transport: http`: each omitted key takes its default, and each
5876/// value is checked against its bounds. `max_delay_ms` below `initial_delay_ms` is refused on
5877/// `max_delay_ms`.
5878fn parse_inference_retry(
5879    raw: Option<RawInferenceRetry>,
5880) -> Result<InferenceRetry, RuntimeManifestError> {
5881    let defaults = InferenceRetry::default();
5882    let Some(raw) = raw else {
5883        return Ok(defaults);
5884    };
5885    let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5886        field: format!("inference.retry.{key}"),
5887        message,
5888    };
5889    let retry = InferenceRetry {
5890        max_retries: raw.max_retries.unwrap_or(defaults.max_retries),
5891        initial_delay_ms: raw.initial_delay_ms.unwrap_or(defaults.initial_delay_ms),
5892        max_delay_ms: raw.max_delay_ms.unwrap_or(defaults.max_delay_ms),
5893    };
5894    if retry.max_retries > InferenceRetry::MAX_RETRIES {
5895        return Err(refuse(
5896            "max_retries",
5897            format!("must be at most {}", InferenceRetry::MAX_RETRIES),
5898        ));
5899    }
5900    if !(1..=InferenceRetry::MAX_INITIAL_DELAY_MS).contains(&retry.initial_delay_ms) {
5901        return Err(refuse(
5902            "initial_delay_ms",
5903            format!(
5904                "must be between 1 and {}",
5905                InferenceRetry::MAX_INITIAL_DELAY_MS
5906            ),
5907        ));
5908    }
5909    if !(1..=InferenceRetry::MAX_DELAY_MS).contains(&retry.max_delay_ms) {
5910        return Err(refuse(
5911            "max_delay_ms",
5912            format!("must be between 1 and {}", InferenceRetry::MAX_DELAY_MS),
5913        ));
5914    }
5915    if retry.max_delay_ms < retry.initial_delay_ms {
5916        return Err(refuse(
5917            "max_delay_ms",
5918            format!(
5919                "must be at least inference.retry.initial_delay_ms ({})",
5920                retry.initial_delay_ms
5921            ),
5922        ));
5923    }
5924    Ok(retry)
5925}
5926
5927/// Whether `name` is an alternate's name: a lowercase letter, then up to 62 lowercase letters,
5928/// digits and hyphens.
5929fn is_valid_driver_choice_name(name: &str) -> bool {
5930    let mut chars = name.chars();
5931    chars.next().is_some_and(|first| first.is_ascii_lowercase())
5932        && name.len() <= 63
5933        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
5934}
5935
5936/// Lowers `inference.alternates` under `transport: http`, with the rules one entry and the list
5937/// can be judged on alone. What needs `artifacts:` is [`validate_driver_choices`]'s.
5938///
5939/// `primary_driver` and `primary_model` are the primary choice's pair, which no alternate may
5940/// repeat: two names for one pair would make a switch between them a change of nothing.
5941fn parse_alternates(
5942    raw: Option<Vec<RawInferenceAlternate>>,
5943    primary_driver: &str,
5944    primary_model: &str,
5945) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
5946    let mut alternates: Vec<InferenceAlternate> = Vec::new();
5947    for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
5948        let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5949            field: if key.is_empty() {
5950                format!("inference.alternates[{index}]")
5951            } else {
5952                format!("inference.alternates[{index}].{key}")
5953            },
5954            message,
5955        };
5956        let name = optional_trimmed_string(entry.name)
5957            .ok_or_else(|| refuse("name", "missing required field".to_string()))?;
5958        if name == PRIMARY_DRIVER_CHOICE {
5959            return Err(refuse(
5960                "name",
5961                format!(
5962                    "'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
5963                     inference.model; name the alternate something else"
5964                ),
5965            ));
5966        }
5967        if !is_valid_driver_choice_name(&name) {
5968            return Err(refuse(
5969                "name",
5970                format!(
5971                    "'{name}' is not a driver choice name; a name is a lowercase letter followed \
5972                     by up to 62 lowercase letters, digits and hyphens"
5973                ),
5974            ));
5975        }
5976        if alternates.iter().any(|alternate| alternate.name == name) {
5977            return Err(refuse(
5978                "name",
5979                format!("'{name}' names more than one alternate"),
5980            ));
5981        }
5982        let model = optional_trimmed_string(entry.model)
5983            .ok_or_else(|| refuse("model", "missing required field".to_string()))?;
5984        let Some(driver) = entry.driver else {
5985            return Err(refuse(
5986                "driver.artifact",
5987                "missing required field".to_string(),
5988            ));
5989        };
5990        if driver
5991            .config
5992            .as_ref()
5993            .is_some_and(|config| !matches!(config, serde_norway::Value::Null))
5994        {
5995            return Err(refuse(
5996                "driver.config",
5997                "is not valid on an alternate; inference.driver.config applies to every \
5998                 declared driver, and per-driver settings belong in that driver's \
5999                 artifacts[].config"
6000                    .to_string(),
6001            ));
6002        }
6003        let driver = optional_trimmed_string(driver.artifact)
6004            .ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
6005        if driver == primary_driver && model == primary_model {
6006            return Err(refuse(
6007                "",
6008                format!(
6009                    "'{name}' is driver '{driver}' with model '{model}', which is the primary \
6010                     choice; an alternate names a different pair"
6011                ),
6012            ));
6013        }
6014        if let Some(twin) = alternates
6015            .iter()
6016            .find(|alternate| alternate.driver == driver && alternate.model == model)
6017        {
6018            return Err(refuse(
6019                "",
6020                format!(
6021                    "'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
6022                     already is; an alternate names a different pair",
6023                    twin.name
6024                ),
6025            ));
6026        }
6027        alternates.push(InferenceAlternate {
6028            name,
6029            model,
6030            driver,
6031        });
6032    }
6033    Ok(alternates)
6034}
6035
6036/// `inference.tool_refresh` under `transport: http`: absent is [`ToolRefresh::Compaction`].
6037fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
6038    let Some(value) = raw else {
6039        return Ok(ToolRefresh::default());
6040    };
6041    ToolRefresh::ALL
6042        .into_iter()
6043        .find(|refresh| refresh.wire_name() == value)
6044        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6045            field: "inference.tool_refresh".to_string(),
6046            message: format!(
6047                "must be one of: {}; got '{value}'",
6048                ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
6049            ),
6050        })
6051}
6052
6053fn parse_context(
6054    raw: Option<RawContextConfig>,
6055) -> Result<Option<ContextConfig>, RuntimeManifestError> {
6056    let Some(raw) = raw else {
6057        return Ok(None);
6058    };
6059
6060    if let Some(max_tokens) = raw.max_tokens {
6061        if max_tokens == 0 {
6062            return Err(RuntimeManifestError::InvalidInferenceConfig {
6063                field: "context.max_tokens".to_string(),
6064                message: "must be greater than 0".to_string(),
6065            });
6066        }
6067    }
6068
6069    let fraction = |field: &str, value: Option<f32>, default: f32| match value {
6070        None => Ok(default),
6071        Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
6072        Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
6073            field: field.to_string(),
6074            message: "must be between 0.0 and 1.0".to_string(),
6075        }),
6076    };
6077
6078    let record = match raw.record.as_deref().map(str::trim) {
6079        None => true,
6080        Some("on") => true,
6081        Some("off") => false,
6082        Some(other) => {
6083            return Err(RuntimeManifestError::InvalidInferenceConfig {
6084                field: "context.record".to_string(),
6085                message: format!("unknown value '{other}'; expected: on, off"),
6086            })
6087        }
6088    };
6089
6090    Ok(Some(ContextConfig {
6091        max_tokens: raw.max_tokens,
6092        record,
6093        record_store: raw.record_store.map(|store| store.trim().to_string()),
6094        seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
6095        seed_overflow_margin: fraction(
6096            "context.seed_overflow_margin",
6097            raw.seed_overflow_margin,
6098            DEFAULT_SEED_OVERFLOW_MARGIN,
6099        )?,
6100        retain: parse_context_retain(raw.retain)?,
6101        images: parse_context_images(raw.images)?,
6102    }))
6103}
6104
6105/// The `context.images` block, each absent key at its default.
6106fn parse_context_images(
6107    raw: Option<RawContextImages>,
6108) -> Result<ContextImagesConfig, RuntimeManifestError> {
6109    let defaults = ContextImagesConfig::default();
6110    let Some(raw) = raw else {
6111        return Ok(defaults);
6112    };
6113    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
6114        field: field.to_string(),
6115        message,
6116    };
6117    let count = |field: &str, value: Option<serde_norway::Value>, default: u32| match value {
6118        None => Ok(default),
6119        Some(value) => value
6120            .as_u64()
6121            .and_then(|n| u32::try_from(n).ok())
6122            .filter(|n| *n >= 1)
6123            .ok_or_else(|| {
6124                let message = match scalar_text(&value) {
6125                    Some(text) => format!("must be a whole number of at least 1, got '{text}'"),
6126                    None => "must be a whole number of at least 1".to_string(),
6127                };
6128                invalid(field, message)
6129            }),
6130    };
6131    let max_bytes = match raw.max_bytes {
6132        None => defaults.max_bytes,
6133        Some(value) => {
6134            let field = "context.images.max_bytes";
6135            let text = scalar_text(&value)
6136                .ok_or_else(|| invalid(field, format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}")))?;
6137            let parsed = parse_byte_size(&text).map_err(|message| invalid(field, message))?;
6138            if parsed == 0 {
6139                return Err(invalid(
6140                    field,
6141                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
6142                ));
6143            }
6144            parsed
6145        }
6146    };
6147    Ok(ContextImagesConfig {
6148        keep: count("context.images.keep", raw.keep, defaults.keep)?,
6149        max_bytes,
6150        max_per_turn: count(
6151            "context.images.max_per_turn",
6152            raw.max_per_turn,
6153            defaults.max_per_turn,
6154        )?,
6155    })
6156}
6157
6158/// The keys `trace.retain` accepts, in the order an error lists them.
6159const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
6160
6161/// The keys `context.retain` accepts, in the order an error lists them.
6162const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
6163
6164/// What both `retain:` blocks say when they carry nothing. Stated once so the two errors, which
6165/// travel through different `RuntimeManifestError` variants, cannot drift apart.
6166const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
6167                                  keep everything, which is what no policy means";
6168
6169/// One `retain:` block, reduced to its two recognized keys.
6170///
6171/// Shared by `trace.retain` and `context.retain`: they differ only in the name of the count key,
6172/// so the shape check, the unknown-key refusal and the empty-block refusal are written once.
6173/// `count_key` is `max_sessions` or `max_messages`; `block` is the dotted path an error names.
6174fn parse_retain_block(
6175    block: &str,
6176    count_key: &str,
6177    accepted: &[&str],
6178    raw: serde_norway::Value,
6179    invalid: &dyn Fn(String, String) -> RuntimeManifestError,
6180) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
6181    let mapping = match raw {
6182        serde_norway::Value::Mapping(mapping) => mapping,
6183        // `retain:` with nothing under it parses as null and means exactly `retain: {}`.
6184        serde_norway::Value::Null => {
6185            return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
6186        }
6187        other => {
6188            return Err(invalid(
6189                block.to_string(),
6190                format!(
6191                    "must be a block of keys ({}), got '{other:?}'",
6192                    accepted.join(", ")
6193                ),
6194            ))
6195        }
6196    };
6197    if mapping.is_empty() {
6198        return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
6199    }
6200
6201    let mut count: Option<u32> = None;
6202    let mut max_age_secs: Option<u64> = None;
6203    for (key, value) in &mapping {
6204        let key = key.as_str().unwrap_or_default();
6205        let field = format!("{block}.{key}");
6206        if key == count_key {
6207            let parsed = value
6208                .as_u64()
6209                .and_then(|n| u32::try_from(n).ok())
6210                .ok_or_else(|| {
6211                    invalid(
6212                        field.clone(),
6213                        "must be a whole number of at least 1".to_string(),
6214                    )
6215                })?;
6216            if parsed == 0 {
6217                return Err(invalid(
6218                    field,
6219                    "must be at least 1 — a limit of zero would delete what retention exists \
6220                     to keep; remove the key to leave it unbounded"
6221                        .to_string(),
6222                ));
6223            }
6224            count = Some(parsed);
6225        } else if key == "max_age" {
6226            let text = scalar_text(value).ok_or_else(|| {
6227                invalid(
6228                    field.clone(),
6229                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
6230                )
6231            })?;
6232            let parsed =
6233                parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
6234            if parsed == 0 {
6235                return Err(invalid(
6236                    field,
6237                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
6238                ));
6239            }
6240            max_age_secs = Some(parsed);
6241        } else {
6242            return Err(invalid(
6243                block.to_string(),
6244                format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
6245            ));
6246        }
6247    }
6248    Ok((count, max_age_secs))
6249}
6250
6251fn parse_trace_retain(
6252    raw: Option<serde_norway::Value>,
6253) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
6254    let Some(raw) = raw else {
6255        return Ok(None);
6256    };
6257    let (max_sessions, max_age_secs) = parse_retain_block(
6258        "trace.retain",
6259        "max_sessions",
6260        &TRACE_RETAIN_KEYS,
6261        raw,
6262        &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
6263    )?;
6264    Ok(Some(TraceRetainConfig {
6265        max_sessions,
6266        max_age_secs,
6267    }))
6268}
6269
6270fn parse_context_retain(
6271    raw: Option<serde_norway::Value>,
6272) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
6273    let Some(raw) = raw else {
6274        return Ok(None);
6275    };
6276    let (max_messages, max_age_secs) = parse_retain_block(
6277        "context.retain",
6278        "max_messages",
6279        &CONTEXT_RETAIN_KEYS,
6280        raw,
6281        &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
6282    )?;
6283    Ok(Some(ContextRetainConfig {
6284        max_messages,
6285        max_age_secs,
6286    }))
6287}
6288
6289fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
6290    raw.map(|raw| ObservabilityConfig {
6291        otel_endpoint: raw
6292            .otel_endpoint
6293            .map(|value| value.trim().to_string())
6294            .filter(|value| !value.is_empty()),
6295        eval: raw.eval.map(parse_eval_config),
6296    })
6297}
6298
6299fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
6300    let scorers = raw
6301        .scorers
6302        .into_iter()
6303        .filter_map(|raw| {
6304            let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
6305            match raw.scorer_type.as_str() {
6306                "exit_ok" => Some(ScorerConfig::ExitOk { name }),
6307                "max_turns" => Some(ScorerConfig::MaxTurns {
6308                    name,
6309                    max: raw.max.unwrap_or(10) as u32,
6310                }),
6311                "max_tokens" => Some(ScorerConfig::MaxTokens {
6312                    name,
6313                    max: raw.max.unwrap_or(100_000),
6314                }),
6315                "tool_sequence" => Some(ScorerConfig::ToolSequence {
6316                    name,
6317                    expected: raw.expected.unwrap_or_default(),
6318                }),
6319                "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
6320                other => {
6321                    crate::warn_line(format_args!(
6322                        "[murmur-artifact] unknown scorer type '{other}' — skipping"
6323                    ));
6324                    None
6325                }
6326            }
6327        })
6328        .collect();
6329
6330    EvalConfig {
6331        dataset_id: raw.dataset_id,
6332        scorers,
6333    }
6334}
6335
6336fn parse_compaction(
6337    raw: Option<RawCompactionConfig>,
6338) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
6339    let Some(raw) = raw else {
6340        return Ok(None);
6341    };
6342
6343    if let Some(threshold) = raw.threshold {
6344        if threshold <= 0.0 || threshold > 1.0 {
6345            return Err(RuntimeManifestError::InvalidInferenceConfig {
6346                field: "inference.compaction.threshold".to_string(),
6347                message: format!("must be in (0.0, 1.0] but got {threshold}"),
6348            });
6349        }
6350    }
6351
6352    if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
6353        return Err(RuntimeManifestError::InvalidInferenceConfig {
6354            field: "inference.compaction.system_prompt".to_string(),
6355            message: "at most one of inference.compaction.system_prompt, \
6356                      inference.compaction.system_prompt_file may be set"
6357                .to_string(),
6358        });
6359    }
6360
6361    if raw.max_tokens == Some(0) {
6362        return Err(RuntimeManifestError::InvalidInferenceConfig {
6363            field: "inference.compaction.max_tokens".to_string(),
6364            message: "must be greater than 0".to_string(),
6365        });
6366    }
6367    let driver_config =
6368        driver_config_json(raw.driver_config, "inference.compaction.driver_config")?;
6369
6370    Ok(Some(CompactionConfig {
6371        threshold: raw.threshold,
6372        model: raw.model,
6373        system_prompt: raw.system_prompt,
6374        system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
6375        // A plain optional boolean: nothing to validate, and deliberately independent of the
6376        // threshold-range and prompt-source checks above.
6377        dump_summaries: raw.dump_summaries,
6378        max_tokens: raw.max_tokens,
6379        driver_config,
6380    }))
6381}
6382
6383fn optional_trimmed_string(value: Option<String>) -> Option<String> {
6384    value
6385        .map(|s| s.trim().to_string())
6386        .filter(|s| !s.is_empty())
6387}
6388
6389fn required_inference_field(
6390    value: Option<String>,
6391    key: &str,
6392) -> Result<String, RuntimeManifestError> {
6393    value
6394        .map(|value| value.trim().to_string())
6395        .filter(|value| !value.is_empty())
6396        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6397            field: format!("inference.{key}"),
6398            message: "missing required field".to_string(),
6399        })
6400}
6401
6402/// One artifact entry's `gateway:` block, checked and with its key reference kept unresolved.
6403///
6404/// The credential rule reads what the manifest wrote, never a resolved value, so it decides the
6405/// same under [`SecretResolution::Resolve`] and [`SecretResolution::Skip`]: a non-blank `api_key`
6406/// is keyed, `keyless: true` with no `api_key` key is keyless, both together is
6407/// [`RuntimeManifestError::InvalidArtifact`], and anything else is
6408/// [`RuntimeManifestError::GatewayWithoutCredential`]. The endpoint's address plays no part.
6409fn parse_artifact_gateway(
6410    index: usize,
6411    name: &str,
6412    raw: Option<RawArtifactGateway>,
6413    secrets: SecretResolution,
6414) -> Result<ArtifactGateway, RuntimeManifestError> {
6415    let (endpoint, api_key, keyless) = match raw {
6416        Some(raw) => (
6417            optional_trimmed_string(raw.endpoint),
6418            raw.api_key,
6419            raw.keyless,
6420        ),
6421        None => (None, None, None),
6422    };
6423    let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
6424        index,
6425        message: format!(
6426            "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
6427             the upstream URL the runtime sends this artifact's keyed requests to"
6428        ),
6429    })?;
6430    validate_gateway_endpoint(name, &endpoint)
6431        .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
6432
6433    let keyless = match keyless {
6434        None | Some(serde_norway::Value::Null) | Some(serde_norway::Value::Bool(false)) => false,
6435        Some(serde_norway::Value::Bool(true)) => true,
6436        Some(_) => {
6437            return Err(RuntimeManifestError::InvalidArtifact {
6438                index,
6439                message: format!(
6440                    "artifact '{name}' declares gateway.keyless with a value that is not a \
6441                     boolean; write keyless: true for an upstream that takes no key"
6442                ),
6443            })
6444        }
6445    };
6446    if keyless && api_key.is_some() {
6447        return Err(RuntimeManifestError::InvalidArtifact {
6448            index,
6449            message: format!(
6450                "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
6451                 gateway either presents a key or is keyless"
6452            ),
6453        });
6454    }
6455    if keyless {
6456        return Ok(ArtifactGateway {
6457            endpoint,
6458            api_key: None,
6459            keyless: true,
6460        });
6461    }
6462
6463    let written = api_key
6464        .flatten()
6465        .map(|value| value.trim().to_string())
6466        .filter(|value| !value.is_empty());
6467    let Some(written) = written else {
6468        return Err(RuntimeManifestError::GatewayWithoutCredential {
6469            index,
6470            name: name.to_string(),
6471            endpoint,
6472        });
6473    };
6474    let api_key = match secrets {
6475        SecretResolution::Skip => None,
6476        SecretResolution::Resolve => Some(match parse_env_reference(&written) {
6477            Some(variable) => ApiKeyReference::Environment(variable.to_string()),
6478            None => ApiKeyReference::Literal(written),
6479        }),
6480    };
6481
6482    Ok(ArtifactGateway {
6483        endpoint,
6484        api_key,
6485        keyless: false,
6486    })
6487}
6488
6489/// Checks a `gateway.endpoint`, the only control on where an artifact's key is sent: a
6490/// gateway-addressed request is readdressed before any `network.allow` rule is consulted, so
6491/// nothing else confines the destination.
6492///
6493/// Accepted: an `https://` URL with a host, or an `http://` URL whose host is `localhost` or an
6494/// IP literal for which [`IpAddr::is_loopback`] returns true (`127.0.0.0/8`, `::1`). An optional
6495/// port and path are kept.
6496///
6497/// Refused:
6498/// - `${` anywhere, checked before parsing: the field is never interpolated, and the URL parser
6499///   accepts `$`, `{` and `}` in a host, so `https://${HOST}` would otherwise parse as a literal
6500///   host.
6501/// - A malformed URL, a scheme other than `http`/`https`, or no host.
6502/// - Userinfo (`user@`, `user:pass@`): `https://api.example.com@evil.example` addresses
6503///   `evil.example`, and the gateway never sends userinfo, so a password there is a second,
6504///   unmanaged credential. The message names the host the URL addresses.
6505/// - A query or a fragment: the gateway readdresses each request by its own path and query, so
6506///   either would be silently dropped.
6507/// - `http://` to any host that is not loopback, including `0.0.0.0` and `::ffff:127.0.0.1`.
6508///
6509/// The endpoint confines the origin only. The guest chooses the whole path and query of every
6510/// gateway request.
6511///
6512/// The error is the whole message, naming `gateway.endpoint` and the artifact entry `artifact`.
6513/// It quotes the endpoint with any userinfo masked, so a password written into the URL is never
6514/// echoed.
6515fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
6516    let shown = mask_endpoint_userinfo(endpoint);
6517    let refuse =
6518        |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
6519    if endpoint.contains("${") {
6520        return Err(refuse(
6521            "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
6522             literally"
6523                .to_string(),
6524        ));
6525    }
6526    let url = Url::parse(endpoint)
6527        .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
6528    let host = match url.scheme() {
6529        "http" | "https" => url
6530            .host_str()
6531            .filter(|host| !host.is_empty())
6532            .ok_or_else(|| refuse("which has no host".to_string()))?,
6533        other => {
6534            return Err(refuse(format!(
6535                "whose scheme '{other}' is unsupported — expected http or https"
6536            )))
6537        }
6538    };
6539    if !url.username().is_empty() || url.password().is_some() {
6540        return Err(refuse(format!(
6541            "which carries userinfo before '@' — the gateway never sends it, and this URL \
6542             addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
6543             the key through gateway.api_key"
6544        )));
6545    }
6546    if url.query().is_some() {
6547        return Err(refuse(
6548            "which carries a query — the gateway keeps each request's own query, so write the \
6549             upstream without one"
6550                .to_string(),
6551        ));
6552    }
6553    if url.fragment().is_some() {
6554        return Err(refuse(
6555            "which carries a fragment — write the upstream without one".to_string(),
6556        ));
6557    }
6558    if url.scheme() == "http" {
6559        let bare = host.trim_start_matches('[').trim_end_matches(']');
6560        let is_loopback = bare == "localhost"
6561            || bare
6562                .parse::<IpAddr>()
6563                .map(|ip| ip.is_loopback())
6564                .unwrap_or(false);
6565        if !is_loopback {
6566            return Err(refuse(format!(
6567                "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
6568                 remote upstream, or http://localhost or a loopback IP literal for a local one"
6569            )));
6570        }
6571    }
6572    Ok(())
6573}
6574
6575/// `endpoint` with everything before the last `@` of its authority replaced by `***`.
6576///
6577/// Textual rather than parsed, so it also masks an endpoint the URL parser rejects.
6578fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
6579    let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
6580    let rest = &endpoint[authority_start..];
6581    let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
6582    match rest[..authority_len].rfind('@') {
6583        Some(at) => format!(
6584            "{}***{}",
6585            &endpoint[..authority_start],
6586            &endpoint[authority_start + at..]
6587        )
6588        .into(),
6589        None => endpoint.into(),
6590    }
6591}
6592
6593/// The cross-entry rules for a `gateway:` on a driver, which need the parsed `inference:` block.
6594///
6595/// A driver's gateway is a metered inference gateway only when that driver is the configured
6596/// `transport: http` driver or an `inference.alternates` entry's driver. On any other driver entry
6597/// it could be spent only through tool dispatch, where nothing meters it, so it is refused. Under
6598/// `transport: http` the configured driver's entry must exist and must carry the upstream.
6599fn validate_driver_gateways(
6600    artifacts: &[RuntimeArtifact],
6601    inference: Option<&InferenceConfig>,
6602) -> Result<(), RuntimeManifestError> {
6603    let configured_driver = inference
6604        .filter(|inference| inference.transport == "http")
6605        .and_then(|inference| inference.driver.as_ref())
6606        .map(|driver| driver.artifact.as_str());
6607    let alternate_drivers: Vec<&str> = inference
6608        .map(|inference| {
6609            inference
6610                .alternates
6611                .iter()
6612                .map(|alternate| alternate.driver.as_str())
6613                .collect()
6614        })
6615        .unwrap_or_default();
6616    let under_process = inference.is_some_and(|inference| inference.transport == "process");
6617
6618    // A reference to nothing is reported as such before any entry is judged against it: a
6619    // misspelt driver name would otherwise read as a gateway on the wrong driver.
6620    if let Some(driver) = configured_driver {
6621        if !artifacts.iter().any(|artifact| artifact.name == driver) {
6622            return Err(RuntimeManifestError::InvalidInferenceConfig {
6623                field: "inference.driver.artifact".to_string(),
6624                message: not_declared_message(artifacts, driver),
6625            });
6626        }
6627    }
6628
6629    for (index, artifact) in artifacts.iter().enumerate() {
6630        if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
6631            continue;
6632        }
6633        if under_process {
6634            return Err(RuntimeManifestError::InvalidArtifact {
6635                index,
6636                message: format!(
6637                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
6638                     valid with transport: process — the CLI subprocess holds its own \
6639                     credentials and a process driver is granted nothing",
6640                    artifact.name
6641                ),
6642            });
6643        }
6644        if configured_driver != Some(artifact.name.as_str())
6645            && !alternate_drivers.contains(&artifact.name.as_str())
6646        {
6647            return Err(RuntimeManifestError::InvalidArtifact {
6648                index,
6649                message: format!(
6650                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
6651                     the configured inference driver; a driver's gateway is accepted only on the \
6652                     entry inference.driver.artifact names under transport: http, or on one an \
6653                     inference.alternates entry names",
6654                    artifact.name
6655                ),
6656            });
6657        }
6658    }
6659
6660    if let Some(driver) = configured_driver {
6661        let Some((index, entry)) = artifacts
6662            .iter()
6663            .enumerate()
6664            .find(|(_, artifact)| artifact.name == driver)
6665        else {
6666            return Err(RuntimeManifestError::InvalidInferenceConfig {
6667                field: "inference.driver.artifact".to_string(),
6668                message: not_declared_message(artifacts, driver),
6669            });
6670        };
6671        if entry.gateway.is_none() {
6672            return Err(RuntimeManifestError::InvalidArtifact {
6673                index,
6674                message: format!(
6675                    "artifact '{driver}' is the transport: http inference driver but declares no \
6676                     gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
6677                     on its entry"
6678                ),
6679            });
6680        }
6681    }
6682    Ok(())
6683}
6684
6685/// The cross-entry rules for `inference.alternates`, which need `artifacts:`.
6686///
6687/// Each alternate's `driver.artifact` must be an `artifacts:` entry with `runtime: driver`, and
6688/// that entry must carry its own `gateway:` — the credential a switch attaches — unless it is the
6689/// primary's own driver, whose gateway [`validate_driver_gateways`] already requires.
6690fn validate_driver_choices(
6691    artifacts: &[RuntimeArtifact],
6692    inference: Option<&InferenceConfig>,
6693) -> Result<(), RuntimeManifestError> {
6694    let Some(inference) = inference else {
6695        return Ok(());
6696    };
6697    let primary_driver = inference
6698        .driver
6699        .as_ref()
6700        .map(|driver| driver.artifact.as_str());
6701    for (index, alternate) in inference.alternates.iter().enumerate() {
6702        let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
6703            field: format!("inference.alternates[{index}].driver.artifact"),
6704            message,
6705        };
6706        let driver = alternate.driver.as_str();
6707        let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
6708            return Err(refuse(not_declared_message(artifacts, driver)));
6709        };
6710        if entry.runtime != ArtifactRuntime::Driver {
6711            return Err(refuse(format!(
6712                "artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
6713                 runtime: driver entry",
6714                entry.runtime.as_str()
6715            )));
6716        }
6717        if entry.gateway.is_none() && primary_driver != Some(driver) {
6718            return Err(refuse(format!(
6719                "artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
6720                 its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
6721                 URL>, api_key: ... }}' on its entry"
6722            )));
6723        }
6724    }
6725    Ok(())
6726}
6727
6728/// Refuses alternates nothing could ever select: `inference.driver` is in neither
6729/// `control.settings` nor `control.agent_settings`.
6730fn validate_alternates_selectable(
6731    inference: Option<&InferenceConfig>,
6732    control: Option<&ControlConfig>,
6733) -> Result<(), RuntimeManifestError> {
6734    if inference.is_none_or(|inference| inference.alternates.is_empty()) {
6735        return Ok(());
6736    }
6737    let selectable = control.is_some_and(|control| {
6738        control.controller_may_set(ControllableSetting::InferenceDriver)
6739            || control.agent_may_set(ControllableSetting::InferenceDriver)
6740    });
6741    if selectable {
6742        return Ok(());
6743    }
6744    Err(RuntimeManifestError::InvalidInferenceConfig {
6745        field: "inference.alternates".to_string(),
6746        message: "declares alternates that nothing could ever select; list inference.driver in \
6747                  control.settings for a controller to switch, or in control.agent_settings for \
6748                  the agent to"
6749            .to_string(),
6750    })
6751}
6752
6753/// The cross-entry rules for the `transport: process` driver, which need the parsed
6754/// `inference:` block.
6755///
6756/// The artifact `inference.driver.artifact` names must be declared in `artifacts:` with
6757/// `runtime: driver`. A process driver is granted nothing, so its entry may carry neither
6758/// `capabilities:` nor `config:`; its settings travel in `inference.driver.config`. A `gateway:`
6759/// on it is refused by [`validate_driver_gateways`].
6760fn validate_process_driver_entry(
6761    artifacts: &[RuntimeArtifact],
6762    inference: Option<&InferenceConfig>,
6763) -> Result<(), RuntimeManifestError> {
6764    let Some(driver) = inference
6765        .filter(|inference| inference.transport == "process")
6766        .and_then(|inference| inference.driver.as_ref())
6767        .map(|driver| driver.artifact.as_str())
6768    else {
6769        return Ok(());
6770    };
6771    let Some((index, entry)) = artifacts
6772        .iter()
6773        .enumerate()
6774        .find(|(_, artifact)| artifact.name == driver)
6775    else {
6776        return Err(RuntimeManifestError::InvalidInferenceConfig {
6777            field: "inference.driver.artifact".to_string(),
6778            message: not_declared_message(artifacts, driver),
6779        });
6780    };
6781    if entry.runtime != ArtifactRuntime::Driver {
6782        return Err(RuntimeManifestError::InvalidArtifact {
6783            index,
6784            message: format!(
6785                "artifact '{driver}' is the transport: process driver and must be declared with \
6786                 runtime: driver"
6787            ),
6788        });
6789    }
6790    if entry.capabilities.is_some() || entry.config.is_some() {
6791        return Err(RuntimeManifestError::InvalidArtifact {
6792            index,
6793            message: format!(
6794                "artifact '{driver}' is the transport: process driver, which is granted nothing; \
6795                 remove capabilities:/config: from its entry — driver settings go in \
6796                 inference.driver.config"
6797            ),
6798        });
6799    }
6800    Ok(())
6801}
6802
6803fn parse_inference_driver(
6804    raw: RawInferenceDriver,
6805) -> Result<InferenceDriver, RuntimeManifestError> {
6806    let artifact = raw
6807        .artifact
6808        .map(|value| value.trim().to_string())
6809        .filter(|value| !value.is_empty())
6810        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6811            field: "inference.driver.artifact".to_string(),
6812            message: "missing required field".to_string(),
6813        })?;
6814
6815    let config = driver_config_json(raw.config, "inference.driver.config")?;
6816
6817    Ok(InferenceDriver { artifact, config })
6818}
6819
6820/// Lowers a driver config mapping to compact JSON, or `None` when absent or null. `field` names
6821/// the key in the refusal of anything but a mapping.
6822fn driver_config_json(
6823    raw: Option<serde_norway::Value>,
6824    field: &str,
6825) -> Result<Option<String>, RuntimeManifestError> {
6826    let Some(raw) = raw else {
6827        return Ok(None);
6828    };
6829
6830    if matches!(raw, serde_norway::Value::Null) {
6831        return Ok(None);
6832    }
6833
6834    if !matches!(raw, serde_norway::Value::Mapping(_)) {
6835        return Err(RuntimeManifestError::InvalidInferenceConfig {
6836            field: field.to_string(),
6837            message: "expected mapping/object".to_string(),
6838        });
6839    }
6840
6841    let value =
6842        serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
6843            field: field.to_string(),
6844            message: format!("failed to convert to JSON: {err}"),
6845        })?;
6846
6847    serde_json::to_string(&value).map(Some).map_err(|err| {
6848        RuntimeManifestError::InvalidInferenceConfig {
6849            field: field.to_string(),
6850            message: format!("failed to encode JSON: {err}"),
6851        }
6852    })
6853}
6854
6855fn parse_env_reference(value: &str) -> Option<&str> {
6856    if !value.starts_with("${") || !value.ends_with('}') {
6857        return None;
6858    }
6859
6860    let variable = &value[2..value.len() - 1];
6861    is_valid_env_variable(variable).then_some(variable)
6862}
6863
6864fn is_valid_env_variable(value: &str) -> bool {
6865    let mut chars = value.chars();
6866    let Some(first) = chars.next() else {
6867        return false;
6868    };
6869
6870    if !(first == '_' || first.is_ascii_uppercase()) {
6871        return false;
6872    }
6873
6874    chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
6875}
6876
6877#[cfg(test)]
6878mod tests {
6879    use super::*;
6880    use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
6881
6882    fn manifest_with_trace(block: &str) -> String {
6883        format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
6884    }
6885
6886    fn lifecycle(task_acceptance: TaskAcceptance, queue_depth: usize) -> LifecycleConfig {
6887        LifecycleConfig {
6888            task_acceptance,
6889            queue_depth,
6890            ..Default::default()
6891        }
6892    }
6893
6894    #[test]
6895    fn tasks_held_at_once_under_queue_is_one_running_plus_queue_depth_waiting() {
6896        assert_eq!(lifecycle(TaskAcceptance::Queue, 1).tasks_held_at_once(), 2);
6897        assert_eq!(lifecycle(TaskAcceptance::Queue, 9).tasks_held_at_once(), 10);
6898    }
6899
6900    #[test]
6901    fn queue_has_room_is_false_only_for_a_queue_at_depth_zero() {
6902        assert!(!lifecycle(TaskAcceptance::Queue, 0).queue_has_room());
6903        assert!(lifecycle(TaskAcceptance::Queue, 1).queue_has_room());
6904        assert!(lifecycle(TaskAcceptance::Single, 0).queue_has_room());
6905        assert!(lifecycle(TaskAcceptance::None, 0).queue_has_room());
6906        assert!(lifecycle(TaskAcceptance::Single, 3).queue_has_room());
6907    }
6908
6909    fn parse_lifecycle(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
6910        RuntimeManifest::from_yaml_str(&format!(
6911            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n{block}"
6912        ))
6913    }
6914
6915    #[test]
6916    fn queue_depth_zero_under_queue_is_refused_at_load() {
6917        let error = parse_lifecycle("  task_acceptance: queue\n  queue_depth: 0\n").unwrap_err();
6918        assert!(
6919            matches!(error, RuntimeManifestError::QueueWithoutRoom),
6920            "{error:?}"
6921        );
6922        assert_eq!(
6923            error.to_string(),
6924            "murmur.yaml: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves \
6925             task_acceptance: queue no room for a waiting task"
6926        );
6927    }
6928
6929    /// `queue_depth` means nothing outside `queue`, so `0` there parses.
6930    #[test]
6931    fn queue_depth_zero_outside_queue_still_parses() {
6932        for block in [
6933            "  task_acceptance: single\n  queue_depth: 0\n",
6934            "  task_acceptance: none\n  queue_depth: 0\n",
6935            "  queue_depth: 0\n",
6936        ] {
6937            let lifecycle = parse_lifecycle(block)
6938                .unwrap_or_else(|error| panic!("{block:?}: {error}"))
6939                .effective_lifecycle();
6940            assert_eq!(lifecycle.queue_depth, 0, "{block:?}");
6941        }
6942        let queue = parse_lifecycle("  task_acceptance: queue\n  queue_depth: 1\n")
6943            .unwrap()
6944            .effective_lifecycle();
6945        assert_eq!(queue.task_acceptance, TaskAcceptance::Queue);
6946        assert_eq!(queue.queue_depth, 1);
6947    }
6948
6949    #[test]
6950    fn tasks_held_at_once_under_single_is_one_whatever_the_depth() {
6951        assert_eq!(lifecycle(TaskAcceptance::Single, 1).tasks_held_at_once(), 1);
6952        assert_eq!(lifecycle(TaskAcceptance::Single, 5).tasks_held_at_once(), 1);
6953    }
6954
6955    #[test]
6956    fn tasks_held_at_once_under_none_is_zero_whatever_the_depth() {
6957        assert_eq!(lifecycle(TaskAcceptance::None, 1).tasks_held_at_once(), 0);
6958        assert_eq!(lifecycle(TaskAcceptance::None, 5).tasks_held_at_once(), 0);
6959    }
6960
6961    /// The default is `single` at `queue_depth: 1`, which holds one task; switching only
6962    /// `task_acceptance` to `queue` keeps the default depth and holds two.
6963    #[test]
6964    fn tasks_held_at_once_of_the_default_lifecycle_and_default_depth() {
6965        assert_eq!(LifecycleConfig::default().tasks_held_at_once(), 1);
6966        let queue = RuntimeManifest::from_yaml_str(
6967            "name: cap\nversion: 0.0.1\nlifecycle:\n  task_acceptance: queue\n",
6968        )
6969        .unwrap()
6970        .effective_lifecycle();
6971        assert_eq!(queue.queue_depth, 1);
6972        assert_eq!(queue.tasks_held_at_once(), 2);
6973    }
6974
6975    /// No `trace:` block at all leaves the config absent; the runtime substitutes
6976    /// [`TraceCapture::default`] rather than the manifest carrying a synthesized one.
6977    #[test]
6978    fn absent_trace_block_stays_absent_and_defaults_to_meta() {
6979        let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
6980        assert_eq!(manifest.trace, None);
6981        assert_eq!(TraceCapture::default(), TraceCapture::Meta);
6982    }
6983
6984    #[test]
6985    fn each_capture_mode_parses_from_the_trace_block() {
6986        for (yaml, expected) in [
6987            ("none", TraceCapture::None),
6988            ("meta", TraceCapture::Meta),
6989            ("content", TraceCapture::Content),
6990        ] {
6991            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
6992                "  capture: {yaml}\n"
6993            )))
6994            .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
6995            assert_eq!(
6996                manifest.trace,
6997                Some(TraceConfig {
6998                    capture: expected,
6999                    retain: None
7000                })
7001            );
7002        }
7003    }
7004
7005    // ── retain ───────────────────────────────────────────────────────────────
7006
7007    /// A `trace:` block with only `capture` and a `context:` block with only `record` declare no
7008    /// retention at all: an absent `retain:` is the only way to say "keep everything", and it is
7009    /// what a capsule that never mentions retention gets.
7010    #[test]
7011    fn no_retain_block_anywhere_parses_as_no_policy() {
7012        let manifest = RuntimeManifest::from_yaml_str(
7013            "name: cap\nversion: 0.0.1\ntrace:\n  capture: content\ncontext:\n  record: on\n",
7014        )
7015        .unwrap();
7016        assert_eq!(manifest.trace.unwrap().retain, None);
7017        assert_eq!(manifest.context.unwrap().retain, None);
7018    }
7019
7020    #[test]
7021    fn trace_retain_parses_both_keys_and_the_day_suffix() {
7022        let manifest = RuntimeManifest::from_yaml_str(
7023            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 50\n    max_age: 14d\n",
7024        )
7025        .unwrap();
7026        assert_eq!(
7027            manifest.trace.unwrap().retain,
7028            Some(TraceRetainConfig {
7029                max_sessions: Some(50),
7030                max_age_secs: Some(14 * 86_400),
7031            })
7032        );
7033    }
7034
7035    fn context_images_of(block: &str) -> Result<ContextImagesConfig, RuntimeManifestError> {
7036        RuntimeManifest::from_yaml_str(&format!(
7037            "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n{block}"
7038        ))
7039        .map(|manifest| manifest.context.unwrap().images)
7040    }
7041
7042    #[test]
7043    fn context_images_defaults_apply_without_the_block_and_to_each_absent_key() {
7044        let defaults = ContextImagesConfig {
7045            keep: 5,
7046            max_bytes: 5 * 1024 * 1024,
7047            max_per_turn: 5,
7048        };
7049        assert_eq!(ContextImagesConfig::default(), defaults);
7050        assert_eq!(context_images_of("").unwrap(), defaults);
7051        assert_eq!(
7052            context_images_of("  images:\n    keep: 2\n").unwrap(),
7053            ContextImagesConfig {
7054                keep: 2,
7055                ..defaults
7056            }
7057        );
7058    }
7059
7060    #[test]
7061    fn context_images_parses_every_key() {
7062        assert_eq!(
7063            context_images_of("  images:\n    keep: 1\n    max_bytes: 1Ki\n    max_per_turn: 9\n")
7064                .unwrap(),
7065            ContextImagesConfig {
7066                keep: 1,
7067                max_bytes: 1024,
7068                max_per_turn: 9,
7069            }
7070        );
7071        assert_eq!(
7072            context_images_of("  images:\n    max_bytes: 4096\n")
7073                .unwrap()
7074                .max_bytes,
7075            4096
7076        );
7077    }
7078
7079    #[test]
7080    fn context_images_refuses_zero_and_a_decimal_byte_suffix() {
7081        for (block, field) in [
7082            ("  images:\n    keep: 0\n", "context.images.keep"),
7083            (
7084                "  images:\n    max_per_turn: 0\n",
7085                "context.images.max_per_turn",
7086            ),
7087            ("  images:\n    max_bytes: 0\n", "context.images.max_bytes"),
7088            (
7089                "  images:\n    max_bytes: 10MB\n",
7090                "context.images.max_bytes",
7091            ),
7092            ("  images:\n    keep: many\n", "context.images.keep"),
7093        ] {
7094            match context_images_of(block) {
7095                Err(RuntimeManifestError::InvalidInferenceConfig { field: got, .. }) => {
7096                    assert_eq!(got, field, "{block}")
7097                }
7098                other => panic!("{block}: expected a refusal naming {field}, got {other:?}"),
7099            }
7100        }
7101        let error = context_images_of("  images:\n    keep: 0\n").unwrap_err();
7102        assert!(
7103            error.to_string().contains("got '0'"),
7104            "the value is named as written: {error}"
7105        );
7106    }
7107
7108    #[test]
7109    fn context_images_unknown_key_is_suggested() {
7110        assert_eq!(
7111            unknown_keys_of("name: cap\nversion: 0.1.0\ncontext:\n  images:\n    kep: 3\n"),
7112            vec![(
7113                "kep".to_string(),
7114                "context.images".to_string(),
7115                Some("keep".to_string())
7116            )]
7117        );
7118    }
7119
7120    #[test]
7121    fn context_retain_parses_both_keys_and_either_alone() {
7122        let both = RuntimeManifest::from_yaml_str(
7123            "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n    max_messages: 2000\n    max_age: 90d\n",
7124        )
7125        .unwrap();
7126        assert_eq!(
7127            both.context.unwrap().retain,
7128            Some(ContextRetainConfig {
7129                max_messages: Some(2000),
7130                max_age_secs: Some(90 * 86_400),
7131            })
7132        );
7133
7134        let count_only = RuntimeManifest::from_yaml_str(
7135            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 10\n",
7136        )
7137        .unwrap();
7138        assert_eq!(
7139            count_only.context.unwrap().retain,
7140            Some(ContextRetainConfig {
7141                max_messages: Some(10),
7142                max_age_secs: None,
7143            })
7144        );
7145    }
7146
7147    /// An empty block is not a way to declare no policy — omitting the block is. Both spellings
7148    /// of empty are refused, naming the block an operator has to go and change.
7149    #[test]
7150    fn an_empty_retain_block_is_refused_naming_the_block() {
7151        for (yaml, expect_trace) in [
7152            (
7153                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain: {}\n",
7154                true,
7155            ),
7156            (
7157                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n",
7158                true,
7159            ),
7160            (
7161                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain: {}\n",
7162                false,
7163            ),
7164            (
7165                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n",
7166                false,
7167            ),
7168        ] {
7169            let err = RuntimeManifest::from_yaml_str(yaml)
7170                .expect_err("an empty retain block must be refused");
7171            let (field, message) = match (&err, expect_trace) {
7172                (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
7173                    (field, message)
7174                }
7175                (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
7176                    (field, message)
7177                }
7178                _ => panic!("wrong variant for {yaml:?}: {err:?}"),
7179            };
7180            assert_eq!(
7181                field,
7182                if expect_trace {
7183                    "trace.retain"
7184                } else {
7185                    "context.retain"
7186                }
7187            );
7188            assert!(message.contains("at least one key"), "{message}");
7189        }
7190    }
7191
7192    /// A limit of zero would delete what retention exists to keep, so it is
7193    /// refused naming the key rather than accepted as "keep nothing".
7194    #[test]
7195    fn a_zero_retain_limit_is_refused_naming_the_key() {
7196        let trace = RuntimeManifest::from_yaml_str(
7197            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 0\n",
7198        )
7199        .expect_err("max_sessions: 0 must be refused");
7200        assert!(
7201            matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
7202                     if field == "trace.retain.max_sessions"),
7203            "{trace:?}"
7204        );
7205
7206        let context = RuntimeManifest::from_yaml_str(
7207            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 0\n",
7208        )
7209        .expect_err("max_messages: 0 must be refused");
7210        assert!(
7211            matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
7212                     if field == "context.retain.max_messages"),
7213            "{context:?}"
7214        );
7215
7216        let age = RuntimeManifest::from_yaml_str(
7217            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_age: 0\n",
7218        )
7219        .expect_err("max_age: 0 must be refused");
7220        assert!(
7221            matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
7222                     if field == "trace.retain.max_age"),
7223            "{age:?}"
7224        );
7225    }
7226
7227    #[test]
7228    fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
7229        let err = RuntimeManifest::from_yaml_str(
7230            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_traces: 3\n",
7231        )
7232        .expect_err("an unknown key must be refused");
7233        match err {
7234            RuntimeManifestError::InvalidTraceConfig { field, message } => {
7235                assert_eq!(field, "trace.retain");
7236                assert!(message.contains("max_traces"), "{message}");
7237                assert!(message.contains("max_sessions"), "{message}");
7238            }
7239            other => panic!("expected InvalidTraceConfig, got {other:?}"),
7240        }
7241    }
7242
7243    #[test]
7244    fn an_unparseable_retain_duration_reports_the_accepted_form() {
7245        let err = RuntimeManifest::from_yaml_str(
7246            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_age: 5 weeks\n",
7247        )
7248        .expect_err("'5 weeks' must be refused");
7249        match err {
7250            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
7251                assert_eq!(field, "context.retain.max_age");
7252                assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
7253            }
7254            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
7255        }
7256    }
7257
7258    #[test]
7259    fn unparseable_capture_value_names_the_field_and_accepted_values() {
7260        let err = RuntimeManifest::from_yaml_str(&manifest_with_trace("  capture: verbose\n"))
7261            .expect_err("'verbose' is not a capture mode");
7262        let rendered = err.to_string();
7263        assert!(rendered.contains("trace.capture"), "{rendered}");
7264        for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
7265            assert!(rendered.contains(accepted), "{rendered}");
7266        }
7267    }
7268
7269    #[test]
7270    fn resources_block_is_optional_and_absent_stays_absent() {
7271        let manifest = RuntimeManifest::from_yaml_str(
7272            r#"
7273name: cap
7274version: 0.0.1
7275capabilities:
7276  shell:
7277    allow: [bash]
7278"#,
7279        )
7280        .unwrap();
7281
7282        // `None` here is "the manifest said nothing", not "unlimited" — the runtime substitutes
7283        // its own defaults (see `capsule_runtime::resources::HostResourceLimits::resolve`), and
7284        // keeping the two states distinct is what lets it own them in one place.
7285        let caps = manifest.capabilities.expect("shell block must parse");
7286        assert_eq!(caps.resources, None);
7287    }
7288
7289    #[test]
7290    fn resources_fields_parse_and_each_stays_independently_optional() {
7291        let manifest = RuntimeManifest::from_yaml_str(
7292            r#"
7293name: cap
7294version: 0.0.1
7295capabilities:
7296  resources:
7297    max_processes: 32
7298    max_open_files: 16
7299    max_file_size_bytes: 1048576
7300    cpu_seconds: 30
7301    memory_bytes: 268435456
7302    cgroup_memory_bytes: 536870912
7303    cgroup_pids_max: 64
7304    cgroup_cpu_percent: 50
7305    cgroup_io_bytes_per_sec: 1048576
7306    workdir_max_bytes: 2097152
7307"#,
7308        )
7309        .unwrap();
7310
7311        let resources = manifest
7312            .capabilities
7313            .and_then(|caps| caps.resources)
7314            .expect("resources block must parse");
7315        assert_eq!(resources.max_processes, Some(32));
7316        assert_eq!(resources.max_open_files, Some(16));
7317        assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
7318        assert_eq!(resources.cpu_seconds, Some(30));
7319        assert_eq!(resources.memory_bytes, Some(268_435_456));
7320        assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
7321        assert_eq!(resources.cgroup_pids_max, Some(64));
7322        assert_eq!(resources.cgroup_cpu_percent, Some(50));
7323        assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
7324        assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
7325    }
7326
7327    #[test]
7328    fn state_block_is_optional_and_absent_stays_absent() {
7329        let manifest = RuntimeManifest::from_yaml_str(
7330            r#"
7331name: cap
7332version: 0.0.1
7333artifacts:
7334  - name: local/notes-tool
7335    version: 1.0.0
7336    runtime: tool
7337    capabilities:
7338      filesystem:
7339        scope: cache
7340"#,
7341        )
7342        .unwrap();
7343
7344        // Absent is deny: no store name to default, and nothing downstream creates a directory.
7345        let caps = manifest.artifacts[0]
7346            .capabilities
7347            .as_ref()
7348            .expect("filesystem block must parse");
7349        assert_eq!(caps.state, None);
7350    }
7351
7352    /// A declared-but-empty `state:` block is a real grant — it means "give me a store, named
7353    /// after the capsule" — so it must lower to `Some(..)` with no name, distinguishable from
7354    /// the absent block above.
7355    #[test]
7356    fn state_block_declared_empty_grants_the_capsule_named_store() {
7357        let manifest = RuntimeManifest::from_yaml_str(
7358            r#"
7359name: cap
7360version: 0.0.1
7361artifacts:
7362  - name: local/notes-tool
7363    version: 1.0.0
7364    runtime: tool
7365    capabilities:
7366      state: {}
7367"#,
7368        )
7369        .unwrap();
7370
7371        let state = manifest.artifacts[0]
7372            .capabilities
7373            .as_ref()
7374            .and_then(|caps| caps.state.as_ref())
7375            .expect("state block must parse");
7376        assert_eq!(state.store, None);
7377    }
7378
7379    #[test]
7380    fn state_store_name_parses_and_stays_independently_optional() {
7381        let manifest = RuntimeManifest::from_yaml_str(
7382            r#"
7383name: cap
7384version: 0.0.1
7385artifacts:
7386  - name: local/notes-tool
7387    version: 1.0.0
7388    runtime: tool
7389    capabilities:
7390      state:
7391        store: "  shey  "
7392      network:
7393        allow: [https://api.example.com]
7394"#,
7395        )
7396        .unwrap();
7397
7398        let caps = manifest.artifacts[0]
7399            .capabilities
7400            .as_ref()
7401            .expect("capabilities must parse");
7402        // Trimmed, but otherwise verbatim: whether the name is a usable directory segment is
7403        // decided by the runtime (`E-CAP-009`), not here.
7404        assert_eq!(
7405            caps.state.as_ref().and_then(|state| state.store.as_deref()),
7406            Some("shey")
7407        );
7408        // Declaring a store never touches any sibling sub-block.
7409        assert_eq!(
7410            caps.network
7411                .as_ref()
7412                .map(|network| network.allow.as_slice()),
7413            Some(["https://api.example.com".to_string()].as_slice())
7414        );
7415        assert_eq!(caps.filesystem, None);
7416    }
7417
7418    /// An explicitly empty name is kept rather than normalised to "default to the capsule name":
7419    /// the operator wrote a name, and handing them a different store than the one they wrote
7420    /// would be worse than the refusal the runtime raises for it.
7421    #[test]
7422    fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
7423        let manifest = RuntimeManifest::from_yaml_str(
7424            r#"
7425name: cap
7426version: 0.0.1
7427artifacts:
7428  - name: local/notes-tool
7429    version: 1.0.0
7430    runtime: tool
7431    capabilities:
7432      state:
7433        store: ""
7434"#,
7435        )
7436        .unwrap();
7437
7438        assert_eq!(
7439            manifest.artifacts[0]
7440                .capabilities
7441                .as_ref()
7442                .and_then(|caps| caps.state.as_ref())
7443                .and_then(|state| state.store.as_deref()),
7444            Some("")
7445        );
7446    }
7447
7448    #[test]
7449    fn resources_partial_block_leaves_undeclared_fields_none() {
7450        let manifest = RuntimeManifest::from_yaml_str(
7451            r#"
7452name: cap
7453version: 0.0.1
7454capabilities:
7455  resources:
7456    max_open_files: 16
7457"#,
7458        )
7459        .unwrap();
7460
7461        let resources = manifest
7462            .capabilities
7463            .and_then(|caps| caps.resources)
7464            .expect("resources block must parse");
7465        assert_eq!(resources.max_open_files, Some(16));
7466        assert_eq!(resources.max_processes, None);
7467        assert_eq!(resources.workdir_max_bytes, None);
7468    }
7469
7470    /// Every field rejects `0` at parse time, naming its own path. A zero ceiling is never what
7471    /// an author means, and accepting one would turn a typo into a subprocess that cannot fork,
7472    /// open a file, or run for a single CPU-second — discovered at runtime instead of at parse.
7473    #[test]
7474    fn resources_zero_is_rejected_on_every_field() {
7475        for field in [
7476            "max_processes",
7477            "max_open_files",
7478            "max_file_size_bytes",
7479            "cpu_seconds",
7480            "memory_bytes",
7481            "cgroup_memory_bytes",
7482            "cgroup_pids_max",
7483            "cgroup_cpu_percent",
7484            "cgroup_io_bytes_per_sec",
7485            "workdir_max_bytes",
7486        ] {
7487            let yaml =
7488                format!("name: cap\nversion: 0.0.1\ncapabilities:\n  resources:\n    {field}: 0\n");
7489            let error =
7490                RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
7491
7492            match error {
7493                RuntimeManifestError::InvalidCapabilities {
7494                    field: reported,
7495                    message,
7496                } => {
7497                    assert_eq!(reported, format!("capabilities.resources.{field}"));
7498                    assert_eq!(message, "must be greater than zero");
7499                }
7500                other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
7501            }
7502        }
7503    }
7504
7505    #[test]
7506    fn artifact_runtime_defaults_to_tool() {
7507        let manifest = RuntimeManifest::from_yaml_str(
7508            r#"
7509name: cap
7510version: 0.0.1
7511artifacts:
7512  - name: local/echo
7513    version: 1.2.3
7514"#,
7515        )
7516        .unwrap();
7517
7518        assert_eq!(manifest.artifacts.len(), 1);
7519        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7520    }
7521
7522    #[test]
7523    fn artifact_runtime_tool_explicit() {
7524        let manifest = RuntimeManifest::from_yaml_str(
7525            r#"
7526name: cap
7527version: 0.0.1
7528artifacts:
7529  - name: local/echo
7530    version: 1.2.3
7531    runtime: tool
7532"#,
7533        )
7534        .unwrap();
7535
7536        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7537    }
7538
7539    #[test]
7540    fn artifact_runtime_wasm_is_error() {
7541        let err = RuntimeManifest::from_yaml_str(
7542            r#"
7543name: cap
7544version: 0.0.1
7545artifacts:
7546  - name: local/echo
7547    version: 1.2.3
7548    runtime: wasm
7549"#,
7550        )
7551        .unwrap_err();
7552
7553        let msg = err.to_string();
7554        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7555        assert!(msg.contains("wasm"), "error was: {msg}");
7556    }
7557
7558    #[test]
7559    fn artifact_runtime_native_is_error() {
7560        let err = RuntimeManifest::from_yaml_str(
7561            r#"
7562name: cap
7563version: 0.0.1
7564artifacts:
7565  - name: local/echo
7566    version: 1.2.3
7567    runtime: native
7568"#,
7569        )
7570        .unwrap_err();
7571
7572        let msg = err.to_string();
7573        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7574        assert!(msg.contains("native"), "error was: {msg}");
7575    }
7576
7577    #[test]
7578    fn invalid_artifact_reports_index() {
7579        let err = RuntimeManifest::from_yaml_str(
7580            r#"
7581name: cap
7582version: 0.0.1
7583artifacts:
7584  - name: ""
7585    version: 1.2.3
7586"#,
7587        )
7588        .unwrap_err();
7589
7590        let msg = err.to_string();
7591        assert!(msg.contains("index 0"));
7592        assert!(msg.contains("missing required field 'name'"));
7593    }
7594
7595    #[test]
7596    fn capabilities_parse_when_all_fields_present() {
7597        let manifest = RuntimeManifest::from_yaml_str(
7598            r#"
7599name: cap
7600version: 0.0.1
7601artifacts:
7602  - name: local/echo
7603    version: 1.2.3
7604capabilities:
7605  network:
7606    allow:
7607      - https://api.anthropic.com
7608      - http://127.0.0.1:8080
7609  filesystem:
7610    scope: ./sandbox
7611"#,
7612        )
7613        .unwrap();
7614
7615        let capabilities = manifest.capabilities.expect("capabilities should exist");
7616        assert_eq!(
7617            capabilities.network.unwrap().allow,
7618            vec![
7619                "https://api.anthropic.com".to_string(),
7620                "http://127.0.0.1:8080".to_string()
7621            ]
7622        );
7623        assert_eq!(
7624            capabilities.filesystem.unwrap().scope,
7625            Some("./sandbox".to_string())
7626        );
7627    }
7628
7629    #[test]
7630    fn env_capabilities_parse_allowlist() {
7631        let manifest = RuntimeManifest::from_yaml_str(
7632            r#"
7633name: cap
7634version: 0.0.1
7635artifacts:
7636  - name: local/echo
7637    version: 1.2.3
7638capabilities:
7639  env:
7640    allow:
7641      - MY_APP_REGION
7642      - GITHUB_TOKEN
7643"#,
7644        )
7645        .unwrap();
7646
7647        let capabilities = manifest.capabilities.expect("capabilities should exist");
7648        assert_eq!(
7649            capabilities.env.unwrap().allow,
7650            vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
7651        );
7652    }
7653
7654    #[test]
7655    fn env_capabilities_absent_when_block_omitted() {
7656        let manifest = RuntimeManifest::from_yaml_str(
7657            r#"
7658name: cap
7659version: 0.0.1
7660artifacts:
7661  - name: local/echo
7662    version: 1.2.3
7663capabilities:
7664  network:
7665    allow:
7666      - https://api.anthropic.com
7667"#,
7668        )
7669        .unwrap();
7670
7671        assert!(manifest.capabilities.unwrap().env.is_none());
7672    }
7673
7674    /// Unlike `capabilities.shell.allow`, an empty `env.allow` is a legitimate no-op rather
7675    /// than a manifest error.
7676    #[test]
7677    fn env_capabilities_allow_empty_list_is_accepted() {
7678        let manifest = RuntimeManifest::from_yaml_str(
7679            r#"
7680name: cap
7681version: 0.0.1
7682artifacts:
7683  - name: local/echo
7684    version: 1.2.3
7685capabilities:
7686  env: {}
7687"#,
7688        )
7689        .unwrap();
7690
7691        assert_eq!(
7692            manifest.capabilities.unwrap().env,
7693            Some(EnvCapabilities { allow: vec![] })
7694        );
7695    }
7696
7697    #[test]
7698    fn missing_capabilities_block_is_backward_compatible() {
7699        let manifest = RuntimeManifest::from_yaml_str(
7700            r#"
7701name: cap
7702version: 0.0.1
7703artifacts:
7704  - name: local/echo
7705    version: 1.2.3
7706"#,
7707        )
7708        .unwrap();
7709
7710        assert!(manifest.capabilities.is_none());
7711    }
7712
7713    #[test]
7714    fn network_only_capabilities_parse() {
7715        let manifest = RuntimeManifest::from_yaml_str(
7716            r#"
7717name: cap
7718version: 0.0.1
7719artifacts:
7720  - name: local/echo
7721    version: 1.2.3
7722capabilities:
7723  network:
7724    allow:
7725      - https://allowed.example.com
7726"#,
7727        )
7728        .unwrap();
7729
7730        let capabilities = manifest.capabilities.unwrap();
7731        assert_eq!(
7732            capabilities.network.unwrap().allow,
7733            vec!["https://allowed.example.com".to_string()]
7734        );
7735        assert!(capabilities.filesystem.is_none());
7736    }
7737
7738    #[test]
7739    fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
7740        let manifest = RuntimeManifest::from_yaml_str(
7741            r#"
7742name: cap
7743version: 0.0.1
7744artifacts:
7745  - name: local/echo
7746    version: 1.2.3
7747capabilities:
7748  network: {}
7749"#,
7750        )
7751        .unwrap();
7752
7753        let capabilities = manifest.capabilities.unwrap();
7754        assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
7755    }
7756
7757    /// `unix_sockets` is optional and denies by default. A capsule that never heard of the key
7758    /// must not silently get `AF_UNIX` sockets — that is the whole `/var/run/docker.sock` escape.
7759    #[test]
7760    fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
7761        let manifest = RuntimeManifest::from_yaml_str(
7762            r#"
7763name: cap
7764version: 0.0.1
7765artifacts:
7766  - name: local/echo
7767    version: 1.2.3
7768capabilities:
7769  network:
7770    allow:
7771      - https://api.anthropic.com
7772"#,
7773        )
7774        .unwrap();
7775
7776        let network = manifest.capabilities.unwrap().network.unwrap();
7777        assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
7778        assert!(!network.unix_sockets);
7779    }
7780
7781    #[test]
7782    fn network_unix_sockets_parses_explicit_false() {
7783        let manifest = RuntimeManifest::from_yaml_str(
7784            r#"
7785name: cap
7786version: 0.0.1
7787artifacts:
7788  - name: local/echo
7789    version: 1.2.3
7790capabilities:
7791  network:
7792    unix_sockets: false
7793"#,
7794        )
7795        .unwrap();
7796
7797        assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
7798    }
7799
7800    #[test]
7801    fn network_unix_sockets_parses_explicit_true() {
7802        let manifest = RuntimeManifest::from_yaml_str(
7803            r#"
7804name: cap
7805version: 0.0.1
7806artifacts:
7807  - name: local/echo
7808    version: 1.2.3
7809capabilities:
7810  network:
7811    unix_sockets: true
7812"#,
7813        )
7814        .unwrap();
7815
7816        let network = manifest.capabilities.unwrap().network.unwrap();
7817        assert!(network.unix_sockets);
7818        // The opt-in is orthogonal to the IP allowlist — declaring it does not imply any host.
7819        assert_eq!(network.allow, Vec::<String>::new());
7820    }
7821
7822    /// `read_only` is normalised on exactly the terms `scope` is — trimmed, with an entry left
7823    /// empty by that trim dropped rather than carried as a rule covering the whole workdir — and
7824    /// path shape is deliberately not judged here.
7825    #[test]
7826    fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
7827        let manifest = RuntimeManifest::from_yaml_str(
7828            r#"
7829name: cap
7830version: 0.0.1
7831capabilities:
7832  filesystem:
7833    read_only:
7834      - tests
7835      - "  bench/fixtures  "
7836      - ""
7837      - "   "
7838"#,
7839        )
7840        .unwrap();
7841
7842        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7843        assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
7844    }
7845
7846    /// An absent key and an empty list are one declaration: nothing is protected.
7847    #[test]
7848    fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
7849        let manifest = RuntimeManifest::from_yaml_str(
7850            r#"
7851name: cap
7852version: 0.0.1
7853capabilities:
7854  filesystem:
7855    scope: workdir
7856"#,
7857        )
7858        .unwrap();
7859
7860        assert!(manifest
7861            .capabilities
7862            .unwrap()
7863            .filesystem
7864            .unwrap()
7865            .read_only
7866            .is_empty());
7867    }
7868
7869    /// Path shape is the runtime's refusal, not the parser's: an absolute or escaping entry parses
7870    /// here exactly as an absolute `scope` does, and fails at launch with `E-CAP-012`.
7871    #[test]
7872    fn filesystem_read_only_does_not_judge_path_shape() {
7873        let manifest = RuntimeManifest::from_yaml_str(
7874            r#"
7875name: cap
7876version: 0.0.1
7877capabilities:
7878  filesystem:
7879    read_only:
7880      - /etc
7881      - ../outside
7882"#,
7883        )
7884        .expect("shape is judged by the runtime, not the parser");
7885
7886        assert_eq!(
7887            manifest.capabilities.unwrap().filesystem.unwrap().read_only,
7888            vec!["/etc", "../outside"]
7889        );
7890    }
7891
7892    /// The default every pre-existing manifest gets: a `filesystem:` block that has never heard of
7893    /// `workdir_exec` must not silently keep the workdir's `Execute` right, because that right is
7894    /// exactly what makes `capabilities.shell.allow` bypassable from inside the workdir.
7895    #[test]
7896    fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
7897        let manifest = RuntimeManifest::from_yaml_str(
7898            r#"
7899name: cap
7900version: 0.0.1
7901artifacts:
7902  - name: local/echo
7903    version: 1.2.3
7904capabilities:
7905  filesystem:
7906    scope: workdir
7907"#,
7908        )
7909        .unwrap();
7910
7911        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7912        assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
7913        assert!(!filesystem.workdir_exec);
7914    }
7915
7916    /// The other absent case: no `filesystem:` block at all. There is nothing to default here —
7917    /// the whole sub-block stays `None` — but a capsule with no filesystem declaration must still
7918    /// read as "workdir exec denied" downstream, which `capability_policy_from_runtime_manifest`
7919    /// covers with its own test.
7920    #[test]
7921    fn filesystem_block_stays_absent_when_undeclared() {
7922        let manifest = RuntimeManifest::from_yaml_str(
7923            r#"
7924name: cap
7925version: 0.0.1
7926artifacts:
7927  - name: local/echo
7928    version: 1.2.3
7929capabilities:
7930  network:
7931    allow:
7932      - https://api.anthropic.com
7933"#,
7934        )
7935        .unwrap();
7936
7937        assert!(manifest.capabilities.unwrap().filesystem.is_none());
7938    }
7939
7940    #[test]
7941    fn filesystem_workdir_exec_parses_explicit_false() {
7942        let manifest = RuntimeManifest::from_yaml_str(
7943            r#"
7944name: cap
7945version: 0.0.1
7946artifacts:
7947  - name: local/echo
7948    version: 1.2.3
7949capabilities:
7950  filesystem:
7951    workdir_exec: false
7952"#,
7953        )
7954        .unwrap();
7955
7956        assert!(
7957            !manifest
7958                .capabilities
7959                .unwrap()
7960                .filesystem
7961                .unwrap()
7962                .workdir_exec
7963        );
7964    }
7965
7966    #[test]
7967    fn filesystem_workdir_exec_parses_explicit_true() {
7968        let manifest = RuntimeManifest::from_yaml_str(
7969            r#"
7970name: cap
7971version: 0.0.1
7972artifacts:
7973  - name: local/echo
7974    version: 1.2.3
7975capabilities:
7976  filesystem:
7977    workdir_exec: true
7978"#,
7979        )
7980        .unwrap();
7981
7982        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7983        assert!(filesystem.workdir_exec);
7984        // Orthogonal to `scope`, exactly as `unix_sockets` is orthogonal to `network.allow`:
7985        // declaring one says nothing about the other.
7986        assert_eq!(filesystem.scope, None);
7987    }
7988
7989    #[test]
7990    fn network_internal_port_parses() {
7991        let manifest = RuntimeManifest::from_yaml_str(
7992            r#"
7993name: worker
7994version: 0.1.0
7995artifacts: []
7996
7997network:
7998  internal_port: 58172
7999"#,
8000        )
8001        .unwrap();
8002
8003        assert_eq!(
8004            manifest.network.unwrap().internal_port,
8005            Some(58172),
8006            "network.internal_port should parse to Some(58172)"
8007        );
8008    }
8009
8010    #[test]
8011    fn network_internal_port_absent_when_omitted() {
8012        let manifest = RuntimeManifest::from_yaml_str(
8013            r#"
8014name: worker
8015version: 0.1.0
8016artifacts: []
8017"#,
8018        )
8019        .unwrap();
8020
8021        assert!(manifest.network.is_none());
8022    }
8023
8024    #[test]
8025    fn shell_capabilities_parse_with_allow_and_env_fields() {
8026        let manifest = RuntimeManifest::from_yaml_str(
8027            r#"
8028name: cap
8029version: 0.0.1
8030artifacts: []
8031capabilities:
8032  shell:
8033    allow:
8034      - bash
8035      - git
8036    strip_env:
8037      - "*_TOKEN"
8038      - SECRET_KEY
8039    baseline_env:
8040      - SSH_AUTH_SOCK
8041"#,
8042        )
8043        .unwrap();
8044
8045        let shell = manifest
8046            .capabilities
8047            .unwrap()
8048            .shell
8049            .expect("shell capabilities should be present");
8050        assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
8051        assert_eq!(
8052            shell.strip_env,
8053            Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
8054        );
8055        assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
8056    }
8057
8058    #[test]
8059    fn shell_capabilities_require_non_empty_allowlist() {
8060        let err = RuntimeManifest::from_yaml_str(
8061            r#"
8062name: cap
8063version: 0.0.1
8064artifacts: []
8065capabilities:
8066  shell:
8067    allow: []
8068"#,
8069        )
8070        .unwrap_err();
8071
8072        let msg = err.to_string();
8073        assert!(msg.contains("capabilities.shell.allow"));
8074        assert!(msg.contains("at least one"));
8075    }
8076
8077    fn shell_allow_error(entry: &str) -> String {
8078        let yaml = format!(
8079            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  shell:\n    allow: [bash, {}]\n",
8080            serde_json::to_string(entry).unwrap()
8081        );
8082        RuntimeManifest::from_yaml_str(&yaml)
8083            .expect_err(&format!("{entry:?} must be refused"))
8084            .to_string()
8085    }
8086
8087    #[test]
8088    fn shell_allow_refuses_an_entry_that_is_not_a_bare_name() {
8089        for (entry, reason) in [
8090            ("/tmp/uv-tool/bin/uv", "is a path"),
8091            ("bin/uv", "is a path"),
8092            ("..", "names a directory"),
8093            (".", "names a directory"),
8094            ("", "is empty"),
8095            ("a b", "contains whitespace"),
8096            ("uv\t", "contains whitespace"),
8097            ("uv\0", "contains a NUL byte"),
8098        ] {
8099            let message = shell_allow_error(entry);
8100            assert!(
8101                message.contains("'capabilities.shell.allow[1]'"),
8102                "{entry:?}: {message}"
8103            );
8104            assert!(
8105                message.contains(&format!(
8106                    "'{}' {reason}; an entry is a bare binary name",
8107                    entry.escape_debug()
8108                )),
8109                "{entry:?}: {message}"
8110            );
8111        }
8112    }
8113
8114    #[test]
8115    fn shell_allow_accepts_bare_names() {
8116        let manifest = RuntimeManifest::from_yaml_str(
8117            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  shell:\n    allow: [bash, python3.12, git-lfs, \"c++\"]\n",
8118        )
8119        .unwrap();
8120        assert_eq!(
8121            manifest.capabilities.unwrap().shell.unwrap().allow,
8122            ["bash", "python3.12", "git-lfs", "c++"]
8123        );
8124    }
8125
8126    #[test]
8127    fn interpreter_runtime_parses_accepted_shape() {
8128        let manifest = RuntimeManifest::from_yaml_str(
8129            r#"
8130name: cap
8131version: 0.0.1
8132artifacts: []
8133capabilities:
8134  shell:
8135    allow:
8136      - python3
8137    interpreter_runtime:
8138      - binary: python3
8139        dirs:
8140          - path: /usr/lib/python3.11
8141            list_dir: true
8142          - path: /usr/lib/python3.11/lib-dynload
8143            list_dir: false
8144"#,
8145        )
8146        .unwrap();
8147
8148        let shell = manifest.capabilities.unwrap().shell.unwrap();
8149        assert_eq!(
8150            shell.interpreter_runtime,
8151            vec![InterpreterRuntimeGrant {
8152                binary: "python3".to_string(),
8153                dirs: vec![
8154                    InterpreterRuntimeDir {
8155                        path: "/usr/lib/python3.11".to_string(),
8156                        list_dir: true,
8157                    },
8158                    InterpreterRuntimeDir {
8159                        path: "/usr/lib/python3.11/lib-dynload".to_string(),
8160                        list_dir: false,
8161                    },
8162                ],
8163            }]
8164        );
8165    }
8166
8167    #[test]
8168    fn interpreter_runtime_defaults_to_empty_when_absent() {
8169        let manifest = RuntimeManifest::from_yaml_str(
8170            r#"
8171name: cap
8172version: 0.0.1
8173artifacts: []
8174capabilities:
8175  shell:
8176    allow:
8177      - python3
8178"#,
8179        )
8180        .unwrap();
8181
8182        let shell = manifest.capabilities.unwrap().shell.unwrap();
8183        assert!(shell.interpreter_runtime.is_empty());
8184    }
8185
8186    /// Shared helper: parse a manifest whose sole `interpreter_runtime` grant is `grant_yaml`
8187    /// (indented to sit under `interpreter_runtime:`), returning the error message.
8188    fn interpreter_runtime_reject(grant_yaml: &str) -> String {
8189        let yaml = format!(
8190            r#"
8191name: cap
8192version: 0.0.1
8193artifacts: []
8194capabilities:
8195  shell:
8196    allow:
8197      - python3
8198    interpreter_runtime:
8199{grant_yaml}
8200"#,
8201        );
8202        RuntimeManifest::from_yaml_str(&yaml)
8203            .expect_err("grant should be rejected")
8204            .to_string()
8205    }
8206
8207    #[test]
8208    fn interpreter_runtime_rejects_binary_not_in_allow() {
8209        let msg = interpreter_runtime_reject(
8210            "      - binary: ruby\n        dirs:\n          - path: /usr/lib/ruby\n            list_dir: true\n",
8211        );
8212        assert!(
8213            msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
8214            "{msg}"
8215        );
8216        assert!(msg.contains("ruby"), "{msg}");
8217        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
8218    }
8219
8220    #[test]
8221    fn interpreter_runtime_rejects_relative_path() {
8222        let msg = interpreter_runtime_reject(
8223            "      - binary: python3\n        dirs:\n          - path: usr/lib/python3.11\n            list_dir: true\n",
8224        );
8225        assert!(
8226            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
8227            "{msg}"
8228        );
8229        assert!(msg.contains("absolute"), "{msg}");
8230    }
8231
8232    // ── capabilities.plan (capsule-wide) ─────────────────────────────────────
8233
8234    /// A capsule manifest whose capsule-wide capabilities carry `plan: <block>`.
8235    fn manifest_with_plan(block: &str) -> String {
8236        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n{block}")
8237    }
8238
8239    #[test]
8240    fn plan_submit_true_grants_the_capsule() {
8241        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n"))
8242            .expect("a plan block parses");
8243        assert_eq!(
8244            manifest.capabilities.unwrap().plan,
8245            Some(PlanCapabilities { submit: true })
8246        );
8247    }
8248
8249    /// `submit: false` and an absent `plan:` block both leave the capsule ungranted; the
8250    /// difference between them is only whether the operator wrote the denial down.
8251    #[test]
8252    fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
8253        let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: false\n"))
8254            .expect("an explicit denial parses");
8255        assert_eq!(
8256            explicit.capabilities.unwrap().plan,
8257            Some(PlanCapabilities { submit: false })
8258        );
8259
8260        let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
8261            .expect("no capabilities block parses");
8262        assert!(absent.capabilities.is_none());
8263    }
8264
8265    /// The capability is never inferred: a `plan:` block that omits `submit:` is a parse error
8266    /// naming the key, exactly as `task_io:` without `read:` is.
8267    #[test]
8268    fn plan_without_submit_is_rejected() {
8269        let err = RuntimeManifest::from_yaml_str(&manifest_with_plan("    {}\n"))
8270            .expect_err("an empty plan block is refused");
8271        let msg = err.to_string();
8272        assert!(msg.contains("capabilities.plan.submit"), "{msg}");
8273        assert!(msg.contains("never"), "{msg}");
8274    }
8275
8276    /// An unknown key inside the block is reported by path, like every other capability block.
8277    #[test]
8278    fn an_unknown_plan_key_is_reported_by_path() {
8279        let manifest =
8280            RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n    depth: 3\n"))
8281                .expect("an unknown key is a warning, not a parse error");
8282        assert!(
8283            manifest
8284                .unknown_keys
8285                .iter()
8286                .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
8287            "{:?}",
8288            manifest.unknown_keys
8289        );
8290    }
8291
8292    // ── capabilities.install (capsule-wide) ──────────────────────────────────
8293
8294    /// A capsule manifest whose capsule-wide capabilities carry `install: <block>`.
8295    fn manifest_with_install(block: &str) -> String {
8296        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  install:\n{block}")
8297    }
8298
8299    #[test]
8300    fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
8301        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8302            "    skill: [ana/code-review, \"ana/style-*\"]\n    tool: [\"local/b-*\", local/a, \"murmur/*\", \"*\"]\n",
8303        ))
8304        .expect("an install block parses");
8305        assert_eq!(
8306            manifest.capabilities.unwrap().install,
8307            Some(InstallCapabilities {
8308                skill: vec!["ana/code-review".into(), "ana/style-*".into()],
8309                tool: vec![
8310                    "local/b-*".into(),
8311                    "local/a".into(),
8312                    "murmur/*".into(),
8313                    "*".into()
8314                ],
8315                from: InstallFrom::Stores,
8316            })
8317        );
8318    }
8319
8320    /// Either list alone is a grant; the other defaults to empty, which admits nothing of its
8321    /// kind.
8322    #[test]
8323    fn capabilities_install_accepts_either_list_alone() {
8324        let skill_only =
8325            RuntimeManifest::from_yaml_str(&manifest_with_install("    skill: [local/a]\n"))
8326                .expect("skill alone parses");
8327        assert_eq!(
8328            skill_only.capabilities.unwrap().install,
8329            Some(InstallCapabilities {
8330                skill: vec!["local/a".into()],
8331                tool: vec![],
8332                from: InstallFrom::Stores,
8333            })
8334        );
8335        let tool_only =
8336            RuntimeManifest::from_yaml_str(&manifest_with_install("    tool: [\"local/b-*\"]\n"))
8337                .expect("tool alone parses");
8338        assert_eq!(
8339            tool_only.capabilities.unwrap().install,
8340            Some(InstallCapabilities {
8341                skill: vec![],
8342                tool: vec!["local/b-*".into()],
8343                from: InstallFrom::Stores,
8344            })
8345        );
8346    }
8347
8348    #[test]
8349    fn capabilities_install_absent_is_none() {
8350        let manifest = RuntimeManifest::from_yaml_str(
8351            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n    submit: true\n",
8352        )
8353        .expect("a capabilities block without install parses");
8354        assert_eq!(manifest.capabilities.unwrap().install, None);
8355    }
8356
8357    fn install_from(list: &str) -> Result<InstallFrom, RuntimeManifestError> {
8358        RuntimeManifest::from_yaml_str(&manifest_with_install(&format!(
8359            "    tool: [\"murmur/tool-*\"]\n    from: {list}\n"
8360        )))
8361        .map(|manifest| manifest.capabilities.unwrap().install.unwrap().from)
8362    }
8363
8364    #[test]
8365    fn install_from_absent_is_stores() {
8366        let manifest =
8367            RuntimeManifest::from_yaml_str(&manifest_with_install("    tool: [local/a]\n"))
8368                .unwrap();
8369        assert_eq!(
8370            manifest.capabilities.unwrap().install.unwrap().from,
8371            InstallFrom::Stores
8372        );
8373    }
8374
8375    #[test]
8376    fn install_from_accepts_stores_alone_and_with_sources_in_either_order() {
8377        assert_eq!(install_from("[stores]").unwrap(), InstallFrom::Stores);
8378        assert_eq!(
8379            install_from("[stores, sources]").unwrap(),
8380            InstallFrom::StoresAndSources
8381        );
8382        assert_eq!(
8383            install_from("[sources, stores]").unwrap(),
8384            InstallFrom::StoresAndSources
8385        );
8386        assert_eq!(InstallFrom::StoresAndSources.as_list(), "stores, sources");
8387        assert!(InstallFrom::StoresAndSources.sources());
8388        assert!(!InstallFrom::Stores.sources());
8389    }
8390
8391    #[test]
8392    fn install_from_refuses_empty_sourceless_duplicate_and_unknown_lists() {
8393        for (list, why) in [
8394            ("[]", "is empty"),
8395            ("[sources]", "does not name stores"),
8396            ("[stores, stores]", "names \"stores\" twice"),
8397            ("[github]", "names \"github\", which is not a place"),
8398        ] {
8399            match install_from(list) {
8400                Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8401                    assert_eq!(field, "capabilities.install.from", "{list}");
8402                    assert!(message.contains(why), "{list}: {message}");
8403                    assert!(
8404                        message.contains("expected [stores] or [stores, sources]"),
8405                        "{list}: {message}"
8406                    );
8407                }
8408                other => panic!("{list}: expected InvalidCapabilities, got {other:?}"),
8409            }
8410        }
8411    }
8412
8413    #[test]
8414    fn install_from_is_no_unknown_key() {
8415        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8416            "    tool: [local/a]\n    from: [stores]\n",
8417        ))
8418        .unwrap();
8419        assert!(
8420            manifest
8421                .unknown_keys
8422                .iter()
8423                .all(|key| key.block_path != "capabilities.install"),
8424            "{:?}",
8425            manifest.unknown_keys
8426        );
8427    }
8428
8429    #[test]
8430    fn install_from_capabilities_install_is_refused_under_transport_process() {
8431        let yaml = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  install:\n    \
8432                    tool: [\"murmur/tool-*\"]\ninference:\n  transport: process\n  \
8433                    command: claude\n  model: claude-haiku-4-5-20251001\n  driver:\n    \
8434                    artifact: murmur/driver-claude-code\n";
8435        match RuntimeManifest::from_yaml_str(yaml) {
8436            Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8437                assert_eq!(field, "capabilities.install");
8438                assert_eq!(
8439                    message,
8440                    "is not valid with transport: process; the harness owns its tool list, so \
8441                     a pulled artifact could never reach it"
8442                );
8443            }
8444            other => panic!("expected InvalidCapabilities, got {other:?}"),
8445        }
8446    }
8447
8448    /// A block that names nothing is refused rather than read as "grant nothing": omitting the
8449    /// block is how a capsule grants nothing.
8450    #[test]
8451    fn capabilities_install_that_names_nothing_is_refused() {
8452        for block in [
8453            "    {}\n",
8454            "    skill: []\n",
8455            "    skill: []\n    tool: []\n",
8456            "    driver: [x]\n",
8457        ] {
8458            let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
8459                .expect_err("an install block naming nothing is refused");
8460            match &err {
8461                RuntimeManifestError::InvalidCapabilities { field, message } => {
8462                    assert_eq!(field, "capabilities.install", "{block:?}");
8463                    assert!(message.contains("at least one"), "{message}");
8464                    assert!(message.contains("omit the block"), "{message}");
8465                }
8466                other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
8467            }
8468        }
8469    }
8470
8471    #[test]
8472    fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
8473        for entry in [
8474            "a*b", "*x", "**", "Foo", "-a", "a/b/c", " ", "*/x", "a/*x", "a/x*y", "A/x",
8475        ] {
8476            for key in ["skill", "tool"] {
8477                let block = format!("    {key}: [{entry:?}]\n");
8478                let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8479                    .expect_err("a bad entry is refused");
8480                match &err {
8481                    RuntimeManifestError::InvalidArtifactName {
8482                        field,
8483                        message,
8484                        hint,
8485                    } => {
8486                        assert_eq!(field, &format!("capabilities.install.{key}[0]"));
8487                        assert!(message.contains(&format!("{entry:?}")), "{message}");
8488                        assert!(
8489                            message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
8490                            "{message}"
8491                        );
8492                        assert_eq!(hint, &None, "{entry:?}");
8493                    }
8494                    other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8495                }
8496            }
8497        }
8498    }
8499
8500    /// An entry with no scope is refused at its index with the hint naming its scoped forms.
8501    #[test]
8502    fn capabilities_install_refuses_an_unscoped_entry_with_the_scoped_hint() {
8503        for (entry, scoped) in [
8504            ("tool-*", "local/tool-*"),
8505            ("mur*", "local/mur*"),
8506            ("tool-x", "local/tool-x"),
8507            ("murmur-tool-editor", "murmur/tool-editor"),
8508        ] {
8509            let block = format!("    tool: [\"local/ok\", {entry:?}]\n");
8510            let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8511                .expect_err("an unscoped entry is refused");
8512            match &err {
8513                RuntimeManifestError::InvalidArtifactName {
8514                    field,
8515                    message,
8516                    hint,
8517                } => {
8518                    assert_eq!(field, "capabilities.install.tool[1]");
8519                    assert_eq!(
8520                        message,
8521                        &format!(
8522                            "entry {entry:?} is invalid ('{entry}' names no scope); expected {}",
8523                            crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
8524                        )
8525                    );
8526                    let hint = hint.as_deref().expect("an unscoped entry carries a hint");
8527                    assert!(hint.contains(scoped), "{entry:?}: {hint}");
8528                }
8529                other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8530            }
8531            assert!(
8532                err.to_string().contains("'capabilities.install.tool[1]'"),
8533                "{err}"
8534            );
8535        }
8536    }
8537
8538    /// The grant is held by the session, so a per-artifact block is refused on every role.
8539    #[test]
8540    fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
8541        for runtime in ["tool", "hook", "driver", "skill"] {
8542            let yaml = format!(
8543                "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/a\n    version: 0.1.0\n    \
8544                 runtime: {runtime}\n    capabilities:\n      install:\n        skill: [x]\n"
8545            );
8546            let err = RuntimeManifest::from_yaml_str(&yaml)
8547                .expect_err("a per-artifact install block is refused");
8548            match &err {
8549                RuntimeManifestError::InvalidArtifact { index, message } => {
8550                    assert_eq!(*index, 0);
8551                    assert!(message.contains("capabilities.install"), "{message}");
8552                    assert!(message.contains("capsule-wide"), "{message}");
8553                }
8554                other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
8555            }
8556        }
8557    }
8558
8559    /// An unknown key inside the block is reported by path and grants nothing.
8560    #[test]
8561    fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
8562        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8563            "    skill: [local/a]\n    driver: [x]\n",
8564        ))
8565        .expect("an unknown key beside a real entry is a warning, not a parse error");
8566        assert!(
8567            manifest
8568                .unknown_keys
8569                .iter()
8570                .any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
8571            "{:?}",
8572            manifest.unknown_keys
8573        );
8574        assert_eq!(
8575            manifest.capabilities.unwrap().install,
8576            Some(InstallCapabilities {
8577                skill: vec!["local/a".into()],
8578                tool: vec![],
8579                from: InstallFrom::Stores,
8580            })
8581        );
8582    }
8583
8584    // ── capabilities.task_io (per-hook, honored only on runtime: hook) ────────
8585
8586    /// A capsule manifest with one artifact entry carrying `capabilities: task_io: <block>`.
8587    fn manifest_with_task_io(runtime: &str, block: &str) -> String {
8588        format!(
8589            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/gate\n    version: 1.2.3\n    \
8590             runtime: {runtime}\n    capabilities:\n      task_io:\n{block}"
8591        )
8592    }
8593
8594    #[test]
8595    fn task_io_read_true_grants_the_hook() {
8596        let manifest =
8597            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: true\n"))
8598                .expect("task_io on a hook entry parses");
8599        assert_eq!(
8600            manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
8601            Some(TaskIoCapabilities { read: true })
8602        );
8603    }
8604
8605    /// `read: false` and an absent `task_io:` block both leave the hook ungranted; the
8606    /// difference between them is only whether the operator wrote the denial down.
8607    #[test]
8608    fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
8609        let explicit =
8610            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: false\n"))
8611                .expect("an explicit denial parses");
8612        assert_eq!(
8613            explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
8614            Some(TaskIoCapabilities { read: false })
8615        );
8616
8617        let absent = RuntimeManifest::from_yaml_str(
8618            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/gate\n    version: 1.2.3\n    \
8619             runtime: hook\n",
8620        )
8621        .expect("no capabilities block parses");
8622        assert!(absent.artifacts[0].capabilities.is_none());
8623    }
8624
8625    /// The capability is never inferred: a `task_io:` block that omits `read:` is a parse
8626    /// error naming the key, following `interpreter_runtime[].dirs[].list_dir`.
8627    #[test]
8628    fn task_io_without_read_is_rejected() {
8629        let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        {}\n"))
8630            .unwrap_err();
8631        let msg = err.to_string();
8632        assert!(
8633            msg.contains("capabilities.task_io.read"),
8634            "error was: {msg}"
8635        );
8636        assert!(msg.contains("explicitly"), "error was: {msg}");
8637    }
8638
8639    /// Nothing outside a hook can be handed the import, so declaring the key there is a parse
8640    /// error rather than a silently inert grant.
8641    #[test]
8642    fn task_io_outside_a_hook_entry_is_rejected() {
8643        for runtime in ["tool", "driver", "skill"] {
8644            let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
8645                runtime,
8646                "        read: true\n",
8647            ))
8648            .unwrap_err();
8649            let msg = err.to_string();
8650            assert!(
8651                msg.contains("capabilities") && msg.contains("runtime: hook"),
8652                "{runtime} entry: error was: {msg}"
8653            );
8654        }
8655    }
8656
8657    // ── capabilities.filesystem.records (per-hook, honored only on runtime: hook) ──
8658
8659    /// A capsule manifest with one artifact entry carrying `capabilities: filesystem: <block>`.
8660    fn manifest_with_filesystem(runtime: &str, block: &str) -> String {
8661        format!(
8662            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/thing\n    version: 0.1.0\n    \
8663             runtime: {runtime}\n    capabilities:\n      filesystem:\n{block}"
8664        )
8665    }
8666
8667    /// `records: true` on a hook entry grants it; absent, it is `false`.
8668    #[test]
8669    fn filesystem_records_on_a_hook_entry_parses() {
8670        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8671            "hook",
8672            "        records: true\n",
8673        ))
8674        .expect("records on a hook entry parses");
8675        let filesystem = manifest.artifacts[0]
8676            .capabilities
8677            .as_ref()
8678            .unwrap()
8679            .filesystem
8680            .as_ref()
8681            .unwrap();
8682        assert!(filesystem.records);
8683        assert_eq!(filesystem.scope, None);
8684
8685        let scoped =
8686            RuntimeManifest::from_yaml_str(&manifest_with_filesystem("hook", "        scope: .\n"))
8687                .unwrap();
8688        let filesystem = scoped.artifacts[0]
8689            .capabilities
8690            .as_ref()
8691            .unwrap()
8692            .filesystem
8693            .as_ref()
8694            .unwrap();
8695        assert!(!filesystem.records);
8696        assert!(scoped.unknown_keys.is_empty(), "{:?}", scoped.unknown_keys);
8697    }
8698
8699    /// Beside a `scope`, on any other role and in the capsule-wide block, `records: true` is
8700    /// refused, naming the field.
8701    #[test]
8702    fn filesystem_records_is_refused_beside_a_scope_off_a_hook_and_capsule_wide() {
8703        let beside = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8704            "hook",
8705            "        records: true\n        scope: .\n",
8706        ))
8707        .unwrap_err()
8708        .to_string();
8709        assert!(
8710            beside.contains("capabilities.filesystem.records"),
8711            "{beside}"
8712        );
8713        assert!(beside.contains("capabilities.filesystem.scope"), "{beside}");
8714        for runtime in ["tool", "driver"] {
8715            let off_hook = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8716                runtime,
8717                "        records: true\n",
8718            ))
8719            .unwrap_err()
8720            .to_string();
8721            assert!(
8722                off_hook.contains("capabilities.filesystem.records"),
8723                "{off_hook}"
8724            );
8725            assert!(off_hook.contains("runtime: hook"), "{off_hook}");
8726        }
8727        let capsule_wide = RuntimeManifest::from_yaml_str(
8728            "name: cap\nversion: 0.0.1\ncapabilities:\n  filesystem:\n    records: true\n",
8729        )
8730        .unwrap_err()
8731        .to_string();
8732        assert!(
8733            capsule_wide.contains("capabilities.filesystem.records"),
8734            "{capsule_wide}"
8735        );
8736    }
8737
8738    /// The capsule-wide block reaches capsule, tool and driver components, none of which can
8739    /// receive the import — so it is rejected there too, naming the key.
8740    #[test]
8741    fn task_io_in_the_capsule_wide_block_is_rejected() {
8742        let err = RuntimeManifest::from_yaml_str(
8743            "name: cap\nversion: 0.0.1\ncapabilities:\n  task_io:\n    read: true\n",
8744        )
8745        .unwrap_err();
8746        let msg = err.to_string();
8747        assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
8748        assert!(msg.contains("runtime: hook"), "error was: {msg}");
8749    }
8750
8751    // ── capabilities.conversation (per-hook, honored only on runtime: hook) ───
8752
8753    /// A capsule manifest with one artifact entry carrying `capabilities: conversation: <block>`.
8754    fn manifest_with_conversation(runtime: &str, block: &str) -> String {
8755        format!(
8756            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/memory\n    version: 1.2.3\n    \
8757             runtime: {runtime}\n    capabilities:\n      conversation:\n{block}"
8758        )
8759    }
8760
8761    #[test]
8762    fn conversation_read_lowers_onto_the_hook_entry() {
8763        for (declared, expected) in [("true", true), ("false", false)] {
8764            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8765                "hook",
8766                &format!("        read: {declared}\n"),
8767            ))
8768            .expect("conversation on a hook entry parses");
8769            assert_eq!(
8770                manifest.artifacts[0]
8771                    .capabilities
8772                    .as_ref()
8773                    .unwrap()
8774                    .conversation,
8775                Some(ConversationCapabilities { read: expected })
8776            );
8777        }
8778    }
8779
8780    /// The capability is never inferred: a block that omits `read:` is a parse error naming the
8781    /// key, exactly as `capabilities.task_io.read` is.
8782    #[test]
8783    fn conversation_without_read_is_rejected() {
8784        let err =
8785            RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", "        {}\n"))
8786                .unwrap_err();
8787        let msg = err.to_string();
8788        assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
8789        assert!(msg.contains("explicitly"), "was: {msg}");
8790    }
8791
8792    /// Only a hook's world can import the interface, so declaring the grant on any other role is
8793    /// a parse error rather than a silently inert block.
8794    #[test]
8795    fn conversation_outside_a_hook_entry_is_rejected() {
8796        for runtime in ["tool", "driver", "skill"] {
8797            let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8798                runtime,
8799                "        read: true\n",
8800            ))
8801            .unwrap_err();
8802            let msg = err.to_string();
8803            assert!(
8804                msg.contains("capabilities") && msg.contains("runtime: hook"),
8805                "{runtime} entry: error was: {msg}"
8806            );
8807        }
8808    }
8809
8810    /// Unlike `task_io`, the capsule-wide block parses: it is inert, and the runtime says so once
8811    /// as `W-SEC-016` rather than refusing a manifest an operator can fix at leisure.
8812    #[test]
8813    fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
8814        let manifest = RuntimeManifest::from_yaml_str(
8815            "name: cap\nversion: 0.0.1\ncapabilities:\n  conversation:\n    read: true\n",
8816        )
8817        .expect("a capsule-wide block is accepted");
8818        assert_eq!(
8819            manifest.capabilities.unwrap().conversation,
8820            Some(ConversationCapabilities { read: true })
8821        );
8822    }
8823
8824    // ── context.record / context.record_store ────────────────────────────────
8825
8826    /// The record is on unless the manifest says otherwise, and `record_store` defaults to
8827    /// nothing — the runtime substitutes the capsule name.
8828    #[test]
8829    fn a_context_block_records_by_default() {
8830        let context = context_of("context:\n  max_tokens: 1000\n").unwrap();
8831        assert!(context.record);
8832        assert_eq!(context.record_store, None);
8833    }
8834
8835    #[test]
8836    fn record_off_and_on_are_the_two_accepted_values() {
8837        assert!(!context_of("context:\n  record: off\n").unwrap().record);
8838        assert!(context_of("context:\n  record: on\n").unwrap().record);
8839
8840        let err = RuntimeManifest::from_yaml_str(
8841            "name: cap\nversion: 0.0.1\ncontext:\n  record: sometimes\n",
8842        )
8843        .unwrap_err();
8844        let msg = err.to_string();
8845        assert!(msg.contains("context.record"), "was: {msg}");
8846        assert!(msg.contains("sometimes"), "was: {msg}");
8847    }
8848
8849    /// `record_store` is a name the runtime turns into a directory, so it is kept verbatim apart
8850    /// from surrounding whitespace; its *shape* is refused at launch, where the path is built.
8851    #[test]
8852    fn record_store_is_kept_verbatim_and_trimmed() {
8853        assert_eq!(
8854            context_of("context:\n  record_store: '  shey  '\n")
8855                .unwrap()
8856                .record_store,
8857            Some("shey".to_string())
8858        );
8859        assert_eq!(
8860            context_of("context:\n  record: off\n  record_store: shey\n")
8861                .unwrap()
8862                .record_store,
8863            Some("shey".to_string()),
8864            "a store declared beside record: off is kept and simply never used"
8865        );
8866    }
8867
8868    #[test]
8869    fn interpreter_runtime_rejects_missing_list_dir() {
8870        let msg = interpreter_runtime_reject(
8871            "      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.11\n",
8872        );
8873        assert!(
8874            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
8875            "{msg}"
8876        );
8877        assert!(msg.contains("explicitly"), "{msg}");
8878    }
8879
8880    #[test]
8881    fn interpreter_runtime_rejects_empty_dirs() {
8882        let msg = interpreter_runtime_reject("      - binary: python3\n        dirs: []\n");
8883        assert!(
8884            msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
8885            "{msg}"
8886        );
8887        assert!(msg.contains("at least one"), "{msg}");
8888    }
8889
8890    #[test]
8891    fn staged_runtime_parses_accepted_shape() {
8892        let manifest = RuntimeManifest::from_yaml_str(
8893            r#"
8894name: cap
8895version: 0.0.1
8896artifacts: []
8897capabilities:
8898  containment: sealed
8899  shell:
8900    allow:
8901      - python3
8902      - bash
8903    staged_runtime:
8904      - binary: python3
8905        source_path: /opt/testbed/conda/envs/django__django
8906        pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
8907"#,
8908        )
8909        .unwrap();
8910
8911        let shell = manifest.capabilities.unwrap().shell.unwrap();
8912        assert_eq!(
8913            shell.staged_runtime,
8914            vec![StagedRuntimeGrant {
8915                binary: "python3".to_string(),
8916                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
8917                pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
8918            }]
8919        );
8920        // The two mechanisms are alternatives, so an accepted `staged_runtime` grant leaves the
8921        // interpreter_runtime list untouched rather than populating it as a side effect.
8922        assert!(shell.interpreter_runtime.is_empty());
8923    }
8924
8925    #[test]
8926    fn staged_runtime_defaults_to_empty_when_absent() {
8927        let manifest = RuntimeManifest::from_yaml_str(
8928            r#"
8929name: cap
8930version: 0.0.1
8931artifacts: []
8932capabilities:
8933  shell:
8934    allow:
8935      - python3
8936"#,
8937        )
8938        .unwrap();
8939
8940        let shell = manifest.capabilities.unwrap().shell.unwrap();
8941        assert!(shell.staged_runtime.is_empty());
8942    }
8943
8944    /// Shared helper: parse a manifest whose sole `staged_runtime` grant is `grant_yaml` (indented
8945    /// to sit under `staged_runtime:`), returning the error message. `extra` is spliced into the
8946    /// same `shell` block, which is how the mutual-exclusion case adds an `interpreter_runtime`.
8947    fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
8948        let yaml = format!(
8949            r#"
8950name: cap
8951version: 0.0.1
8952artifacts: []
8953capabilities:
8954  shell:
8955    allow:
8956      - python3
8957{extra}    staged_runtime:
8958{grant_yaml}
8959"#,
8960        );
8961        RuntimeManifest::from_yaml_str(&yaml)
8962            .expect_err("grant should be rejected")
8963            .to_string()
8964    }
8965
8966    #[test]
8967    fn staged_runtime_rejects_binary_not_in_allow() {
8968        let msg = staged_runtime_reject(
8969            "      - binary: ruby\n        source_path: /opt/ruby\n        pin: ruby-3.2.2\n",
8970            "",
8971        );
8972        assert!(
8973            msg.contains("capabilities.shell.staged_runtime[0].binary"),
8974            "{msg}"
8975        );
8976        assert!(msg.contains("ruby"), "{msg}");
8977        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
8978    }
8979
8980    #[test]
8981    fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
8982        let msg = staged_runtime_reject(
8983            "      - binary: python3\n        source_path: /opt/py\n        pin: cpython-3.9.19\n",
8984            "    interpreter_runtime:\n      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.9\n            list_dir: true\n",
8985        );
8986        assert!(
8987            msg.contains("capabilities.shell.staged_runtime[0].binary"),
8988            "{msg}"
8989        );
8990        assert!(msg.contains("python3"), "{msg}");
8991        assert!(msg.contains("interpreter_runtime"), "{msg}");
8992        assert!(msg.contains("mutually exclusive"), "{msg}");
8993    }
8994
8995    #[test]
8996    fn staged_runtime_rejects_relative_source_path() {
8997        let msg = staged_runtime_reject(
8998            "      - binary: python3\n        source_path: opt/testbed/conda\n        pin: cpython-3.9.19\n",
8999            "",
9000        );
9001        assert!(
9002            msg.contains("capabilities.shell.staged_runtime[0].source_path"),
9003            "{msg}"
9004        );
9005        assert!(msg.contains("absolute"), "{msg}");
9006    }
9007
9008    #[test]
9009    fn staged_runtime_rejects_missing_or_empty_pin() {
9010        // Omitted entirely, and present-but-blank: both are "unpinned", and the field exists
9011        // precisely so that state is unrepresentable.
9012        for grant in [
9013            "      - binary: python3\n        source_path: /opt/testbed/conda\n",
9014            "      - binary: python3\n        source_path: /opt/testbed/conda\n        pin: \"   \"\n",
9015        ] {
9016            let msg = staged_runtime_reject(grant, "");
9017            assert!(
9018                msg.contains("capabilities.shell.staged_runtime[0].pin"),
9019                "{msg}"
9020            );
9021            assert!(msg.contains("never inferred"), "{msg}");
9022        }
9023    }
9024
9025    #[test]
9026    fn parses_inference_block() {
9027        let manifest = RuntimeManifest::from_yaml_str(
9028            r#"
9029name: cap
9030version: 0.0.1
9031artifacts:
9032  - name: murmur/driver-anthropic
9033    version: 0.1.0
9034    runtime: driver
9035    gateway:
9036      endpoint: http://127.0.0.1:8080
9037      api_key: literal-key
9038inference:
9039  transport: http
9040  model: test-model
9041  driver:
9042    artifact: murmur/driver-anthropic
9043    config:
9044      temperature: 0.2
9045      headers:
9046        x-test: true
9047"#,
9048        )
9049        .unwrap();
9050
9051        assert_eq!(
9052            manifest.artifacts[0].gateway,
9053            Some(ArtifactGateway {
9054                endpoint: "http://127.0.0.1:8080".to_string(),
9055                api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
9056                keyless: false,
9057            })
9058        );
9059        let inference = manifest.inference.expect("inference should exist");
9060        assert_eq!(inference.transport, "http");
9061        assert_eq!(inference.model, "test-model");
9062        let driver = inference.driver.as_ref().expect("driver should be present");
9063        assert_eq!(driver.artifact, "murmur/driver-anthropic");
9064        assert_eq!(
9065            driver.config,
9066            Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
9067        );
9068        assert!(inference.system_prompt.is_none());
9069        assert!(inference.system_prompt_file.is_none());
9070    }
9071
9072    /// A capsule whose one tool entry declares `gateway:` with `block` as its body, indented
9073    /// under the key.
9074    fn tool_gateway_manifest(block: &str) -> String {
9075        format!(
9076            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/web-search\n    version: 0.1.0\n    runtime: tool\n    gateway:{block}\n"
9077        )
9078    }
9079
9080    fn gateway_endpoint_error(endpoint: &str) -> String {
9081        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
9082            "\n      endpoint: \"{endpoint}\""
9083        )))
9084        .unwrap_err()
9085        .to_string()
9086    }
9087
9088    #[test]
9089    fn gateway_endpoint_https_is_accepted() {
9090        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9091            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
9092        ))
9093        .unwrap();
9094        assert_eq!(
9095            manifest.artifacts[0].gateway,
9096            Some(ArtifactGateway {
9097                endpoint: "https://api.tavily.com".to_string(),
9098                api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
9099                keyless: false,
9100            })
9101        );
9102    }
9103
9104    #[test]
9105    fn gateway_endpoint_http_localhost_is_accepted() {
9106        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9107            "\n      endpoint: http://localhost:11434\n      keyless: true",
9108        ))
9109        .unwrap();
9110        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
9111        assert_eq!(gateway.endpoint, "http://localhost:11434");
9112        assert_eq!(gateway.api_key, None);
9113        assert!(gateway.keyless);
9114    }
9115
9116    #[test]
9117    fn gateway_endpoint_http_loopback_ip_is_accepted() {
9118        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9119            "\n      endpoint: http://127.0.0.1:8080\n      api_key: ${LOCAL_KEY}",
9120        ))
9121        .unwrap();
9122        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
9123        assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
9124        assert_eq!(
9125            gateway.api_key,
9126            Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
9127        );
9128    }
9129
9130    #[test]
9131    fn gateway_endpoint_without_scheme_is_refused() {
9132        let msg = gateway_endpoint_error("api.tavily.com");
9133        assert!(msg.contains("gateway.endpoint"), "{msg}");
9134        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9135        assert!(msg.contains("does not parse as a URL"), "{msg}");
9136    }
9137
9138    #[test]
9139    fn gateway_endpoint_ftp_scheme_is_refused() {
9140        let msg = gateway_endpoint_error("ftp://example.com");
9141        assert!(msg.contains("gateway.endpoint"), "{msg}");
9142        assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
9143    }
9144
9145    #[test]
9146    fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
9147        let msg = gateway_endpoint_error("http://api.attacker.example.com");
9148        assert!(msg.contains("gateway.endpoint"), "{msg}");
9149        assert!(msg.contains("api.attacker.example.com"), "{msg}");
9150        assert!(msg.contains("non-loopback"), "{msg}");
9151    }
9152
9153    #[test]
9154    fn gateway_endpoint_with_a_query_is_refused() {
9155        let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
9156        assert!(msg.contains("gateway.endpoint"), "{msg}");
9157        assert!(msg.contains("carries a query"), "{msg}");
9158    }
9159
9160    #[test]
9161    fn gateway_endpoint_with_a_fragment_is_refused() {
9162        let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
9163        assert!(msg.contains("gateway.endpoint"), "{msg}");
9164        assert!(msg.contains("carries a fragment"), "{msg}");
9165    }
9166
9167    #[test]
9168    fn gateway_endpoint_with_userinfo_is_refused() {
9169        let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
9170        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9171        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
9172        assert!(msg.contains("host 'api.example.com'"), "{msg}");
9173        assert!(msg.contains("gateway.api_key"), "{msg}");
9174        assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
9175        assert!(
9176            !msg.contains("sk-in-url"),
9177            "a password in the URL is never echoed: {msg}"
9178        );
9179    }
9180
9181    #[test]
9182    fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
9183        let msg = gateway_endpoint_error("https://user:sk-in-url@");
9184        assert!(msg.contains("does not parse as a URL"), "{msg}");
9185        assert!(
9186            !msg.contains("sk-in-url"),
9187            "a password in the URL is never echoed: {msg}"
9188        );
9189    }
9190
9191    #[test]
9192    fn gateway_endpoint_with_a_masquerading_host_is_refused() {
9193        let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
9194        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
9195        assert!(
9196            msg.contains("addresses host '127.0.0.1'"),
9197            "the message names the host the URL really addresses: {msg}"
9198        );
9199    }
9200
9201    #[test]
9202    fn gateway_endpoint_with_interpolation_is_refused() {
9203        let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
9204        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9205        assert!(msg.contains("contains '${'"), "{msg}");
9206        assert!(msg.contains("not interpolated"), "{msg}");
9207    }
9208
9209    #[test]
9210    fn gateway_endpoint_http_unspecified_address_is_refused() {
9211        let msg = gateway_endpoint_error("http://0.0.0.0:8080");
9212        assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
9213    }
9214
9215    #[test]
9216    fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
9217        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9218            "\n      endpoint: http://[::1]:8080\n      api_key: ${LOCAL_KEY}",
9219        ))
9220        .unwrap();
9221        assert_eq!(
9222            manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
9223            "http://[::1]:8080"
9224        );
9225    }
9226
9227    /// The error a tool entry whose `gateway:` body is `block` draws, parsed with secrets resolved.
9228    fn gateway_credential_error(block: &str) -> RuntimeManifestError {
9229        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
9230    }
9231
9232    fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
9233        match &error {
9234            RuntimeManifestError::GatewayWithoutCredential {
9235                index,
9236                name,
9237                endpoint: refused,
9238            } => {
9239                assert_eq!(*index, 0);
9240                assert_eq!(name, "local/web-search");
9241                assert_eq!(refused, endpoint);
9242            }
9243            other => panic!("expected GatewayWithoutCredential, got {other:?}"),
9244        }
9245        let msg = error.to_string();
9246        assert!(
9247            msg.contains(&format!(
9248                "artifact 'local/web-search' declares gateway.endpoint '{endpoint}' but binds no \
9249                 credential"
9250            )),
9251            "{msg}"
9252        );
9253    }
9254
9255    #[test]
9256    fn gateway_credential_absent_is_refused() {
9257        assert_without_credential(
9258            gateway_credential_error("\n      endpoint: https://api.tavily.com"),
9259            "https://api.tavily.com",
9260        );
9261    }
9262
9263    #[test]
9264    fn gateway_credential_blank_is_refused() {
9265        for key in ["\"\"", "\"   \"", "''"] {
9266            assert_without_credential(
9267                gateway_credential_error(&format!(
9268                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
9269                )),
9270                "https://api.tavily.com",
9271            );
9272        }
9273    }
9274
9275    #[test]
9276    fn gateway_credential_null_is_refused() {
9277        for key in ["~", "null", ""] {
9278            assert_without_credential(
9279                gateway_credential_error(&format!(
9280                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
9281                )),
9282                "https://api.tavily.com",
9283            );
9284        }
9285    }
9286
9287    #[test]
9288    fn gateway_credential_keyless_false_is_refused() {
9289        assert_without_credential(
9290            gateway_credential_error(
9291                "\n      endpoint: https://api.tavily.com\n      keyless: false",
9292            ),
9293            "https://api.tavily.com",
9294        );
9295    }
9296
9297    #[test]
9298    fn gateway_credential_keyless_is_accepted() {
9299        for (parse, mode) in [
9300            (
9301                RuntimeManifest::from_yaml_str as fn(&str) -> _,
9302                "with secrets",
9303            ),
9304            (
9305                RuntimeManifest::from_yaml_str_without_secrets,
9306                "without secrets",
9307            ),
9308        ] {
9309            let manifest = parse(&tool_gateway_manifest(
9310                "\n      endpoint: https://api.example.com\n      keyless: true",
9311            ))
9312            .unwrap();
9313            assert_eq!(
9314                manifest.artifacts[0].gateway,
9315                Some(ArtifactGateway {
9316                    endpoint: "https://api.example.com".to_string(),
9317                    api_key: None,
9318                    keyless: true,
9319                }),
9320                "{mode}"
9321            );
9322        }
9323    }
9324
9325    #[test]
9326    fn gateway_credential_keyless_not_a_boolean_is_refused() {
9327        for value in ["\"true\"", "yes please", "1"] {
9328            let error = gateway_credential_error(&format!(
9329                "\n      endpoint: https://api.tavily.com\n      keyless: {value}"
9330            ));
9331            assert!(
9332                matches!(
9333                    error,
9334                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
9335                ),
9336                "{value}: {error:?}"
9337            );
9338            assert!(
9339                error
9340                    .to_string()
9341                    .contains("gateway.keyless with a value that is not a boolean"),
9342                "{error}"
9343            );
9344        }
9345    }
9346
9347    #[test]
9348    fn gateway_credential_and_keyless_together_are_refused() {
9349        for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
9350            let error = gateway_credential_error(&format!(
9351                "\n      endpoint: https://api.tavily.com\n      api_key: {key}\n      keyless: true"
9352            ));
9353            assert!(
9354                matches!(
9355                    error,
9356                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
9357                ),
9358                "{error:?}"
9359            );
9360            assert!(
9361                error.to_string().contains(
9362                    "artifact 'local/web-search' declares both gateway.api_key and gateway.keyless: \
9363                     true; a gateway either presents a key or is keyless"
9364                ),
9365                "{error}"
9366            );
9367        }
9368    }
9369
9370    #[test]
9371    fn gateway_credential_absent_is_refused_without_secrets() {
9372        for block in [
9373            "\n      endpoint: https://api.tavily.com",
9374            "\n      endpoint: https://api.tavily.com\n      api_key: \"\"",
9375            "\n      endpoint: https://api.tavily.com\n      keyless: false",
9376        ] {
9377            assert_without_credential(
9378                RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
9379                    .unwrap_err(),
9380                "https://api.tavily.com",
9381            );
9382        }
9383        let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
9384            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
9385        ))
9386        .unwrap();
9387        let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
9388        assert_eq!(gateway.api_key, None, "the key reference is left unread");
9389        assert!(!gateway.keyless, "a keyed gateway is never keyless");
9390    }
9391
9392    /// The refusal reads what the manifest wrote, never the address: a loopback or private
9393    /// endpoint with no credential is refused exactly as a public one is.
9394    #[test]
9395    fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
9396        for endpoint in [
9397            "http://127.0.0.1:11434",
9398            "http://localhost:11434",
9399            "http://[::1]:11434",
9400            "https://10.0.0.5",
9401            "https://192.168.1.10:8443",
9402        ] {
9403            assert_without_credential(
9404                gateway_credential_error(&format!("\n      endpoint: {endpoint}")),
9405                endpoint,
9406            );
9407        }
9408    }
9409
9410    #[test]
9411    fn gateway_endpoint_missing_from_the_block_is_refused() {
9412        for block in [
9413            "\n      api_key: ${TAVILY_API_KEY}",
9414            "",
9415            " {}",
9416            "\n      endpoint: \"  \"",
9417        ] {
9418            let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
9419                .unwrap_err()
9420                .to_string();
9421            assert!(
9422                msg.contains("without 'gateway.endpoint'"),
9423                "{block:?}: {msg}"
9424            );
9425            assert!(
9426                msg.contains("artifact 'local/web-search'"),
9427                "{block:?}: {msg}"
9428            );
9429        }
9430    }
9431
9432    #[test]
9433    fn gateway_block_unknown_key_is_reported() {
9434        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9435            "\n      endpoint: https://api.tavily.com\n      keyless: true\n      api_kee: x",
9436        ))
9437        .unwrap();
9438        let unknown = manifest.unknown_keys;
9439        assert_eq!(unknown.len(), 1, "{unknown:?}");
9440        assert_eq!(unknown[0].key, "api_kee");
9441        assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
9442    }
9443
9444    #[test]
9445    fn gateway_on_skill_entry_is_refused() {
9446        let msg = RuntimeManifest::from_yaml_str(
9447            "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",
9448        )
9449        .unwrap_err()
9450        .to_string();
9451        assert!(
9452            msg.contains("artifact 'local/notes' declares 'gateway:'"),
9453            "{msg}"
9454        );
9455        assert!(msg.contains("runtime: skill"), "{msg}");
9456    }
9457
9458    #[test]
9459    fn gateway_on_hook_entry_is_accepted() {
9460        let manifest = RuntimeManifest::from_yaml_str(
9461            "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",
9462        )
9463        .unwrap();
9464        assert!(manifest.artifacts[0].gateway.is_some());
9465    }
9466
9467    /// An `http` driver capsule. `driver_entry` is the YAML of the driver's artifact entry, or
9468    /// empty for none.
9469    fn http_driver_manifest(artifacts: &str) -> String {
9470        format!(
9471            "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n  transport: http\n  model: test-model\n  driver:\n    artifact: murmur/driver-anthropic\n"
9472        )
9473    }
9474
9475    #[test]
9476    fn http_driver_entry_without_gateway_endpoint_is_refused() {
9477        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9478            "\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver",
9479        ))
9480        .unwrap_err()
9481        .to_string();
9482        assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9483        assert!(msg.contains("gateway.endpoint"), "{msg}");
9484    }
9485
9486    #[test]
9487    fn http_driver_absent_from_artifacts_is_refused() {
9488        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
9489            .unwrap_err()
9490            .to_string();
9491        assert!(msg.contains("inference.driver.artifact"), "{msg}");
9492        assert!(msg.contains("not declared in artifacts:"), "{msg}");
9493    }
9494
9495    #[test]
9496    fn gateway_on_unconfigured_driver_entry_is_refused() {
9497        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9498            "\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",
9499        ))
9500        .unwrap_err()
9501        .to_string();
9502        assert!(msg.contains("'murmur/driver-openai'"), "{msg}");
9503        assert!(
9504            msg.contains("accepted only on the entry inference.driver.artifact names"),
9505            "{msg}"
9506        );
9507
9508        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";
9509        let msg = RuntimeManifest::from_yaml_str(no_inference)
9510            .unwrap_err()
9511            .to_string();
9512        assert!(msg.contains("not the configured inference driver"), "{msg}");
9513    }
9514
9515    #[test]
9516    fn gateway_on_driver_entry_under_process_transport_is_refused() {
9517        let msg = RuntimeManifest::from_yaml_str(
9518            "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",
9519        )
9520        .unwrap_err()
9521        .to_string();
9522        assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9523        assert!(msg.contains("transport: process"), "{msg}");
9524    }
9525
9526    #[test]
9527    fn inference_api_key_is_refused_naming_gateway_api_key() {
9528        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";
9529        for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
9530            let manifest = http_driver_manifest(entry)
9531                .replace("  model:", &format!("  api_key: {value}\n  model:"));
9532            let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
9533            let msg = err.to_string();
9534            assert!(msg.contains("'inference.api_key'"), "{msg}");
9535            assert!(msg.contains("was removed"), "{msg}");
9536            assert!(
9537                msg.contains(
9538                    "set gateway.api_key on the artifacts: entry 'murmur/driver-anthropic'"
9539                ),
9540                "{msg}"
9541            );
9542            assert!(!msg.contains("sk-literal"), "{msg}");
9543        }
9544
9545        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  api_key: ${KEY}\n";
9546        let msg = RuntimeManifest::from_yaml_str(process)
9547            .unwrap_err()
9548            .to_string();
9549        assert!(msg.contains("'inference.api_key'"), "{msg}");
9550        assert!(
9551            msg.contains("on the artifacts: entry inference.driver.artifact names"),
9552            "{msg}"
9553        );
9554        assert!(
9555            RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
9556            "the refusal does not depend on resolving secrets"
9557        );
9558    }
9559
9560    #[test]
9561    fn inference_endpoint_is_refused_naming_gateway_endpoint() {
9562        let manifest = http_driver_manifest(
9563            "\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",
9564        )
9565        .replace("  model:", "  endpoint: https://api.anthropic.com\n  model:");
9566        let msg = RuntimeManifest::from_yaml_str(&manifest)
9567            .unwrap_err()
9568            .to_string();
9569        assert!(msg.contains("'inference.endpoint'"), "{msg}");
9570        assert!(
9571            msg.contains("set gateway.endpoint on the artifacts: entry 'murmur/driver-anthropic'"),
9572            "{msg}"
9573        );
9574
9575        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  endpoint: http://localhost:8080\n";
9576        let msg = RuntimeManifest::from_yaml_str(process)
9577            .unwrap_err()
9578            .to_string();
9579        assert!(msg.contains("'inference.endpoint'"), "{msg}");
9580        assert!(msg.contains("was removed"), "{msg}");
9581    }
9582
9583    #[test]
9584    fn without_secrets_leaves_gateway_api_key_unread() {
9585        let manifest = tool_gateway_manifest(
9586            "\n      endpoint: https://api.tavily.com\n      api_key: tvly-literal",
9587        );
9588        let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
9589        assert_eq!(
9590            resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
9591            Some(ApiKeyReference::Literal("tvly-literal".to_string()))
9592        );
9593        let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
9594        let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
9595        assert_eq!(gateway.endpoint, "https://api.tavily.com");
9596        assert_eq!(gateway.api_key, None);
9597    }
9598
9599    #[test]
9600    fn referenced_env_variables_reads_gateway_api_key() {
9601        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";
9602        let referenced = referenced_env_variables(manifest);
9603        assert_eq!(
9604            referenced,
9605            vec![
9606                ReferencedEnvVariable {
9607                    field: "artifacts.murmur/driver-anthropic.gateway.api_key".to_string(),
9608                    variable: "ANTHROPIC_API_KEY".to_string(),
9609                },
9610                ReferencedEnvVariable {
9611                    field: "artifacts.local/web-search.gateway.api_key".to_string(),
9612                    variable: "TAVILY_API_KEY".to_string(),
9613                },
9614            ]
9615        );
9616        assert!(referenced
9617            .iter()
9618            .all(ReferencedEnvVariable::is_gateway_credential));
9619    }
9620
9621    #[test]
9622    fn parses_inline_system_prompt() {
9623        let manifest = RuntimeManifest::from_yaml_str(
9624            r#"
9625name: cap
9626version: 0.0.1
9627artifacts:
9628  - name: murmur/driver-anthropic
9629    version: 0.1.0
9630    runtime: driver
9631    gateway:
9632      endpoint: http://127.0.0.1:8080
9633      keyless: true
9634inference:
9635  model: test-model
9636  system_prompt: "Always begin with CONFIRMED:"
9637  driver:
9638    artifact: murmur/driver-anthropic
9639"#,
9640        )
9641        .unwrap();
9642
9643        let inference = manifest.inference.unwrap();
9644        assert_eq!(
9645            inference.system_prompt,
9646            Some("Always begin with CONFIRMED:".to_string())
9647        );
9648        assert!(inference.system_prompt_file.is_none());
9649    }
9650
9651    #[test]
9652    fn parses_compaction_system_prompt() {
9653        let manifest = RuntimeManifest::from_yaml_str(
9654            r#"
9655name: cap
9656version: 0.0.1
9657artifacts:
9658  - name: murmur/driver-anthropic
9659    version: 0.1.0
9660    runtime: driver
9661    gateway:
9662      endpoint: http://127.0.0.1:8080
9663      keyless: true
9664inference:
9665  model: test-model
9666  compaction:
9667    model: compaction-model
9668    system_prompt: "task = X, currently editing Y, already tried Z."
9669  driver:
9670    artifact: murmur/driver-anthropic
9671"#,
9672        )
9673        .unwrap();
9674
9675        let compaction = manifest.inference.unwrap().compaction.unwrap();
9676        assert_eq!(
9677            compaction.system_prompt,
9678            Some("task = X, currently editing Y, already tried Z.".to_string())
9679        );
9680        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9681    }
9682
9683    /// The two override fields resolve independently: `model` set, `system_prompt`
9684    /// absent leaves the latter `None` with no default string substituted.
9685    #[test]
9686    fn compaction_system_prompt_absent_stays_none() {
9687        let manifest = RuntimeManifest::from_yaml_str(
9688            r#"
9689name: cap
9690version: 0.0.1
9691artifacts:
9692  - name: murmur/driver-anthropic
9693    version: 0.1.0
9694    runtime: driver
9695    gateway:
9696      endpoint: http://127.0.0.1:8080
9697      keyless: true
9698inference:
9699  model: test-model
9700  compaction:
9701    model: compaction-model
9702  driver:
9703    artifact: murmur/driver-anthropic
9704"#,
9705        )
9706        .unwrap();
9707
9708        let compaction = manifest.inference.unwrap().compaction.unwrap();
9709        assert!(compaction.system_prompt.is_none());
9710        assert!(compaction.system_prompt_file.is_none());
9711        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9712    }
9713
9714    /// `system_prompt_file` parses as the second, independent prompt source: the path is
9715    /// carried through verbatim (resolution against the manifest dir happens in the
9716    /// runtime), the inline field stays `None`, and `model` is unaffected.
9717    #[test]
9718    fn parses_compaction_system_prompt_file() {
9719        let manifest = RuntimeManifest::from_yaml_str(
9720            r#"
9721name: cap
9722version: 0.0.1
9723artifacts:
9724  - name: murmur/driver-anthropic
9725    version: 0.1.0
9726    runtime: driver
9727    gateway:
9728      endpoint: http://127.0.0.1:8080
9729      keyless: true
9730inference:
9731  model: test-model
9732  compaction:
9733    model: compaction-model
9734    system_prompt_file: "compaction-instructions.md"
9735  driver:
9736    artifact: murmur/driver-anthropic
9737"#,
9738        )
9739        .unwrap();
9740
9741        let compaction = manifest.inference.unwrap().compaction.unwrap();
9742        assert_eq!(
9743            compaction.system_prompt_file,
9744            Some("compaction-instructions.md".to_string())
9745        );
9746        assert!(compaction.system_prompt.is_none());
9747        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9748    }
9749
9750    /// `system_prompt_file` alone, with no `model`, still parses — the two fields do not
9751    /// depend on each other in either direction.
9752    #[test]
9753    fn compaction_system_prompt_file_independent_of_model() {
9754        let manifest = RuntimeManifest::from_yaml_str(
9755            r#"
9756name: cap
9757version: 0.0.1
9758artifacts:
9759  - name: murmur/driver-anthropic
9760    version: 0.1.0
9761    runtime: driver
9762    gateway:
9763      endpoint: http://127.0.0.1:8080
9764      keyless: true
9765inference:
9766  model: test-model
9767  compaction:
9768    threshold: 0.5
9769    system_prompt_file: "compaction-instructions.md"
9770  driver:
9771    artifact: murmur/driver-anthropic
9772"#,
9773        )
9774        .unwrap();
9775
9776        let compaction = manifest.inference.unwrap().compaction.unwrap();
9777        assert_eq!(
9778            compaction.system_prompt_file,
9779            Some("compaction-instructions.md".to_string())
9780        );
9781        assert!(compaction.model.is_none());
9782        assert_eq!(compaction.threshold, Some(0.5));
9783    }
9784
9785    /// `dump_summaries` is a plain optional boolean: present-true, present-false and absent
9786    /// are three distinguishable states, so the runtime can tell "explicitly off" from
9787    /// "never configured" even though both resolve to the same behavior.
9788    #[test]
9789    fn parses_compaction_dump_summaries() {
9790        let parse = |line: &str| {
9791            RuntimeManifest::from_yaml_str(&format!(
9792                r#"
9793name: cap
9794version: 0.0.1
9795artifacts:
9796  - name: murmur/driver-anthropic
9797    version: 0.1.0
9798    runtime: driver
9799    gateway:
9800      endpoint: http://127.0.0.1:8080
9801      keyless: true
9802inference:
9803  model: test-model
9804  compaction:
9805    model: compaction-model
9806{line}  driver:
9807    artifact: murmur/driver-anthropic
9808"#
9809            ))
9810            .unwrap()
9811            .inference
9812            .unwrap()
9813            .compaction
9814            .unwrap()
9815        };
9816
9817        assert_eq!(
9818            parse("    dump_summaries: true\n").dump_summaries,
9819            Some(true)
9820        );
9821        assert_eq!(
9822            parse("    dump_summaries: false\n").dump_summaries,
9823            Some(false)
9824        );
9825        assert_eq!(parse("").dump_summaries, None);
9826    }
9827
9828    /// `dump_summaries` is orthogonal to every other compaction field: it neither trips the
9829    /// prompt-source exclusivity check nor the threshold range check, and does not disturb
9830    /// the values those fields parse to.
9831    #[test]
9832    fn compaction_dump_summaries_does_not_interact_with_other_fields() {
9833        let compaction = RuntimeManifest::from_yaml_str(
9834            r#"
9835name: cap
9836version: 0.0.1
9837artifacts:
9838  - name: murmur/driver-anthropic
9839    version: 0.1.0
9840    runtime: driver
9841    gateway:
9842      endpoint: http://127.0.0.1:8080
9843      keyless: true
9844inference:
9845  model: test-model
9846  compaction:
9847    threshold: 0.5
9848    model: compaction-model
9849    system_prompt_file: "compaction-instructions.md"
9850    dump_summaries: true
9851  driver:
9852    artifact: murmur/driver-anthropic
9853"#,
9854        )
9855        .unwrap()
9856        .inference
9857        .unwrap()
9858        .compaction
9859        .unwrap();
9860
9861        assert_eq!(compaction.dump_summaries, Some(true));
9862        assert_eq!(compaction.threshold, Some(0.5));
9863        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9864        assert_eq!(
9865            compaction.system_prompt_file,
9866            Some("compaction-instructions.md".to_string())
9867        );
9868        assert!(compaction.system_prompt.is_none());
9869
9870        // Still mutually exclusive with dump_summaries in the mix...
9871        let err = RuntimeManifest::from_yaml_str(
9872            r#"
9873name: cap
9874version: 0.0.1
9875artifacts:
9876  - name: murmur/driver-anthropic
9877    version: 0.1.0
9878    runtime: driver
9879    gateway:
9880      endpoint: http://127.0.0.1:8080
9881      keyless: true
9882inference:
9883  model: test-model
9884  compaction:
9885    dump_summaries: true
9886    system_prompt: "inline"
9887    system_prompt_file: "compaction-instructions.md"
9888  driver:
9889    artifact: murmur/driver-anthropic
9890"#,
9891        )
9892        .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
9893        assert!(matches!(
9894            err,
9895            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9896                if field == "inference.compaction.system_prompt"
9897        ));
9898
9899        // ...and the threshold range is still enforced.
9900        let err = RuntimeManifest::from_yaml_str(
9901            r#"
9902name: cap
9903version: 0.0.1
9904artifacts:
9905  - name: murmur/driver-anthropic
9906    version: 0.1.0
9907    runtime: driver
9908    gateway:
9909      endpoint: http://127.0.0.1:8080
9910      keyless: true
9911inference:
9912  model: test-model
9913  compaction:
9914    dump_summaries: true
9915    threshold: 1.5
9916  driver:
9917    artifact: murmur/driver-anthropic
9918"#,
9919        )
9920        .expect_err("dump_summaries must not suppress the threshold range check");
9921        assert!(matches!(
9922            err,
9923            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9924                if field == "inference.compaction.threshold"
9925        ));
9926    }
9927
9928    fn compaction_call_manifest(lines: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
9929        RuntimeManifest::from_yaml_str(&format!(
9930            r#"
9931name: cap
9932version: 0.0.1
9933artifacts:
9934  - name: murmur/driver-anthropic
9935    version: 0.1.0
9936    runtime: driver
9937    gateway:
9938      endpoint: http://127.0.0.1:8080
9939      keyless: true
9940inference:
9941  model: test-model
9942  driver:
9943    artifact: murmur/driver-anthropic
9944    config:
9945      reasoning_effort: max
9946  compaction:
9947{lines}"#
9948        ))
9949    }
9950
9951    #[test]
9952    fn parses_compaction_max_tokens_and_driver_config() {
9953        let manifest = compaction_call_manifest(
9954            "    max_tokens: 8000\n    driver_config:\n      reasoning_effort: low\n      extra: [1, 2]\n",
9955        )
9956        .unwrap();
9957        assert!(
9958            manifest.unknown_keys.is_empty(),
9959            "neither key is unknown: {:?}",
9960            manifest.unknown_keys
9961        );
9962        let compaction = manifest.inference.unwrap().compaction.unwrap();
9963        assert_eq!(compaction.max_tokens, Some(8000));
9964        assert_eq!(
9965            compaction.driver_config.as_deref(),
9966            Some(r#"{"extra":[1,2],"reasoning_effort":"low"}"#)
9967        );
9968
9969        let compaction = compaction_call_manifest("    threshold: 0.5\n")
9970            .unwrap()
9971            .inference
9972            .unwrap()
9973            .compaction
9974            .unwrap();
9975        assert_eq!(compaction.max_tokens, None);
9976        assert_eq!(compaction.driver_config, None);
9977
9978        // An empty mapping is an overlay that changes nothing, not an absent one.
9979        let compaction = compaction_call_manifest("    driver_config: {}\n")
9980            .unwrap()
9981            .inference
9982            .unwrap()
9983            .compaction
9984            .unwrap();
9985        assert_eq!(compaction.driver_config.as_deref(), Some("{}"));
9986    }
9987
9988    #[test]
9989    fn compaction_max_tokens_zero_is_refused() {
9990        match compaction_call_manifest("    max_tokens: 0\n").unwrap_err() {
9991            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9992                assert_eq!(field, "inference.compaction.max_tokens");
9993                assert_eq!(message, "must be greater than 0");
9994            }
9995            other => panic!("unexpected error: {other:?}"),
9996        }
9997    }
9998
9999    #[test]
10000    fn compaction_driver_config_must_be_a_mapping() {
10001        for value in ["low", "[reasoning_effort]", "3"] {
10002            match compaction_call_manifest(&format!("    driver_config: {value}\n")).unwrap_err() {
10003                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10004                    assert_eq!(field, "inference.compaction.driver_config");
10005                    assert_eq!(message, "expected mapping/object");
10006                }
10007                other => panic!("unexpected error for {value}: {other:?}"),
10008            }
10009        }
10010    }
10011
10012    /// The harness runs its own compaction under `transport: process`, so neither key would
10013    /// shape any call there.
10014    #[test]
10015    fn process_transport_rejects_compaction_call_keys() {
10016        for (key, value) in [
10017            ("max_tokens", "8000"),
10018            ("driver_config", "{reasoning_effort: low}"),
10019        ] {
10020            let err = RuntimeManifest::from_yaml_str(&format!(
10021                "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-claude-code\n    \
10022                 version: 0.1.0\n    runtime: driver\ninference:\n  transport: process\n  \
10023                 model: claude-haiku-4-5-20251001\n  driver:\n    artifact: murmur/driver-claude-code\n  \
10024                 compaction:\n    {key}: {value}\n"
10025            ))
10026            .unwrap_err();
10027            match err {
10028                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10029                    assert_eq!(field, format!("inference.compaction.{key}"));
10030                    assert_eq!(message, "is not valid with transport: process");
10031                }
10032                other => panic!("unexpected error for {key}: {other:?}"),
10033            }
10034        }
10035    }
10036
10037    /// Setting both compaction prompt sources is a parse error naming the compaction
10038    /// fields — distinguishable from the top-level three-way `inference.system_prompt`
10039    /// exclusivity error, and with neither value silently preferred.
10040    #[test]
10041    fn compaction_prompt_sources_are_mutually_exclusive() {
10042        let err = RuntimeManifest::from_yaml_str(
10043            r#"
10044name: cap
10045version: 0.0.1
10046artifacts:
10047  - name: murmur/driver-anthropic
10048    version: 0.1.0
10049    runtime: driver
10050    gateway:
10051      endpoint: http://127.0.0.1:8080
10052      keyless: true
10053inference:
10054  model: test-model
10055  compaction:
10056    system_prompt: "inline"
10057    system_prompt_file: "compaction-instructions.md"
10058  driver:
10059    artifact: murmur/driver-anthropic
10060"#,
10061        )
10062        .expect_err("both compaction prompt sources set must fail");
10063
10064        match err {
10065            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10066                assert_eq!(field, "inference.compaction.system_prompt");
10067                assert_eq!(
10068                    message,
10069                    "at most one of inference.compaction.system_prompt, \
10070                     inference.compaction.system_prompt_file may be set"
10071                );
10072            }
10073            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
10074        }
10075    }
10076
10077    /// The compaction-level exclusivity check is separate from the top-level one: an
10078    /// inline top-level `system_prompt` alongside a compaction `system_prompt_file` is a
10079    /// perfectly legal manifest.
10080    #[test]
10081    fn top_level_and_compaction_prompt_sources_do_not_collide() {
10082        let manifest = RuntimeManifest::from_yaml_str(
10083            r#"
10084name: cap
10085version: 0.0.1
10086artifacts:
10087  - name: murmur/driver-anthropic
10088    version: 0.1.0
10089    runtime: driver
10090    gateway:
10091      endpoint: http://127.0.0.1:8080
10092      keyless: true
10093inference:
10094  model: test-model
10095  system_prompt: "be helpful"
10096  compaction:
10097    system_prompt_file: "compaction-instructions.md"
10098  driver:
10099    artifact: murmur/driver-anthropic
10100"#,
10101        )
10102        .unwrap();
10103
10104        let inference = manifest.inference.unwrap();
10105        assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
10106        assert_eq!(
10107            inference.compaction.unwrap().system_prompt_file,
10108            Some("compaction-instructions.md".to_string())
10109        );
10110    }
10111
10112    #[test]
10113    fn parses_system_prompt_file() {
10114        let manifest = RuntimeManifest::from_yaml_str(
10115            r#"
10116name: cap
10117version: 0.0.1
10118artifacts:
10119  - name: murmur/driver-anthropic
10120    version: 0.1.0
10121    runtime: driver
10122    gateway:
10123      endpoint: http://127.0.0.1:8080
10124      keyless: true
10125inference:
10126  model: test-model
10127  system_prompt_file: "conventions.md"
10128  driver:
10129    artifact: murmur/driver-anthropic
10130"#,
10131        )
10132        .unwrap();
10133
10134        let inference = manifest.inference.unwrap();
10135        assert_eq!(
10136            inference.system_prompt_file,
10137            Some("conventions.md".to_string())
10138        );
10139        assert!(inference.system_prompt.is_none());
10140    }
10141
10142    #[test]
10143    fn rejects_both_inline_and_file_system_prompt() {
10144        let err = RuntimeManifest::from_yaml_str(
10145            r#"
10146name: cap
10147version: 0.0.1
10148artifacts:
10149  - name: murmur/driver-anthropic
10150    version: 0.1.0
10151    runtime: driver
10152    gateway:
10153      endpoint: http://127.0.0.1:8080
10154      keyless: true
10155inference:
10156  model: test-model
10157  system_prompt: "inline"
10158  system_prompt_file: "conventions.md"
10159  driver:
10160    artifact: murmur/driver-anthropic
10161"#,
10162        )
10163        .unwrap_err();
10164
10165        let msg = err.to_string();
10166        assert!(msg.contains("inference.system_prompt"));
10167        assert!(msg.contains("system_prompt_file"));
10168    }
10169
10170    fn instruction_files_manifest(transport: &str, entries: &str) -> String {
10171        let (artifact, inference) = if transport == "process" {
10172            (
10173                "  - name: murmur/driver-claude-code\n    version: 0.1.0\n    runtime: driver\n",
10174                "  transport: process\n  driver:\n    artifact: murmur/driver-claude-code\n",
10175            )
10176        } else {
10177            (
10178                "  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    \
10179                 gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\n",
10180                "  model: test-model\n  driver:\n    artifact: murmur/driver-anthropic\n",
10181            )
10182        };
10183        format!(
10184            "name: cap\nversion: 0.0.1\nartifacts:\n{artifact}inference:\n{inference}  \
10185             instruction_files: {entries}\n"
10186        )
10187    }
10188
10189    #[test]
10190    fn instruction_files_absent_is_none() {
10191        let manifest = RuntimeManifest::from_yaml_str(
10192            r#"
10193name: cap
10194version: 0.0.1
10195artifacts:
10196  - name: murmur/driver-anthropic
10197    version: 0.1.0
10198    runtime: driver
10199    gateway:
10200      endpoint: http://127.0.0.1:8080
10201      keyless: true
10202inference:
10203  model: test-model
10204  driver:
10205    artifact: murmur/driver-anthropic
10206"#,
10207        )
10208        .unwrap();
10209        assert_eq!(manifest.inference.unwrap().instruction_files, None);
10210    }
10211
10212    /// Entries are kept as written and in manifest order, `.` components included, on both
10213    /// transports, and the key draws no unknown-key report.
10214    #[test]
10215    fn instruction_files_are_kept_verbatim_in_order_on_both_transports() {
10216        for transport in ["http", "process"] {
10217            let manifest = RuntimeManifest::from_yaml_str(&instruction_files_manifest(
10218                transport,
10219                "[CLAUDE.md, ./AGENTS.md, docs/agents/RULES.md]",
10220            ))
10221            .unwrap();
10222            assert!(
10223                manifest.unknown_keys.is_empty(),
10224                "{transport}: {:?}",
10225                manifest.unknown_keys
10226            );
10227            assert_eq!(
10228                manifest.inference.unwrap().instruction_files,
10229                Some(vec![
10230                    "CLAUDE.md".to_string(),
10231                    "./AGENTS.md".to_string(),
10232                    "docs/agents/RULES.md".to_string(),
10233                ]),
10234                "{transport}"
10235            );
10236        }
10237    }
10238
10239    #[test]
10240    fn instruction_files_empty_list_is_some_empty() {
10241        let manifest =
10242            RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", "[]")).unwrap();
10243        assert_eq!(manifest.inference.unwrap().instruction_files, Some(vec![]));
10244    }
10245
10246    /// An entry that is blank, absolute or climbs out with `..` is refused on the indexed field,
10247    /// with the entry quoted, before anything is staged.
10248    #[test]
10249    fn instruction_files_refuse_entries_that_leave_the_work_folder() {
10250        for (entries, index, entry, problem) in [
10251            ("[/etc/passwd]", 0, "/etc/passwd", "is absolute"),
10252            (
10253                "[AGENTS.md, ../AGENTS.md]",
10254                1,
10255                "../AGENTS.md",
10256                "'..' component",
10257            ),
10258            ("[a/../../b]", 0, "a/../../b", "'..' component"),
10259            ("[\"\"]", 0, "", "is empty"),
10260            ("[AGENTS.md, \"  \"]", 1, "  ", "is empty"),
10261        ] {
10262            let err = RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", entries))
10263                .unwrap_err();
10264            let RuntimeManifestError::InvalidInferenceConfig { field, message } = &err else {
10265                panic!("{entries}: expected InvalidInferenceConfig, got {err:?}");
10266            };
10267            assert_eq!(field, &format!("inference.instruction_files[{index}]"));
10268            assert!(message.contains(&format!("'{entry}'")), "{message}");
10269            assert!(message.contains(problem), "{message}");
10270            assert!(
10271                message.contains("must be a relative path inside the work folder"),
10272                "{message}"
10273            );
10274        }
10275    }
10276
10277    /// A `${NAME}` is kept as the name it is, whether or not this process holds the variable: the
10278    /// parse never reads the environment.
10279    #[test]
10280    fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
10281        let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
10282        let yaml = format!(
10283            "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"
10284        );
10285
10286        assert_eq!(
10287            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
10288                .gateway
10289                .clone()
10290                .unwrap()
10291                .api_key,
10292            Some(ApiKeyReference::Environment(key.to_string()))
10293        );
10294        assert_eq!(
10295            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
10296                .unwrap()
10297                .artifacts[0]
10298                .gateway
10299                .clone()
10300                .unwrap()
10301                .api_key,
10302            None
10303        );
10304    }
10305
10306    #[test]
10307    fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
10308        let literal = ["sk-", "ant-", "debug"].concat();
10309        let debug = format!(
10310            "{:?} {:?}",
10311            ApiKeyReference::Environment("SOME_KEY".to_string()),
10312            ApiKeyReference::Literal(literal.clone())
10313        );
10314        assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
10315        assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
10316        assert!(!debug.contains(&literal), "{debug}");
10317    }
10318
10319    #[test]
10320    fn parse_without_secrets_drops_a_literal_api_key() {
10321        // Key assembled at runtime so the source never contains a
10322        // credential-shaped literal that secret scanners could flag.
10323        let literal = ["sk-", "ant-", "literal"].concat();
10324        let yaml = format!(
10325            "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"
10326        );
10327
10328        assert_eq!(
10329            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
10330                .gateway
10331                .clone()
10332                .unwrap()
10333                .api_key,
10334            Some(ApiKeyReference::Literal(literal))
10335        );
10336        assert_eq!(
10337            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
10338                .unwrap()
10339                .artifacts[0]
10340                .gateway
10341                .clone()
10342                .unwrap()
10343                .api_key,
10344            None
10345        );
10346    }
10347
10348    /// The non-resolving parse is narrower only in what it resolves, never in what it validates:
10349    /// a manifest roost accepts is a manifest `mur run` would also accept.
10350    #[test]
10351    fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
10352        let process_with_key = format!(
10353            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  model: test-model\n  api_key: {}\n",
10354            ["sk-", "ant-", "secret"].concat()
10355        );
10356        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";
10357
10358        for yaml in [process_with_key.as_str(), http_without_model] {
10359            let resolving = RuntimeManifest::from_yaml_str(yaml)
10360                .unwrap_err()
10361                .to_string();
10362            let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
10363                .unwrap_err()
10364                .to_string();
10365            assert_eq!(resolving, without_secrets);
10366        }
10367    }
10368
10369    /// The reference scan answers from the manifest text alone: it reports a reference, ignores
10370    /// a literal, and has nothing to say about a manifest that carries no `inference` block or
10371    /// does not parse at all.
10372    #[test]
10373    fn referenced_env_variables_reports_a_reference_and_nothing_else() {
10374        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";
10375        let referenced = referenced_env_variables(referencing);
10376        assert_eq!(referenced.len(), 1);
10377        assert_eq!(
10378            referenced[0].field,
10379            "artifacts.murmur/driver-anthropic.gateway.api_key"
10380        );
10381        assert!(referenced[0].is_gateway_credential());
10382        assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
10383
10384        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";
10385        assert!(referenced_env_variables(literal).is_empty());
10386
10387        let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
10388        assert!(referenced_env_variables(no_inference).is_empty());
10389
10390        let unparseable = "name: broken\nversion: [\n";
10391        assert!(referenced_env_variables(unparseable).is_empty());
10392    }
10393
10394    #[test]
10395    fn inference_requires_driver_artifact() {
10396        let err = RuntimeManifest::from_yaml_str(
10397            r#"
10398name: cap
10399version: 0.0.1
10400artifacts: []
10401inference:
10402  transport: http
10403  endpoint: http://127.0.0.1:8080
10404  model: test-model
10405"#,
10406        )
10407        .unwrap_err();
10408
10409        assert!(err.to_string().contains("inference.driver"));
10410    }
10411
10412    #[test]
10413    fn test_runtime_driver_deserializes() {
10414        let manifest = RuntimeManifest::from_yaml_str(
10415            r#"
10416name: cap
10417version: 0.0.1
10418artifacts:
10419  - name: local/my-provider
10420    version: 1.0.0
10421    runtime: driver
10422"#,
10423        )
10424        .unwrap();
10425
10426        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
10427    }
10428
10429    #[test]
10430    fn test_runtime_unknown_is_error() {
10431        let err = RuntimeManifest::from_yaml_str(
10432            r#"
10433name: cap
10434version: 0.0.1
10435artifacts:
10436  - name: local/foo
10437    version: 1.0.0
10438    runtime: unknown_type
10439"#,
10440        )
10441        .unwrap_err();
10442
10443        let msg = err.to_string();
10444        assert!(msg.contains("runtime"), "error should mention 'runtime'");
10445        assert!(
10446            msg.contains("unknown_type"),
10447            "error should identify the bad value"
10448        );
10449    }
10450
10451    #[test]
10452    fn test_runtime_all_variants_roundtrip() {
10453        for variant in [
10454            ArtifactRuntime::Tool,
10455            ArtifactRuntime::Driver,
10456            ArtifactRuntime::Hook,
10457            ArtifactRuntime::Skill,
10458        ] {
10459            let json = serde_json::to_string(&variant).unwrap();
10460            let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
10461            assert_eq!(variant, back);
10462        }
10463    }
10464
10465    #[test]
10466    fn skill_source_without_version_defaults_to_local() {
10467        let manifest = RuntimeManifest::from_yaml_str(
10468            r#"
10469name: cap
10470version: 0.0.1
10471artifacts:
10472  - name: my-skill
10473    source: ./skills/my-skill/skill.md
10474    runtime: skill
10475"#,
10476        )
10477        .unwrap();
10478
10479        assert_eq!(manifest.artifacts.len(), 1);
10480        let artifact = &manifest.artifacts[0];
10481        assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
10482        assert_eq!(
10483            artifact.source.as_deref(),
10484            Some("./skills/my-skill/skill.md")
10485        );
10486        assert_eq!(artifact.version, "local");
10487    }
10488
10489    #[test]
10490    fn skill_source_directory_path_is_accepted() {
10491        let manifest = RuntimeManifest::from_yaml_str(
10492            r#"
10493name: cap
10494version: 0.0.1
10495artifacts:
10496  - name: my-skill
10497    source: ./skills/my-skill/
10498    runtime: skill
10499"#,
10500        )
10501        .unwrap();
10502
10503        assert_eq!(
10504            manifest.artifacts[0].source.as_deref(),
10505            Some("./skills/my-skill/")
10506        );
10507        assert_eq!(manifest.artifacts[0].version, "local");
10508    }
10509
10510    #[test]
10511    fn skill_source_with_explicit_version_substitutes_local() {
10512        // Both set is allowed; version is ignored and "local" is substituted.
10513        let manifest = RuntimeManifest::from_yaml_str(
10514            r#"
10515name: cap
10516version: 0.0.1
10517artifacts:
10518  - name: my-skill
10519    source: ./skills/my-skill/skill.md
10520    version: 9.9.9
10521    runtime: skill
10522"#,
10523        )
10524        .unwrap();
10525
10526        assert_eq!(manifest.artifacts[0].version, "local");
10527    }
10528
10529    #[test]
10530    fn local_source_true_allows_source_on_non_skill_runtime() {
10531        for runtime in ["tool", "driver", "hook"] {
10532            let yaml = format!(
10533                r#"
10534name: cap
10535version: 0.0.1
10536artifacts:
10537  - name: my-thing
10538    source: ./local/path
10539    local_source: true
10540    runtime: {runtime}
10541"#
10542            );
10543            let manifest = RuntimeManifest::from_yaml_str(&yaml)
10544                .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
10545            let artifact = &manifest.artifacts[0];
10546            assert!(artifact.local_source, "runtime {runtime}");
10547            assert_eq!(artifact.source.as_deref(), Some("./local/path"));
10548            assert_eq!(artifact.version, "local", "runtime {runtime}");
10549        }
10550    }
10551
10552    #[test]
10553    fn local_source_false_on_skill_with_source_is_error() {
10554        // The gate reads the declared field, not the role: an explicit `local_source: false`
10555        // overrides the skill default and rejects `source:`.
10556        let err = RuntimeManifest::from_yaml_str(
10557            r#"
10558name: cap
10559version: 0.0.1
10560artifacts:
10561  - name: my-skill
10562    source: ./skills/my-skill
10563    local_source: false
10564    runtime: skill
10565"#,
10566        )
10567        .unwrap_err();
10568        let msg = err.to_string();
10569        assert!(msg.contains("'local_source: true'"), "error was: {msg}");
10570        assert!(msg.contains("my-skill"), "error was: {msg}");
10571    }
10572
10573    #[test]
10574    fn local_source_defaults_from_role_when_absent() {
10575        let manifest = RuntimeManifest::from_yaml_str(
10576            r#"
10577name: cap
10578version: 0.0.1
10579artifacts:
10580  - name: my-skill
10581    source: ./skills/my-skill
10582    runtime: skill
10583  - name: local/my-tool
10584    version: 1.0.0
10585    runtime: tool
10586"#,
10587        )
10588        .unwrap();
10589        assert!(manifest.artifacts[0].local_source, "skill defaults to true");
10590        assert!(
10591            !manifest.artifacts[1].local_source,
10592            "tool defaults to false"
10593        );
10594    }
10595
10596    #[test]
10597    fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
10598        let manifest = RuntimeManifest::from_yaml_str(
10599            r#"
10600name: cap
10601version: 0.0.1
10602artifacts:
10603  - name: local/my-tool
10604    version: 1.0.0
10605    runtime: tool
10606    prompt_payload: true
10607  - name: murmur/driver-anthropic
10608    version: 0.1.0
10609    runtime: driver
10610    gateway:
10611      endpoint: http://127.0.0.1:8080
10612      keyless: true
10613inference:
10614  model: test-model
10615  system_prompt_artifact: local/my-tool
10616  driver:
10617    artifact: murmur/driver-anthropic
10618"#,
10619        )
10620        .unwrap();
10621        assert!(manifest.artifacts[0].prompt_payload);
10622    }
10623
10624    #[test]
10625    fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
10626        let err = RuntimeManifest::from_yaml_str(
10627            r#"
10628name: cap
10629version: 0.0.1
10630artifacts:
10631  - name: local/my-skill
10632    version: 1.0.0
10633    runtime: skill
10634    prompt_payload: false
10635  - name: murmur/driver-anthropic
10636    version: 0.1.0
10637    runtime: driver
10638    gateway:
10639      endpoint: http://127.0.0.1:8080
10640      keyless: true
10641inference:
10642  model: test-model
10643  system_prompt_artifact: local/my-skill
10644  driver:
10645    artifact: murmur/driver-anthropic
10646"#,
10647        )
10648        .unwrap_err();
10649        let msg = err.to_string();
10650        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
10651    }
10652
10653    #[test]
10654    fn prompt_payload_defaults_from_role_when_absent() {
10655        let manifest = RuntimeManifest::from_yaml_str(
10656            r#"
10657name: cap
10658version: 0.0.1
10659artifacts:
10660  - name: local/my-skill
10661    version: 1.0.0
10662    runtime: skill
10663  - name: local/my-tool
10664    version: 1.0.0
10665    runtime: tool
10666"#,
10667        )
10668        .unwrap();
10669        assert!(
10670            manifest.artifacts[0].prompt_payload,
10671            "skill defaults to true"
10672        );
10673        assert!(
10674            !manifest.artifacts[1].prompt_payload,
10675            "tool defaults to false"
10676        );
10677    }
10678
10679    #[test]
10680    fn source_on_non_skill_runtime_is_error() {
10681        for runtime in ["tool", "driver", "hook"] {
10682            let yaml = format!(
10683                r#"
10684name: cap
10685version: 0.0.1
10686artifacts:
10687  - name: my-thing
10688    source: ./local/path
10689    runtime: {runtime}
10690"#
10691            );
10692            let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
10693            let msg = err.to_string();
10694            assert!(
10695                msg.contains("'local_source: true'"),
10696                "runtime {runtime}: error was: {msg}"
10697            );
10698            assert!(
10699                msg.contains("my-thing"),
10700                "runtime {runtime}: error was: {msg}"
10701            );
10702        }
10703    }
10704
10705    #[test]
10706    fn registry_skill_still_requires_version() {
10707        let err = RuntimeManifest::from_yaml_str(
10708            r#"
10709name: cap
10710version: 0.0.1
10711artifacts:
10712  - name: local/my-skill
10713    runtime: skill
10714"#,
10715        )
10716        .unwrap_err();
10717
10718        let msg = err.to_string();
10719        assert!(
10720            msg.contains("missing required field 'version'"),
10721            "error was: {msg}"
10722        );
10723    }
10724
10725    #[test]
10726    fn rejects_unknown_inference_transport() {
10727        let err = RuntimeManifest::from_yaml_str(
10728            r#"
10729name: cap
10730version: 0.0.1
10731artifacts: []
10732inference:
10733  transport: grpc
10734  model: test-model
10735  driver:
10736    artifact: murmur/driver-anthropic
10737"#,
10738        )
10739        .unwrap_err();
10740
10741        let msg = err.to_string();
10742        assert!(msg.contains("inference.transport"), "error was: {msg}");
10743        assert!(msg.contains("unknown value"), "error was: {msg}");
10744        assert!(msg.contains("grpc"), "error was: {msg}");
10745    }
10746
10747    /// The driver is what knows a harness, so `command:` alone selects nothing: a manifest that
10748    /// names only a command is refused and told which field to add.
10749    #[test]
10750    fn process_transport_with_command_and_no_driver_is_refused() {
10751        let err = RuntimeManifest::from_yaml_str(
10752            r#"
10753name: cap
10754version: 0.0.1
10755artifacts: []
10756inference:
10757  transport: process
10758  command: some-cli
10759  model: some-model
10760  max_turns: 20
10761"#,
10762        )
10763        .unwrap_err();
10764
10765        let msg = err.to_string();
10766        assert!(
10767            msg.contains("inference.driver.artifact"),
10768            "error was: {msg}"
10769        );
10770        assert!(msg.contains("inference.command"), "error was: {msg}");
10771        assert!(
10772            msg.contains("murmur/driver-claude-code"),
10773            "error was: {msg}"
10774        );
10775    }
10776
10777    #[test]
10778    fn process_transport_with_neither_driver_nor_command_is_refused() {
10779        let err = RuntimeManifest::from_yaml_str(
10780            r#"
10781name: cap
10782version: 0.0.1
10783artifacts: []
10784inference:
10785  transport: process
10786  model: some-model
10787"#,
10788        )
10789        .unwrap_err();
10790
10791        let msg = err.to_string();
10792        assert!(
10793            msg.contains("inference.driver.artifact"),
10794            "error was: {msg}"
10795        );
10796        assert!(
10797            msg.contains("murmur/driver-claude-code"),
10798            "error was: {msg}"
10799        );
10800    }
10801
10802    #[test]
10803    fn process_transport_allows_absent_model() {
10804        // model is optional for transport: process — an absent model leaves the choice to the
10805        // harness, which has an account default of its own.
10806        let manifest =
10807            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
10808                .expect("process transport should parse without a model");
10809        let inference = manifest.inference.expect("inference present");
10810        assert_eq!(inference.transport, "process");
10811        assert!(inference.command.is_none());
10812        assert_eq!(
10813            inference.model, "",
10814            "absent model resolves to empty (harness default)"
10815        );
10816    }
10817
10818    fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
10819        format!(
10820            "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n  transport: process\n  \
10821             driver:\n    artifact: local/fixture-process-driver\n{inference_extra}"
10822        )
10823    }
10824
10825    const PROCESS_DRIVER_ENTRY: &str =
10826        "  - name: local/fixture-process-driver\n    version: 0.1.0\n    runtime: driver";
10827
10828    #[test]
10829    fn process_transport_accepts_driver() {
10830        let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10831            PROCESS_DRIVER_ENTRY,
10832            "    config:\n      profile: work\n  command: /usr/local/bin/fixture-cli\n",
10833        ))
10834        .unwrap();
10835
10836        let inference = manifest.inference.expect("inference should exist");
10837        assert_eq!(inference.transport, "process");
10838        let driver = inference.driver.expect("driver should be parsed");
10839        assert_eq!(driver.artifact, "local/fixture-process-driver");
10840        assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
10841        assert_eq!(
10842            inference.command.as_deref(),
10843            Some("/usr/local/bin/fixture-cli")
10844        );
10845    }
10846
10847    #[test]
10848    fn process_transport_accepts_driver_without_command() {
10849        let manifest =
10850            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
10851                .unwrap();
10852
10853        let inference = manifest.inference.expect("inference should exist");
10854        assert_eq!(
10855            inference.driver.map(|d| d.artifact).as_deref(),
10856            Some("local/fixture-process-driver")
10857        );
10858        assert!(inference.command.is_none());
10859        assert_eq!(inference.model, "");
10860    }
10861
10862    #[test]
10863    fn process_driver_entry_must_be_declared() {
10864        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10865            "  - name: local/other\n    version: 0.1.0\n    runtime: tool",
10866            "",
10867        ))
10868        .unwrap_err()
10869        .to_string();
10870        assert!(msg.contains("inference.driver.artifact"), "{msg}");
10871        assert!(
10872            msg.contains("artifact 'local/fixture-process-driver' is not declared in artifacts:"),
10873            "{msg}"
10874        );
10875    }
10876
10877    #[test]
10878    fn process_driver_entry_must_be_runtime_driver() {
10879        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10880            "  - name: local/fixture-process-driver\n    version: 0.1.0\n    runtime: tool",
10881            "",
10882        ))
10883        .unwrap_err()
10884        .to_string();
10885        assert!(
10886            msg.contains("must be declared with runtime: driver"),
10887            "{msg}"
10888        );
10889    }
10890
10891    #[test]
10892    fn process_driver_entry_refuses_capabilities() {
10893        let entry =
10894            format!("{PROCESS_DRIVER_ENTRY}\n    capabilities:\n      env:\n        allow: [HOME]");
10895        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
10896            .unwrap_err()
10897            .to_string();
10898        assert!(msg.contains("granted nothing"), "{msg}");
10899        assert!(msg.contains("inference.driver.config"), "{msg}");
10900    }
10901
10902    #[test]
10903    fn process_driver_entry_refuses_config() {
10904        let entry = format!("{PROCESS_DRIVER_ENTRY}\n    config:\n      profile: work");
10905        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
10906            .unwrap_err()
10907            .to_string();
10908        assert!(msg.contains("granted nothing"), "{msg}");
10909        assert!(msg.contains("remove capabilities:/config:"), "{msg}");
10910    }
10911
10912    #[test]
10913    fn process_transport_rejects_endpoint() {
10914        let err = RuntimeManifest::from_yaml_str(
10915            r#"
10916name: cap
10917version: 0.0.1
10918artifacts: []
10919inference:
10920  transport: process
10921  command: claude
10922  model: claude-haiku-4-5-20251001
10923  endpoint: http://localhost:8080
10924"#,
10925        )
10926        .unwrap_err();
10927
10928        let msg = err.to_string();
10929        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
10930        assert!(msg.contains("was removed"), "error was: {msg}");
10931    }
10932
10933    #[test]
10934    fn process_transport_rejects_api_key() {
10935        // Key assembled at runtime so the source never contains a
10936        // credential-shaped literal that secret scanners could flag.
10937        let key = ["sk-", "ant-", "secret"].concat();
10938        let err = RuntimeManifest::from_yaml_str(&format!(
10939            r#"
10940name: cap
10941version: 0.0.1
10942artifacts: []
10943inference:
10944  transport: process
10945  command: claude
10946  model: claude-haiku-4-5-20251001
10947  api_key: {key}
10948"#
10949        ))
10950        .unwrap_err();
10951
10952        let msg = err.to_string();
10953        assert!(msg.contains("inference.api_key"), "error was: {msg}");
10954        assert!(msg.contains("was removed"), "error was: {msg}");
10955        assert!(!msg.contains(&key), "error was: {msg}");
10956    }
10957
10958    #[test]
10959    fn process_transport_rejects_max_tokens() {
10960        // Rejected even at a perfectly valid value: the CLI subprocess path never builds a
10961        // driver payload, so accepting it would leave it silently inert.
10962        let err = RuntimeManifest::from_yaml_str(
10963            r#"
10964name: cap
10965version: 0.0.1
10966artifacts: []
10967inference:
10968  transport: process
10969  command: claude
10970  model: claude-haiku-4-5-20251001
10971  max_tokens: 4096
10972"#,
10973        )
10974        .unwrap_err();
10975
10976        let msg = err.to_string();
10977        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
10978        assert!(
10979            msg.contains("not valid with transport: process"),
10980            "error was: {msg}"
10981        );
10982    }
10983
10984    /// The harness's own reported counts are what this transport meters, so the ceiling parses
10985    /// here as it does under `http`. A driver that reports no counts is refused at load, not
10986    /// here: the manifest cannot see which driver it names.
10987    #[test]
10988    fn process_transport_carries_max_session_tokens() {
10989        let manifest = RuntimeManifest::from_yaml_str(
10990            r#"
10991name: cap
10992version: 0.0.1
10993artifacts:
10994  - name: murmur/driver-claude-code
10995    version: 0.1.0
10996    runtime: driver
10997inference:
10998  transport: process
10999  driver:
11000    artifact: murmur/driver-claude-code
11001  model: claude-haiku-4-5-20251001
11002  max_session_tokens: 200000
11003"#,
11004        )
11005        .unwrap();
11006
11007        assert_eq!(
11008            manifest.inference.unwrap().max_session_tokens,
11009            Some(200_000)
11010        );
11011    }
11012
11013    #[test]
11014    fn process_transport_rejects_a_zero_max_session_tokens() {
11015        let err = RuntimeManifest::from_yaml_str(
11016            r#"
11017name: cap
11018version: 0.0.1
11019artifacts:
11020  - name: murmur/driver-claude-code
11021    version: 0.1.0
11022    runtime: driver
11023inference:
11024  transport: process
11025  driver:
11026    artifact: murmur/driver-claude-code
11027  model: claude-haiku-4-5-20251001
11028  max_session_tokens: 0
11029"#,
11030        )
11031        .unwrap_err();
11032
11033        let msg = err.to_string();
11034        assert!(
11035            msg.contains("inference.max_session_tokens"),
11036            "error was: {msg}"
11037        );
11038        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
11039    }
11040
11041    #[test]
11042    fn inference_max_session_tokens_round_trips() {
11043        let manifest = RuntimeManifest::from_yaml_str(
11044            r#"
11045name: cap
11046version: 0.0.1
11047artifacts:
11048  - name: murmur/driver-anthropic
11049    version: 0.1.0
11050    runtime: driver
11051    gateway:
11052      endpoint: https://api.anthropic.com
11053      api_key: test-key
11054inference:
11055  transport: http
11056  model: claude-opus-4-5
11057  max_session_tokens: 5000000000
11058  driver:
11059    artifact: murmur/driver-anthropic
11060"#,
11061        )
11062        .unwrap();
11063
11064        assert_eq!(
11065            manifest.inference.unwrap().max_session_tokens,
11066            Some(5_000_000_000)
11067        );
11068    }
11069
11070    #[test]
11071    fn inference_max_session_tokens_absent_is_none() {
11072        let manifest = RuntimeManifest::from_yaml_str(
11073            r#"
11074name: cap
11075version: 0.0.1
11076artifacts:
11077  - name: murmur/driver-anthropic
11078    version: 0.1.0
11079    runtime: driver
11080    gateway:
11081      endpoint: https://api.anthropic.com
11082      api_key: test-key
11083inference:
11084  transport: http
11085  model: claude-opus-4-5
11086  driver:
11087    artifact: murmur/driver-anthropic
11088"#,
11089        )
11090        .unwrap();
11091
11092        assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
11093    }
11094
11095    #[test]
11096    fn inference_max_session_tokens_zero_is_rejected() {
11097        let err = RuntimeManifest::from_yaml_str(
11098            r#"
11099name: cap
11100version: 0.0.1
11101artifacts:
11102  - name: murmur/driver-anthropic
11103    version: 0.1.0
11104    runtime: driver
11105    gateway:
11106      endpoint: https://api.anthropic.com
11107      api_key: test-key
11108inference:
11109  transport: http
11110  model: claude-opus-4-5
11111  max_session_tokens: 0
11112  driver:
11113    artifact: murmur/driver-anthropic
11114"#,
11115        )
11116        .unwrap_err();
11117
11118        let msg = err.to_string();
11119        assert!(
11120            msg.contains("inference.max_session_tokens"),
11121            "error was: {msg}"
11122        );
11123        assert!(msg.contains("greater than 0"), "error was: {msg}");
11124    }
11125
11126    /// An http capsule manifest with `extra` appended to its `inference:` block.
11127    fn http_manifest_with_inference(extra: &str) -> String {
11128        format!(
11129            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    \
11130             version: 0.1.0\n    runtime: driver\n    gateway:\n      \
11131             endpoint: https://api.anthropic.com\n      api_key: test-key\n\
11132             inference:\n  transport: http\n  model: claude-opus-4-5\n  driver:\n    \
11133             artifact: murmur/driver-anthropic\n{extra}"
11134        )
11135    }
11136
11137    #[test]
11138    fn inference_tool_refresh_absent_is_compaction() {
11139        let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
11140        assert_eq!(
11141            manifest.inference.unwrap().tool_refresh,
11142            ToolRefresh::Compaction
11143        );
11144        assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
11145    }
11146
11147    #[test]
11148    fn inference_tool_refresh_parses_both_values() {
11149        for (value, expected) in [
11150            ("compaction", ToolRefresh::Compaction),
11151            ("immediate", ToolRefresh::Immediate),
11152        ] {
11153            let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
11154                "  tool_refresh: {value}\n"
11155            )))
11156            .unwrap();
11157            assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
11158            assert_eq!(expected.wire_name(), value);
11159            assert!(
11160                manifest.unknown_keys.is_empty(),
11161                "tool_refresh is a known key: {:?}",
11162                manifest.unknown_keys
11163            );
11164        }
11165    }
11166
11167    #[test]
11168    fn inference_tool_refresh_rejects_an_unknown_value() {
11169        let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
11170            "  tool_refresh: sometimes\n",
11171        ))
11172        .unwrap_err();
11173        match err {
11174            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
11175                assert_eq!(field, "inference.tool_refresh");
11176                assert_eq!(
11177                    message,
11178                    "must be one of: compaction, immediate; got 'sometimes'"
11179                );
11180            }
11181            other => panic!("unexpected error: {other:?}"),
11182        }
11183    }
11184
11185    /// Refused whatever its value: the harness owns its tool list under `transport: process`,
11186    /// so the key would be silently inert there.
11187    #[test]
11188    fn process_transport_rejects_tool_refresh() {
11189        for value in ["compaction", "immediate"] {
11190            let err = RuntimeManifest::from_yaml_str(&format!(
11191                "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  \
11192                 command: claude\n  model: claude-haiku-4-5-20251001\n  tool_refresh: {value}\n"
11193            ))
11194            .unwrap_err();
11195            match err {
11196                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
11197                    assert_eq!(field, "inference.tool_refresh");
11198                    assert_eq!(message, "is not valid with transport: process");
11199                }
11200                other => panic!("unexpected error for {value}: {other:?}"),
11201            }
11202        }
11203    }
11204
11205    #[test]
11206    fn inference_max_tokens_round_trips() {
11207        let manifest = RuntimeManifest::from_yaml_str(
11208            r#"
11209name: cap
11210version: 0.0.1
11211artifacts:
11212  - name: murmur/driver-anthropic
11213    version: 0.1.0
11214    runtime: driver
11215    gateway:
11216      endpoint: https://api.anthropic.com
11217      api_key: test-key
11218inference:
11219  transport: http
11220  model: claude-opus-4-5
11221  max_tokens: 4096
11222  driver:
11223    artifact: murmur/driver-anthropic
11224"#,
11225        )
11226        .unwrap();
11227
11228        let inference = manifest.inference.unwrap();
11229        assert_eq!(inference.max_tokens, Some(4096));
11230    }
11231
11232    #[test]
11233    fn inference_max_tokens_absent_is_none() {
11234        // Absent means "the manifest didn't set it" all the way down; the runtime resolves each
11235        // driver choice's cap from its driver's `models:` table, not here.
11236        let manifest = RuntimeManifest::from_yaml_str(
11237            r#"
11238name: cap
11239version: 0.0.1
11240artifacts:
11241  - name: murmur/driver-anthropic
11242    version: 0.1.0
11243    runtime: driver
11244    gateway:
11245      endpoint: https://api.anthropic.com
11246      api_key: test-key
11247inference:
11248  transport: http
11249  model: claude-opus-4-5
11250  driver:
11251    artifact: murmur/driver-anthropic
11252"#,
11253        )
11254        .unwrap();
11255
11256        assert_eq!(manifest.inference.unwrap().max_tokens, None);
11257    }
11258
11259    #[test]
11260    fn inference_max_tokens_zero_is_rejected() {
11261        let err = RuntimeManifest::from_yaml_str(
11262            r#"
11263name: cap
11264version: 0.0.1
11265artifacts:
11266  - name: murmur/driver-anthropic
11267    version: 0.1.0
11268    runtime: driver
11269    gateway:
11270      endpoint: https://api.anthropic.com
11271      api_key: test-key
11272inference:
11273  transport: http
11274  model: claude-opus-4-5
11275  max_tokens: 0
11276  driver:
11277    artifact: murmur/driver-anthropic
11278"#,
11279        )
11280        .unwrap_err();
11281
11282        let msg = err.to_string();
11283        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
11284        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
11285    }
11286
11287    #[test]
11288    fn inference_max_tokens_large_value_is_accepted() {
11289        // Manifest validation stays advisory: an over-large cap is the provider's to reject
11290        // at request time, not ours to clamp or block at load time.
11291        let manifest = RuntimeManifest::from_yaml_str(
11292            r#"
11293name: cap
11294version: 0.0.1
11295artifacts:
11296  - name: murmur/driver-anthropic
11297    version: 0.1.0
11298    runtime: driver
11299    gateway:
11300      endpoint: https://api.anthropic.com
11301      api_key: test-key
11302inference:
11303  transport: http
11304  model: claude-opus-4-5
11305  max_tokens: 999999
11306  driver:
11307    artifact: murmur/driver-anthropic
11308"#,
11309        )
11310        .unwrap();
11311
11312        assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
11313    }
11314
11315    #[test]
11316    fn inference_and_context_max_tokens_are_independent() {
11317        // Two unrelated concepts that happen to share a leaf name: per-turn output cap vs.
11318        // session compaction budget. Neither may leak into the other.
11319        let manifest = RuntimeManifest::from_yaml_str(
11320            r#"
11321name: cap
11322version: 0.0.1
11323artifacts:
11324  - name: murmur/driver-anthropic
11325    version: 0.1.0
11326    runtime: driver
11327    gateway:
11328      endpoint: https://api.anthropic.com
11329      api_key: test-key
11330inference:
11331  transport: http
11332  model: claude-opus-4-5
11333  max_tokens: 4096
11334  driver:
11335    artifact: murmur/driver-anthropic
11336context:
11337  max_tokens: 200000
11338"#,
11339        )
11340        .unwrap();
11341
11342        assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
11343        assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
11344    }
11345
11346    #[test]
11347    fn hook_config_defaults_when_fields_absent() {
11348        let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
11349        assert_eq!(config.binding, HookBinding::All);
11350        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
11351        assert_eq!(config.commit_policy, HookCommitPolicy::None);
11352    }
11353
11354    #[test]
11355    fn hook_config_parses_all_fields() {
11356        let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
11357        let config = parse_hook_config_from_yaml(yaml).unwrap();
11358        assert_eq!(config.binding, HookBinding::OnCompaction);
11359        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
11360        assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
11361    }
11362
11363    #[test]
11364    fn hook_config_parses_on_task_start() {
11365        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
11366        let config = parse_hook_config_from_yaml(yaml).unwrap();
11367        assert_eq!(config.binding, HookBinding::OnTaskStart);
11368    }
11369
11370    #[test]
11371    fn hook_config_parses_on_task_end() {
11372        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
11373        let config = parse_hook_config_from_yaml(yaml).unwrap();
11374        assert_eq!(config.binding, HookBinding::OnTaskEnd);
11375    }
11376
11377    #[test]
11378    fn hook_config_unknown_binding_lists_task_events() {
11379        let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
11380        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11381        assert!(err.contains("on-task-start"), "error was: {err}");
11382        assert!(err.contains("on-task-end"), "error was: {err}");
11383    }
11384
11385    #[test]
11386    fn hook_config_async_with_commit_is_rejected() {
11387        let yaml =
11388            "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
11389        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11390        assert!(err.contains("async-with-commit"), "error was: {err}");
11391    }
11392
11393    #[test]
11394    fn hook_config_on_stage_async_is_rejected() {
11395        let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
11396        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11397        assert!(err.contains("on-stage"), "error was: {err}");
11398        assert!(err.contains("blocking"), "error was: {err}");
11399    }
11400
11401    #[test]
11402    fn hook_config_async_none_is_valid() {
11403        let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
11404        let config = parse_hook_config_from_yaml(yaml).unwrap();
11405        assert_eq!(config.execution_mode, HookExecutionMode::Async);
11406        assert_eq!(config.commit_policy, HookCommitPolicy::None);
11407    }
11408
11409    #[test]
11410    fn hook_config_parses_reopen_task_commit_policy() {
11411        let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
11412        let config = parse_hook_config_from_yaml(yaml).unwrap();
11413        assert_eq!(config.binding, HookBinding::OnTaskEnd);
11414        assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
11415    }
11416
11417    #[test]
11418    fn hook_config_unknown_commit_policy_lists_reopen_task() {
11419        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11420        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11421        assert!(err.contains("reopen-task"), "error was: {err}");
11422    }
11423
11424    #[test]
11425    fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
11426        for binding in ["on-shell", "on-tool-call"] {
11427            let yaml =
11428                format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
11429            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11430            assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
11431        }
11432        assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
11433    }
11434
11435    /// `deny` is the one policy an omitted `binding:` cannot carry: a hook that does not name
11436    /// which of the two gated events it decides on would be asked at both.
11437    #[test]
11438    fn hook_config_deny_without_a_binding_is_rejected() {
11439        let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
11440        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11441        assert!(
11442            err.contains("requires an explicit binding"),
11443            "error was: {err}"
11444        );
11445        assert!(err.contains("on-shell"), "error was: {err}");
11446        assert!(err.contains("on-tool-call"), "error was: {err}");
11447    }
11448
11449    /// Every other binding refuses `deny` through the existing binding-mismatch check.
11450    #[test]
11451    fn hook_config_deny_on_an_ungated_binding_is_rejected() {
11452        let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
11453        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11454        assert!(err.contains("is not valid for binding"), "error was: {err}");
11455        assert!(err.contains("replace-context"), "error was: {err}");
11456    }
11457
11458    /// The unchanged async/commit check covers `deny` for free; this pins that it does.
11459    #[test]
11460    fn hook_config_async_deny_is_rejected() {
11461        let yaml =
11462            "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
11463        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11464        assert!(err.contains("async-with-commit"), "error was: {err}");
11465        assert!(err.contains("deny"), "error was: {err}");
11466    }
11467
11468    #[test]
11469    fn hook_config_unknown_commit_policy_lists_deny() {
11470        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11471        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11472        assert!(err.contains("deny"), "error was: {err}");
11473    }
11474
11475    /// The existing, unmodified async/commit validation must also reject
11476    /// `commit_policy: reopen-task` combined with `execution_mode: async`.
11477    #[test]
11478    fn hook_config_async_reopen_task_is_rejected() {
11479        let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
11480        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11481        assert!(err.contains("async-with-commit"), "error was: {err}");
11482        assert!(err.contains("reopen-task"), "error was: {err}");
11483    }
11484
11485    // ── binding is the single source of truth for what a hook commits ─────────────
11486
11487    /// The two bindings whose events honor no `hook-output` arm at all cannot declare
11488    /// any non-`none` `commit_policy`. The error names the binding, the declared policy,
11489    /// and `none` as what the binding honors.
11490    #[test]
11491    fn hook_config_non_committing_binding_rejects_any_commit_policy() {
11492        for binding in ["on-session-start", "on-session-end"] {
11493            let yaml = format!(
11494                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
11495            );
11496            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11497            assert!(err.contains(binding), "error was: {err}");
11498            assert!(err.contains("replace-context"), "error was: {err}");
11499            assert!(
11500                err.contains("honors commit_policy 'none'"),
11501                "error was: {err}"
11502            );
11503        }
11504    }
11505
11506    /// A binding that honors one arm rejects every *other* policy, naming the one it does
11507    /// honor so the fix is obvious from the message alone.
11508    #[test]
11509    fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
11510        for (binding, declared, honored) in [
11511            ("on-stage", "reopen-task", "write-manifests"),
11512            ("on-compaction", "write-manifests", "replace-context"),
11513            ("on-task-end", "replace-context", "reopen-task"),
11514            ("on-task-start", "replace-context", "seed-context"),
11515            ("on-shell", "replace-context", "deny"),
11516            ("on-tool-call", "replace-context", "deny"),
11517        ] {
11518            let yaml = format!(
11519                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
11520            );
11521            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11522            assert!(err.contains(binding), "error was: {err}");
11523            assert!(
11524                err.contains(&format!("commit_policy '{declared}' is not valid")),
11525                "error was: {err}"
11526            );
11527            assert!(
11528                err.contains(&format!("honors commit_policy '{honored}'")),
11529                "error was: {err}"
11530            );
11531        }
11532    }
11533
11534    /// `on-inference` honors the `artifact` arm, which has no `commit_policy` spelling —
11535    /// so *no* non-`none` policy is ever valid for it, including `write-manifests`.
11536    #[test]
11537    fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
11538        for declared in ["replace-context", "write-manifests", "reopen-task"] {
11539            let yaml = format!(
11540                "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
11541            );
11542            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11543            assert!(err.contains("on-inference"), "error was: {err}");
11544            assert!(err.contains(declared), "error was: {err}");
11545            assert!(
11546                err.contains("honors commit_policy 'none'"),
11547                "error was: {err}"
11548            );
11549            assert!(err.contains("artifact"), "error was: {err}");
11550        }
11551    }
11552
11553    /// A binding declaring exactly the policy it honors is accepted, for every
11554    /// representable pair.
11555    #[test]
11556    fn hook_config_matching_binding_and_commit_policy_is_valid() {
11557        for (binding, policy, expected) in [
11558            (
11559                "on-stage",
11560                "write-manifests",
11561                HookCommitPolicy::WriteManifests,
11562            ),
11563            (
11564                "on-compaction",
11565                "replace-context",
11566                HookCommitPolicy::ReplaceContext,
11567            ),
11568            ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
11569            ("on-shell", "deny", HookCommitPolicy::Deny),
11570            ("on-tool-call", "deny", HookCommitPolicy::Deny),
11571        ] {
11572            let yaml =
11573                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
11574            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11575            assert_eq!(config.commit_policy, expected);
11576        }
11577    }
11578
11579    /// Every binding may declare `commit_policy: none` — including the ones that honor
11580    /// an arm, which simply means the hook opts out of committing.
11581    #[test]
11582    fn hook_config_commit_policy_none_is_valid_for_every_binding() {
11583        for binding in [
11584            "on-stage",
11585            "on-session-start",
11586            "on-task-start",
11587            "on-inference",
11588            "on-tool-call",
11589            "on-shell",
11590            "on-compaction",
11591            "on-task-end",
11592            "on-session-end",
11593        ] {
11594            let yaml =
11595                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
11596            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11597            assert_eq!(config.commit_policy, HookCommitPolicy::None);
11598        }
11599    }
11600
11601    /// Resolved ambiguity: an omitted `binding:` is `HookBinding::All`, which is dispatched
11602    /// to every event — including every one that honors an arm — so every `commit_policy` is
11603    /// accepted for it. Deliberate: no narrowing for `All`. `deny` is the one exception,
11604    /// covered by [`hook_config_deny_without_a_binding_is_rejected`].
11605    #[test]
11606    fn hook_config_omitted_binding_accepts_every_commit_policy() {
11607        for (policy, expected) in [
11608            ("replace-context", HookCommitPolicy::ReplaceContext),
11609            ("write-manifests", HookCommitPolicy::WriteManifests),
11610            ("reopen-task", HookCommitPolicy::ReopenTask),
11611            ("none", HookCommitPolicy::None),
11612        ] {
11613            let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
11614            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11615            assert_eq!(config.binding, HookBinding::All);
11616            assert_eq!(config.commit_policy, expected);
11617        }
11618    }
11619
11620    /// Every hook artifact shipped in `default-artifacts` today must keep parsing after the
11621    /// binding/commit_policy check exists — this change is non-breaking for all eight. The
11622    /// field combinations are reproduced literally rather than read from that repo, so this
11623    /// suite stays self-contained.
11624    #[test]
11625    fn hook_config_shipped_default_artifact_manifests_still_parse() {
11626        let shipped: [(&str, &str, HookConfig); 8] = [
11627            (
11628                "murmur-hook-compact",
11629                "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
11630                HookConfig {
11631                    binding: HookBinding::OnCompaction,
11632                    execution_mode: HookExecutionMode::Blocking,
11633                    commit_policy: HookCommitPolicy::ReplaceContext,
11634                },
11635            ),
11636            (
11637                "murmur-hook-shell-desc",
11638                "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
11639                HookConfig {
11640                    binding: HookBinding::OnStage,
11641                    execution_mode: HookExecutionMode::Blocking,
11642                    commit_policy: HookCommitPolicy::WriteManifests,
11643                },
11644            ),
11645            (
11646                // No `binding:` line in the shipped manifest — `All`, not `on-task-end`.
11647                "murmur-hook-regression-verifier",
11648                "execution_mode: blocking\ncommit_policy: reopen-task\n",
11649                HookConfig {
11650                    binding: HookBinding::All,
11651                    execution_mode: HookExecutionMode::Blocking,
11652                    commit_policy: HookCommitPolicy::ReopenTask,
11653                },
11654            ),
11655            (
11656                "murmur-hook-memory",
11657                "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
11658                HookConfig {
11659                    binding: HookBinding::OnTaskStart,
11660                    execution_mode: HookExecutionMode::Blocking,
11661                    commit_policy: HookCommitPolicy::SeedContext,
11662                },
11663            ),
11664            (
11665                "murmur-hook-debug",
11666                "execution_mode: async\ncommit_policy: none\n",
11667                HookConfig {
11668                    binding: HookBinding::All,
11669                    execution_mode: HookExecutionMode::Async,
11670                    commit_policy: HookCommitPolicy::None,
11671                },
11672            ),
11673            (
11674                "murmur-hook-diff-summary",
11675                "execution_mode: blocking\ncommit_policy: none\n",
11676                HookConfig {
11677                    binding: HookBinding::All,
11678                    execution_mode: HookExecutionMode::Blocking,
11679                    commit_policy: HookCommitPolicy::None,
11680                },
11681            ),
11682            ("murmur-hook-eval", "", HookConfig::default()),
11683            ("murmur-hook-grafana", "", HookConfig::default()),
11684        ];
11685
11686        for (name, fields, expected) in shipped {
11687            let yaml = format!("name: {name}\nruntime: hook\n{fields}");
11688            let config = parse_hook_config_from_yaml(&yaml)
11689                .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
11690            assert_eq!(config, expected, "shipped hook {name}");
11691        }
11692    }
11693
11694    /// The manifest-side honored-policy table agrees with the manifest spellings the
11695    /// parser accepts; `capsule-runtime` separately cross-checks it against its own
11696    /// `HONORED_OUTPUT_ARM` dispatch table.
11697    #[test]
11698    fn commit_policy_for_binding_matches_the_declarable_pairs() {
11699        assert_eq!(
11700            commit_policy_for_binding(&HookBinding::OnStage),
11701            Some(HookCommitPolicy::WriteManifests)
11702        );
11703        assert_eq!(
11704            commit_policy_for_binding(&HookBinding::OnCompaction),
11705            Some(HookCommitPolicy::ReplaceContext)
11706        );
11707        assert_eq!(
11708            commit_policy_for_binding(&HookBinding::OnTaskEnd),
11709            Some(HookCommitPolicy::ReopenTask)
11710        );
11711        assert_eq!(
11712            commit_policy_for_binding(&HookBinding::OnTaskStart),
11713            Some(HookCommitPolicy::SeedContext)
11714        );
11715        assert_eq!(
11716            commit_policy_for_binding(&HookBinding::OnToolCall),
11717            Some(HookCommitPolicy::Deny)
11718        );
11719        assert_eq!(
11720            commit_policy_for_binding(&HookBinding::OnShell),
11721            Some(HookCommitPolicy::Deny)
11722        );
11723        // `on-inference` honors `artifact`, which has no `commit_policy` spelling; the
11724        // other two honor nothing. `All` is unconstrained by design, apart from the `deny`
11725        // carve-out the parser applies.
11726        for binding in [
11727            HookBinding::OnSessionStart,
11728            HookBinding::OnInference,
11729            HookBinding::OnSessionEnd,
11730            HookBinding::All,
11731        ] {
11732            assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
11733        }
11734    }
11735
11736    /// The `on-task-start` pairing is declarable end to end: the parser accepts it, and
11737    /// refuses the same policy on any other binding by name.
11738    #[test]
11739    fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
11740        let config = parse_hook_config_from_yaml(
11741            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
11742             commit_policy: seed-context\n",
11743        )
11744        .expect("on-task-start + seed-context is the declarable pairing");
11745        assert_eq!(config.binding, HookBinding::OnTaskStart);
11746        assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
11747
11748        let error = parse_hook_config_from_yaml(
11749            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
11750             commit_policy: seed-context\n",
11751        )
11752        .expect_err("on-compaction cannot commit a seed");
11753        assert!(error.contains("on-compaction"), "{error}");
11754        assert!(error.contains("seed-context"), "{error}");
11755    }
11756
11757    /// The unknown-value diagnostic lists every accepted spelling, so an operator who
11758    /// mistypes one reads the whole set rather than guessing.
11759    #[test]
11760    fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
11761        let error = parse_hook_config_from_yaml(
11762            "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
11763        )
11764        .expect_err("a misspelled policy is rejected");
11765        assert!(error.contains("seed-context"), "{error}");
11766    }
11767
11768    // ── context.seed_budget / context.seed_overflow_margin ───────────────────
11769
11770    fn context_of(yaml_block: &str) -> Option<ContextConfig> {
11771        RuntimeManifest::from_yaml_str(&format!(
11772            "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
11773        ))
11774        .expect("the manifest under test parses")
11775        .context
11776    }
11777
11778    #[test]
11779    fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
11780        let context = context_of(
11781            "context:\n  max_tokens: 200000\n  seed_budget: 0.10\n  seed_overflow_margin: 0.10\n",
11782        )
11783        .expect("a declared context block parses to Some");
11784        assert_eq!(context.max_tokens, Some(200_000));
11785        assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
11786        assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
11787    }
11788
11789    #[test]
11790    fn seed_budget_and_overflow_margin_default_when_omitted() {
11791        let context = context_of("context:\n  max_tokens: 200000\n")
11792            .expect("a declared context block parses to Some");
11793        assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
11794        assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
11795    }
11796
11797    /// No `context:` block at all stays `None` — the two fractions default *within* a
11798    /// declared block, and never conjure one that the operator did not write.
11799    #[test]
11800    fn seed_budget_defaults_do_not_create_a_context_block() {
11801        assert_eq!(context_of(""), None);
11802    }
11803
11804    #[test]
11805    fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
11806        for (block, field) in [
11807            ("context:\n  seed_budget: 1.5\n", "context.seed_budget"),
11808            ("context:\n  seed_budget: -0.1\n", "context.seed_budget"),
11809            (
11810                "context:\n  seed_overflow_margin: 2.0\n",
11811                "context.seed_overflow_margin",
11812            ),
11813        ] {
11814            let error = RuntimeManifest::from_yaml_str(&format!(
11815                "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
11816            ))
11817            .expect_err("an out-of-range fraction is rejected");
11818            match error {
11819                RuntimeManifestError::InvalidInferenceConfig {
11820                    field: got,
11821                    ref message,
11822                } => {
11823                    assert_eq!(got, field);
11824                    assert!(message.contains("0.0 and 1.0"), "{message}");
11825                }
11826                other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
11827            }
11828        }
11829    }
11830
11831    #[test]
11832    fn deploy_block_mur_version_parses() {
11833        let manifest = RuntimeManifest::from_yaml_str(
11834            "name: cap\nversion: 0.1.0\nartifacts: []\ndeploy:\n  mur_version: \"0.4.5\"\n",
11835        )
11836        .unwrap();
11837        assert_eq!(manifest.deploy_mur_version(), Some("0.4.5"));
11838        assert_eq!(
11839            manifest.deploy,
11840            Some(DeployConfig {
11841                mur_version: Some("0.4.5".to_string())
11842            })
11843        );
11844    }
11845
11846    #[test]
11847    fn deploy_block_absent_is_none() {
11848        let manifest =
11849            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
11850        assert_eq!(manifest.deploy, None);
11851        assert_eq!(manifest.deploy_mur_version(), None);
11852    }
11853
11854    /// A blank pin and an empty block both mean "install the deploying mur's own version".
11855    #[test]
11856    fn deploy_block_blank_mur_version_is_none() {
11857        for deploy in [
11858            "deploy:\n  mur_version: \"\"\n",
11859            "deploy:\n  mur_version: \"  \"\n",
11860        ] {
11861            let manifest = RuntimeManifest::from_yaml_str(&format!(
11862                "name: cap\nversion: 0.1.0\nartifacts: []\n{deploy}"
11863            ))
11864            .unwrap();
11865            assert_eq!(manifest.deploy_mur_version(), None, "{deploy:?}");
11866        }
11867        let manifest =
11868            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\ndeploy: {}\n").unwrap();
11869        assert_eq!(manifest.deploy, Some(DeployConfig::default()));
11870        assert_eq!(manifest.deploy_mur_version(), None);
11871    }
11872
11873    #[test]
11874    fn deploy_block_misspelled_key_is_reported_under_deploy() {
11875        assert_eq!(
11876            unknown_keys_of("name: cap\nversion: 0.1.0\ndeploy:\n  mur_versoin: \"0.4.5\"\n"),
11877            vec![(
11878                "mur_versoin".to_string(),
11879                "deploy".to_string(),
11880                Some("mur_version".to_string())
11881            )]
11882        );
11883    }
11884
11885    #[test]
11886    fn a_top_level_mur_version_is_refused_naming_deploy_mur_version() {
11887        for pin in [
11888            "mur_version: \"0.4.5\"\n",
11889            "mur_version: 0.4.0\n",
11890            "mur_version: \"\"\n",
11891            "mur_version:\n",
11892        ] {
11893            let error = RuntimeManifest::from_yaml_str(&format!(
11894                "name: cap\nversion: 0.1.0\nartifacts: []\n{pin}"
11895            ))
11896            .expect_err(pin);
11897            assert!(
11898                matches!(error, RuntimeManifestError::TopLevelMurVersion),
11899                "{pin:?}: {error:?}"
11900            );
11901            assert!(error.to_string().contains("deploy.mur_version"), "{error}");
11902        }
11903        assert_eq!(
11904            RuntimeManifestError::TopLevelMurVersion.to_string(),
11905            "murmur.yaml: 'mur_version' is not a top-level key — the mur release a deploy \
11906             installs is pinned as 'deploy.mur_version'"
11907        );
11908    }
11909
11910    #[test]
11911    fn a_top_level_mur_version_is_refused_beside_a_deploy_block() {
11912        let yaml = "name: cap\nversion: 0.1.0\nmur_version: \"0.4.5\"\ndeploy:\n  mur_version: \"0.4.5\"\n";
11913        assert!(matches!(
11914            RuntimeManifest::from_yaml_str(yaml),
11915            Err(RuntimeManifestError::TopLevelMurVersion)
11916        ));
11917        assert!(matches!(
11918            RuntimeManifest::from_yaml_str_without_secrets(yaml),
11919            Err(RuntimeManifestError::TopLevelMurVersion)
11920        ));
11921    }
11922
11923    #[test]
11924    fn inference_max_turns_absent_sets_no_cap() {
11925        let manifest = RuntimeManifest::from_yaml_str(
11926            "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",
11927        ).unwrap();
11928        assert_eq!(manifest.inference.unwrap().max_turns, None);
11929    }
11930
11931    #[test]
11932    fn inference_max_turns_explicit_value() {
11933        let manifest = RuntimeManifest::from_yaml_str(
11934            "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",
11935        ).unwrap();
11936        assert_eq!(manifest.inference.unwrap().max_turns, Some(20));
11937    }
11938
11939    #[test]
11940    fn inference_max_turns_zero_is_rejected() {
11941        let err = RuntimeManifest::from_yaml_str(
11942            "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",
11943        ).unwrap_err();
11944        let msg = err.to_string();
11945        assert!(msg.contains("inference.max_turns"), "error was: {msg}");
11946        assert!(msg.contains("greater than 0"), "error was: {msg}");
11947    }
11948
11949    /// A `lifecycle:` block that says nothing about the budget still permits one reopen.
11950    #[test]
11951    fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
11952        let manifest = RuntimeManifest::from_yaml_str(
11953            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 45\n",
11954        )
11955        .unwrap();
11956        let lifecycle = manifest.lifecycle.unwrap();
11957        assert_eq!(lifecycle.shell_grace_secs, Some(45));
11958        // Every other lifecycle field is untouched by declaring this one.
11959        assert_eq!(
11960            lifecycle.task_acceptance,
11961            LifecycleConfig::default().task_acceptance
11962        );
11963        assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
11964        assert_eq!(
11965            lifecycle.queue_depth,
11966            LifecycleConfig::default().queue_depth
11967        );
11968        assert_eq!(lifecycle.input_timeout_secs, None);
11969        assert_eq!(
11970            lifecycle.conversation_mode,
11971            LifecycleConfig::default().conversation_mode
11972        );
11973        assert_eq!(
11974            lifecycle.max_task_reopens,
11975            LifecycleConfig::default().max_task_reopens
11976        );
11977    }
11978
11979    /// An undeclared grace is no grace at all, whether the `lifecycle:` block is there or not.
11980    #[test]
11981    fn lifecycle_shell_grace_secs_is_unset_when_not_declared() {
11982        let manifest = RuntimeManifest::from_yaml_str(
11983            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
11984        )
11985        .unwrap();
11986        let lifecycle = manifest.lifecycle.unwrap();
11987        assert_eq!(lifecycle.shell_grace_secs, None);
11988        assert_eq!(lifecycle.queue_depth, 3);
11989
11990        let without_block =
11991            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11992        assert_eq!(without_block.effective_lifecycle().shell_grace_secs, None);
11993        assert_eq!(LifecycleConfig::default().shell_grace_secs, None);
11994    }
11995
11996    /// The bound a capsule delegates under is declared, and a capsule that declares nothing is
11997    /// still bounded: absent means a ceiling, never "wait forever".
11998    #[test]
11999    fn lifecycle_delegation_deadline_secs_defaults_to_600() {
12000        let without_block =
12001            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12002        assert_eq!(
12003            without_block.effective_lifecycle().delegation_deadline_secs,
12004            600
12005        );
12006
12007        let without_key = RuntimeManifest::from_yaml_str(
12008            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12009        )
12010        .unwrap();
12011        let lifecycle = without_key.lifecycle.unwrap();
12012        assert_eq!(lifecycle.delegation_deadline_secs, 600);
12013        assert_eq!(lifecycle.queue_depth, 3);
12014    }
12015
12016    #[test]
12017    fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
12018        let manifest = RuntimeManifest::from_yaml_str(
12019            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
12020             delegation_deadline_secs: 30\n",
12021        )
12022        .unwrap();
12023        let lifecycle = manifest.lifecycle.unwrap();
12024        assert_eq!(lifecycle.delegation_deadline_secs, 30);
12025        // Every other lifecycle field is untouched by declaring this one.
12026        assert_eq!(
12027            lifecycle.shell_grace_secs,
12028            LifecycleConfig::default().shell_grace_secs
12029        );
12030        assert_eq!(
12031            lifecycle.max_task_reopens,
12032            LifecycleConfig::default().max_task_reopens
12033        );
12034    }
12035
12036    /// Declaring the key produces no unknown-key report, and a misspelling of it still does.
12037    #[test]
12038    fn lifecycle_delegation_deadline_secs_is_a_known_key() {
12039        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
12040        let manifest = RuntimeManifest::from_yaml_str(
12041            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
12042             delegation_deadline_secs: 30\n  delegation_deadline: 30\n",
12043        )
12044        .unwrap();
12045        let reported: Vec<(String, String)> = manifest
12046            .unknown_keys
12047            .iter()
12048            .map(|key| (key.key.clone(), key.block_path.clone()))
12049            .collect();
12050        assert_eq!(
12051            reported,
12052            vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
12053            "the declared key is known and only the misspelt neighbour is reported"
12054        );
12055        assert_eq!(
12056            manifest.lifecycle.unwrap().delegation_deadline_secs,
12057            30,
12058            "the misspelt neighbour does not disturb the declared value"
12059        );
12060    }
12061
12062    /// The deadline notice's margin is optional: absent leaves the runtime's default in force.
12063    #[test]
12064    fn lifecycle_deadline_notice_secs_defaults_to_none() {
12065        let without_block =
12066            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12067        assert_eq!(
12068            without_block.effective_lifecycle().deadline_notice_secs,
12069            None
12070        );
12071        let without_key = RuntimeManifest::from_yaml_str(
12072            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12073        )
12074        .unwrap();
12075        assert_eq!(without_key.lifecycle.unwrap().deadline_notice_secs, None);
12076    }
12077
12078    /// `0` and a positive value are both taken as declared: `0` is "send no notice", not absent.
12079    #[test]
12080    fn lifecycle_deadline_notice_secs_takes_zero_and_positive_values() {
12081        for (declared, expected) in [("0", 0), ("90", 90)] {
12082            let manifest = RuntimeManifest::from_yaml_str(&format!(
12083                "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
12084                 deadline_notice_secs: {declared}\n"
12085            ))
12086            .unwrap();
12087            assert!(manifest.unknown_keys.is_empty());
12088            assert_eq!(
12089                manifest.lifecycle.unwrap().deadline_notice_secs,
12090                Some(expected)
12091            );
12092        }
12093    }
12094
12095    #[test]
12096    fn lifecycle_deadline_notice_secs_refuses_a_negative_or_non_integer_value() {
12097        for declared in ["-5", "1.5", "soon"] {
12098            let refused = RuntimeManifest::from_yaml_str(&format!(
12099                "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
12100                 deadline_notice_secs: {declared}\n"
12101            ));
12102            assert!(refused.is_err(), "{declared} is refused: {refused:?}");
12103        }
12104    }
12105
12106    #[test]
12107    fn lifecycle_deadline_notice_secs_is_a_known_key() {
12108        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"deadline_notice_secs"));
12109    }
12110
12111    fn lifecycle_with_keep_working(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
12112        RuntimeManifest::from_yaml_str(&format!(
12113            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  keep_working:\n{block}"
12114        ))
12115    }
12116
12117    fn refused_keep_working_field(block: &str) -> (String, String) {
12118        match lifecycle_with_keep_working(block) {
12119            Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
12120                (field, message)
12121            }
12122            other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
12123        }
12124    }
12125
12126    #[test]
12127    fn lifecycle_keep_working_is_off_when_absent() {
12128        let without_block =
12129            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12130        assert_eq!(without_block.effective_lifecycle().keep_working, None);
12131        let without_key = RuntimeManifest::from_yaml_str(
12132            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12133        )
12134        .unwrap();
12135        assert_eq!(without_key.lifecycle.unwrap().keep_working, None);
12136    }
12137
12138    #[test]
12139    fn lifecycle_keep_working_takes_the_declared_message_and_limit() {
12140        let manifest =
12141            lifecycle_with_keep_working("    message: \"go on\"\n    limit: 3\n").unwrap();
12142        assert!(
12143            manifest.unknown_keys.is_empty(),
12144            "{:?}",
12145            manifest.unknown_keys
12146        );
12147        assert_eq!(
12148            manifest.lifecycle.unwrap().keep_working,
12149            Some(KeepWorking {
12150                message: "go on".into(),
12151                limit: 3
12152            })
12153        );
12154    }
12155
12156    /// The blank check trims; the message the runtime sends is the operator's bytes as written.
12157    #[test]
12158    fn lifecycle_keep_working_keeps_the_message_as_written() {
12159        let manifest =
12160            lifecycle_with_keep_working("    message: \"  go on \\n\"\n    limit: 1\n").unwrap();
12161        assert_eq!(
12162            manifest.lifecycle.unwrap().keep_working.unwrap().message,
12163            "  go on \n"
12164        );
12165    }
12166
12167    #[test]
12168    fn lifecycle_keep_working_refuses_a_missing_or_blank_message() {
12169        for block in [
12170            "    limit: 3\n",
12171            "    message: \"\"\n    limit: 3\n",
12172            "    message: \"  \\n\\t \"\n    limit: 3\n",
12173        ] {
12174            let (field, _) = refused_keep_working_field(block);
12175            assert_eq!(field, "lifecycle.keep_working.message", "{block:?}");
12176        }
12177    }
12178
12179    #[test]
12180    fn lifecycle_keep_working_refuses_a_missing_limit() {
12181        let (field, message) = refused_keep_working_field("    message: go on\n");
12182        assert_eq!(field, "lifecycle.keep_working.limit");
12183        assert_eq!(message, "is required");
12184    }
12185
12186    #[test]
12187    fn lifecycle_keep_working_refuses_a_zero_limit_and_says_how_to_turn_it_off() {
12188        let (field, message) = refused_keep_working_field("    message: go on\n    limit: 0\n");
12189        assert_eq!(field, "lifecycle.keep_working.limit");
12190        assert!(
12191            message.contains("omit lifecycle.keep_working"),
12192            "the refusal says how to turn the option off: {message}"
12193        );
12194    }
12195
12196    #[test]
12197    fn lifecycle_keep_working_reports_an_unknown_key_inside_the_block() {
12198        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"keep_working"));
12199        let manifest =
12200            lifecycle_with_keep_working("    message: go on\n    limit: 2\n    lmit: 3\n").unwrap();
12201        let reported: Vec<(String, String)> = manifest
12202            .unknown_keys
12203            .iter()
12204            .map(|key| (key.key.clone(), key.block_path.clone()))
12205            .collect();
12206        assert_eq!(
12207            reported,
12208            vec![("lmit".to_string(), "lifecycle.keep_working".to_string())]
12209        );
12210    }
12211
12212    fn lifecycle_with_done_check(
12213        allow: &str,
12214        block: &str,
12215    ) -> Result<RuntimeManifest, RuntimeManifestError> {
12216        RuntimeManifest::from_yaml_str(&format!(
12217            "name: cap\nversion: 0.0.1\nartifacts: []\n{allow}lifecycle:\n  done_check:\n{block}"
12218        ))
12219    }
12220
12221    const ALLOW_BASH: &str = "capabilities:\n  shell:\n    allow: [bash]\n";
12222
12223    fn refused_done_check_field(allow: &str, block: &str) -> (String, String) {
12224        match lifecycle_with_done_check(allow, block) {
12225            Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
12226                (field, message)
12227            }
12228            other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
12229        }
12230    }
12231
12232    #[test]
12233    fn lifecycle_done_check_is_off_when_absent() {
12234        let without_block =
12235            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12236        assert_eq!(without_block.effective_lifecycle().done_check, None);
12237        let without_key = RuntimeManifest::from_yaml_str(
12238            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12239        )
12240        .unwrap();
12241        assert_eq!(without_key.lifecycle.unwrap().done_check, None);
12242    }
12243
12244    #[test]
12245    fn lifecycle_done_check_takes_the_command_and_limit_with_the_default_timeout() {
12246        let manifest = lifecycle_with_done_check(
12247            ALLOW_BASH,
12248            "    command: \"bash -c 'true'\"\n    limit: 2\n",
12249        )
12250        .unwrap();
12251        assert!(
12252            manifest.unknown_keys.is_empty(),
12253            "{:?}",
12254            manifest.unknown_keys
12255        );
12256        let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
12257        assert_eq!(
12258            done_check,
12259            DoneCheck {
12260                command: "bash -c 'true'".into(),
12261                limit: 2,
12262                timeout_secs: 600,
12263            }
12264        );
12265        assert_eq!(done_check.argv(), ["bash", "-c", "true"]);
12266    }
12267
12268    /// `0` observes without reopening; an explicit timeout is kept.
12269    #[test]
12270    fn lifecycle_done_check_accepts_a_zero_limit_and_an_explicit_timeout() {
12271        let manifest = lifecycle_with_done_check(
12272            ALLOW_BASH,
12273            "    command: bash -c 'exit 1'\n    limit: 0\n    timeout_secs: 5\n",
12274        )
12275        .unwrap();
12276        let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
12277        assert_eq!((done_check.limit, done_check.timeout_secs), (0, 5));
12278    }
12279
12280    #[test]
12281    fn lifecycle_done_check_refuses_each_invalid_field() {
12282        for (allow, block, field, message) in [
12283            (ALLOW_BASH, "    limit: 1\n", "command", "is required"),
12284            (
12285                ALLOW_BASH,
12286                "    command: \"  \"\n    limit: 1\n",
12287                "command",
12288                "must not be blank",
12289            ),
12290            (
12291                ALLOW_BASH,
12292                "    command: \"''\"\n    limit: 1\n",
12293                "command",
12294                "must name a program",
12295            ),
12296            (
12297                ALLOW_BASH,
12298                "    command: cargo test\n    limit: 1\n",
12299                "command",
12300                "runs 'cargo', which is not in capabilities.shell.allow",
12301            ),
12302            (
12303                "",
12304                "    command: bash -c true\n    limit: 1\n",
12305                "command",
12306                "runs 'bash', which is not in capabilities.shell.allow",
12307            ),
12308            (
12309                ALLOW_BASH,
12310                "    command: bash -c true\n",
12311                "limit",
12312                "is required",
12313            ),
12314            (
12315                ALLOW_BASH,
12316                "    command: bash -c true\n    limit: 1\n    timeout_secs: 0\n",
12317                "timeout_secs",
12318                "must be at least 1",
12319            ),
12320        ] {
12321            assert_eq!(
12322                refused_done_check_field(allow, block),
12323                (format!("lifecycle.done_check.{field}"), message.to_string()),
12324                "{block:?}"
12325            );
12326        }
12327    }
12328
12329    #[test]
12330    fn lifecycle_done_check_reports_an_unknown_key_inside_the_block() {
12331        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"done_check"));
12332        let manifest = lifecycle_with_done_check(
12333            ALLOW_BASH,
12334            "    command: bash -c true\n    limit: 2\n    limt: 3\n",
12335        )
12336        .unwrap();
12337        let reported: Vec<(String, String)> = manifest
12338            .unknown_keys
12339            .iter()
12340            .map(|key| (key.key.clone(), key.block_path.clone()))
12341            .collect();
12342        assert_eq!(
12343            reported,
12344            vec![("limt".to_string(), "lifecycle.done_check".to_string())]
12345        );
12346    }
12347
12348    /// `0` is a valid explicit value, not an absent one: it demotes at the first poll after the
12349    /// spawn, so effectively every command detaches.
12350    #[test]
12351    fn lifecycle_shell_grace_secs_zero_is_accepted() {
12352        let manifest = RuntimeManifest::from_yaml_str(
12353            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 0\n",
12354        )
12355        .unwrap();
12356        let lifecycle = manifest.lifecycle.unwrap();
12357        assert_eq!(lifecycle.shell_grace_secs, Some(0));
12358        assert_eq!(
12359            lifecycle.max_task_reopens,
12360            LifecycleConfig::default().max_task_reopens
12361        );
12362    }
12363
12364    #[test]
12365    fn lifecycle_max_task_reopens_defaults_to_1() {
12366        let manifest = RuntimeManifest::from_yaml_str(
12367            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  after_task: sleep\n",
12368        )
12369        .unwrap();
12370        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
12371    }
12372
12373    /// No `lifecycle:` block at all lands on the same default through `effective_lifecycle`.
12374    #[test]
12375    fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
12376        let manifest =
12377            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12378        assert!(manifest.lifecycle.is_none());
12379        assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
12380    }
12381
12382    #[test]
12383    fn lifecycle_max_task_reopens_explicit_value() {
12384        let manifest = RuntimeManifest::from_yaml_str(
12385            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 3\n",
12386        )
12387        .unwrap();
12388        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
12389    }
12390
12391    /// Unlike `inference.max_turns`, `0` is a valid explicit value — it disables reopening.
12392    #[test]
12393    fn lifecycle_max_task_reopens_zero_is_accepted() {
12394        let manifest = RuntimeManifest::from_yaml_str(
12395            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 0\n",
12396        )
12397        .unwrap();
12398        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
12399    }
12400
12401    /// The budget is independent of the inference transport in play.
12402    #[test]
12403    fn lifecycle_max_task_reopens_with_process_transport() {
12404        let manifest = RuntimeManifest::from_yaml_str(
12405            "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",
12406        ).unwrap();
12407        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
12408    }
12409
12410    #[test]
12411    fn inference_max_task_reopens_is_rejected_under_http_transport() {
12412        let err = RuntimeManifest::from_yaml_str(
12413            "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",
12414        ).unwrap_err();
12415        assert!(
12416            matches!(
12417                &err,
12418                RuntimeManifestError::InvalidInferenceConfig { field, .. }
12419                    if field == "inference.max_task_reopens"
12420            ),
12421            "error was: {err:?}"
12422        );
12423        let msg = err.to_string();
12424        assert!(
12425            msg.contains("lifecycle.max_task_reopens"),
12426            "error was: {msg}"
12427        );
12428    }
12429
12430    #[test]
12431    fn inference_max_task_reopens_is_rejected_under_process_transport() {
12432        let err = RuntimeManifest::from_yaml_str(
12433            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  max_task_reopens: 2\n",
12434        ).unwrap_err();
12435        assert!(
12436            matches!(
12437                &err,
12438                RuntimeManifestError::InvalidInferenceConfig { field, .. }
12439                    if field == "inference.max_task_reopens"
12440            ),
12441            "error was: {err:?}"
12442        );
12443        let msg = err.to_string();
12444        assert!(
12445            msg.contains("lifecycle.max_task_reopens"),
12446            "error was: {msg}"
12447        );
12448    }
12449
12450    /// The rejection is unconditional: the new key being present alongside it changes nothing.
12451    #[test]
12452    fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
12453        let err = RuntimeManifest::from_yaml_str(
12454            "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",
12455        ).unwrap_err();
12456        assert!(
12457            matches!(
12458                &err,
12459                RuntimeManifestError::InvalidInferenceConfig { field, .. }
12460                    if field == "inference.max_task_reopens"
12461            ),
12462            "error was: {err:?}"
12463        );
12464    }
12465
12466    #[test]
12467    fn skill_is_llm_visible() {
12468        assert!(ArtifactRuntime::Skill.is_llm_visible());
12469    }
12470
12471    #[test]
12472    fn driver_and_hook_are_not_llm_visible() {
12473        assert!(!ArtifactRuntime::Driver.is_llm_visible());
12474        assert!(!ArtifactRuntime::Hook.is_llm_visible());
12475    }
12476
12477    #[test]
12478    fn parses_system_prompt_artifact() {
12479        let manifest = RuntimeManifest::from_yaml_str(
12480            r#"
12481name: cap
12482version: 0.0.1
12483artifacts:
12484  - name: local/my-skill
12485    version: 1.0.0
12486    runtime: skill
12487  - name: murmur/driver-anthropic
12488    version: 0.1.0
12489    runtime: driver
12490    gateway:
12491      endpoint: http://127.0.0.1:8080
12492      keyless: true
12493inference:
12494  model: test-model
12495  system_prompt_artifact: local/my-skill
12496  driver:
12497    artifact: murmur/driver-anthropic
12498"#,
12499        )
12500        .unwrap();
12501
12502        let inference = manifest.inference.unwrap();
12503        assert_eq!(
12504            inference.system_prompt_artifact,
12505            Some("local/my-skill".to_string())
12506        );
12507        assert!(inference.system_prompt.is_none());
12508        assert!(inference.system_prompt_file.is_none());
12509    }
12510
12511    #[test]
12512    fn rejects_system_prompt_artifact_with_inline_prompt() {
12513        let err = RuntimeManifest::from_yaml_str(
12514            r#"
12515name: cap
12516version: 0.0.1
12517artifacts:
12518  - name: local/my-skill
12519    version: 1.0.0
12520    runtime: skill
12521  - name: murmur/driver-anthropic
12522    version: 0.1.0
12523    runtime: driver
12524    gateway:
12525      endpoint: http://127.0.0.1:8080
12526      keyless: true
12527inference:
12528  model: test-model
12529  system_prompt: "inline"
12530  system_prompt_artifact: local/my-skill
12531  driver:
12532    artifact: murmur/driver-anthropic
12533"#,
12534        )
12535        .unwrap_err();
12536
12537        let msg = err.to_string();
12538        assert!(msg.contains("at most one"), "error was: {msg}");
12539    }
12540
12541    #[test]
12542    fn rejects_system_prompt_artifact_naming_non_skill() {
12543        let err = RuntimeManifest::from_yaml_str(
12544            r#"
12545name: cap
12546version: 0.0.1
12547artifacts:
12548  - name: local/my-tool
12549    version: 1.0.0
12550    runtime: tool
12551  - name: murmur/driver-anthropic
12552    version: 0.1.0
12553    runtime: driver
12554    gateway:
12555      endpoint: http://127.0.0.1:8080
12556      keyless: true
12557inference:
12558  model: test-model
12559  system_prompt_artifact: local/my-tool
12560  driver:
12561    artifact: murmur/driver-anthropic
12562"#,
12563        )
12564        .unwrap_err();
12565
12566        let msg = err.to_string();
12567        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12568        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
12569    }
12570
12571    #[test]
12572    fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
12573        let err = RuntimeManifest::from_yaml_str(
12574            r#"
12575name: cap
12576version: 0.0.1
12577artifacts:
12578  - name: murmur/driver-anthropic
12579    version: 0.1.0
12580    runtime: driver
12581    gateway:
12582      endpoint: http://127.0.0.1:8080
12583      keyless: true
12584inference:
12585  model: test-model
12586  system_prompt_artifact: missing-skill
12587  driver:
12588    artifact: murmur/driver-anthropic
12589"#,
12590        )
12591        .unwrap_err();
12592
12593        let msg = err.to_string();
12594        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12595        assert!(msg.contains("not declared"), "error was: {msg}");
12596    }
12597
12598    #[test]
12599    fn rejects_all_three_system_prompt_sources() {
12600        let err = RuntimeManifest::from_yaml_str(
12601            r#"
12602name: cap
12603version: 0.0.1
12604artifacts:
12605  - name: local/my-skill
12606    version: 1.0.0
12607    runtime: skill
12608  - name: murmur/driver-anthropic
12609    version: 0.1.0
12610    runtime: driver
12611    gateway:
12612      endpoint: http://127.0.0.1:8080
12613      keyless: true
12614inference:
12615  model: test-model
12616  system_prompt: "inline"
12617  system_prompt_file: "conventions.md"
12618  system_prompt_artifact: local/my-skill
12619  driver:
12620    artifact: murmur/driver-anthropic
12621"#,
12622        )
12623        .unwrap_err();
12624
12625        let msg = err.to_string();
12626        assert!(msg.contains("at most one"), "error was: {msg}");
12627    }
12628
12629    // ── Per-artifact capability grants ───────────────────────────────────────
12630
12631    #[test]
12632    fn hook_artifact_capabilities_parse_network_and_filesystem() {
12633        let manifest = RuntimeManifest::from_yaml_str(
12634            r#"
12635name: cap
12636version: 0.0.1
12637artifacts:
12638  - name: local/telemetry-hook
12639    version: 1.0.0
12640    runtime: hook
12641    capabilities:
12642      network:
12643        allow:
12644          - https://telemetry.example.com
12645      filesystem:
12646        scope: hook-state
12647"#,
12648        )
12649        .unwrap();
12650
12651        let caps = manifest.artifacts[0]
12652            .capabilities
12653            .as_ref()
12654            .expect("hook entry carries a capability grant");
12655        assert_eq!(
12656            caps.network.as_ref().unwrap().allow,
12657            vec!["https://telemetry.example.com".to_string()]
12658        );
12659        assert_eq!(
12660            caps.filesystem.as_ref().unwrap().scope,
12661            Some("hook-state".to_string())
12662        );
12663    }
12664
12665    /// Default-deny: a hook entry with no `capabilities:` key parses to `None`, which the
12666    /// runtime lowers to zero network and zero preopened directories.
12667    #[test]
12668    fn hook_artifact_without_capabilities_is_none() {
12669        let manifest = RuntimeManifest::from_yaml_str(
12670            r#"
12671name: cap
12672version: 0.0.1
12673artifacts:
12674  - name: local/quiet-hook
12675    version: 1.0.0
12676    runtime: hook
12677"#,
12678        )
12679        .unwrap();
12680
12681        assert!(manifest.artifacts[0].capabilities.is_none());
12682    }
12683
12684    /// Tools and drivers execute, so they may carry a grant — the runtime reads it as a
12685    /// narrowing of the capsule-wide ceiling rather than as a default-deny baseline.
12686    #[test]
12687    fn tool_and_driver_artifact_capabilities_parse() {
12688        for role in ["tool", "driver"] {
12689            let yaml = format!(
12690                r#"
12691name: cap
12692version: 0.0.1
12693artifacts:
12694  - name: local/scoped
12695    version: 1.0.0
12696    runtime: {role}
12697    capabilities:
12698      network:
12699        allow:
12700          - https://api.example.com
12701      filesystem:
12702        scope: cache
12703"#
12704            );
12705            let manifest = RuntimeManifest::from_yaml_str(&yaml)
12706                .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
12707
12708            let caps = manifest.artifacts[0]
12709                .capabilities
12710                .as_ref()
12711                .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
12712            assert_eq!(
12713                caps.network.as_ref().unwrap().allow,
12714                vec!["https://api.example.com".to_string()]
12715            );
12716            assert_eq!(
12717                caps.filesystem.as_ref().unwrap().scope,
12718                Some("cache".to_string())
12719            );
12720        }
12721    }
12722
12723    /// A tool/driver entry with no `capabilities:` key parses to `None`, which the runtime
12724    /// lowers to the unchanged capsule ceiling.
12725    #[test]
12726    fn tool_artifact_without_capabilities_is_none() {
12727        let manifest = RuntimeManifest::from_yaml_str(
12728            r#"
12729name: cap
12730version: 0.0.1
12731artifacts:
12732  - name: local/plain-tool
12733    version: 1.0.0
12734    runtime: tool
12735"#,
12736        )
12737        .unwrap();
12738
12739        assert!(manifest.artifacts[0].capabilities.is_none());
12740    }
12741
12742    /// A skill has no execution surface, so a grant on one would be silently unenforced —
12743    /// rejected at parse time, with a message naming the roles that do work.
12744    #[test]
12745    fn skill_artifact_capabilities_are_rejected() {
12746        let yaml = r#"
12747name: cap
12748version: 0.0.1
12749artifacts:
12750  - name: local/sneaky
12751    version: 1.0.0
12752    runtime: skill
12753    capabilities:
12754      network:
12755        allow:
12756          - https://evil.example.com
12757"#;
12758        let err = match RuntimeManifest::from_yaml_str(yaml) {
12759            Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
12760            Err(err) => err,
12761        };
12762        let msg = err.to_string();
12763        assert!(msg.contains("sneaky"), "error was: {msg}");
12764        assert!(
12765            msg.contains(
12766                "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
12767                 entries"
12768            ),
12769            "error was: {msg}"
12770        );
12771        assert!(msg.contains("runtime: skill"), "error was: {msg}");
12772    }
12773
12774    // ── Per-artifact `config:` (operator-authored artifact configuration) ────
12775
12776    /// The block is carried verbatim as YAML. This crate accepts any mapping; the runtime is
12777    /// what lowers it to JSON and refuses a shape that cannot travel that way.
12778    #[test]
12779    fn artifact_config_is_carried_verbatim() {
12780        let manifest = RuntimeManifest::from_yaml_str(
12781            r#"
12782name: cap
12783version: 0.0.1
12784artifacts:
12785  - name: murmur/tool-corpus
12786    version: 0.1.0
12787    runtime: tool
12788    config:
12789      read_recent: { default: 20, max: 100 }
12790"#,
12791        )
12792        .unwrap();
12793
12794        let config = manifest.artifacts[0]
12795            .config
12796            .as_ref()
12797            .expect("a declared block parses");
12798        assert_eq!(
12799            config["read_recent"]["max"],
12800            serde_norway::Value::Number(100.into())
12801        );
12802    }
12803
12804    /// Absent means absent: no key, no variable, and nothing for the runtime to lower.
12805    #[test]
12806    fn an_undeclared_config_is_none() {
12807        let manifest = RuntimeManifest::from_yaml_str(
12808            r#"
12809name: cap
12810version: 0.0.1
12811artifacts:
12812  - name: local/git
12813    version: 1.0.0
12814    runtime: tool
12815"#,
12816        )
12817        .unwrap();
12818
12819        assert!(manifest.artifacts[0].config.is_none());
12820    }
12821
12822    /// `config:` with nothing under it is a written declaration that carries nothing, and must
12823    /// stay distinguishable from the key being absent — the runtime refuses it, and can only do
12824    /// so if the parser does not collapse YAML null into `None`.
12825    #[test]
12826    fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
12827        let manifest = RuntimeManifest::from_yaml_str(
12828            r#"
12829name: cap
12830version: 0.0.1
12831artifacts:
12832  - name: local/git
12833    version: 1.0.0
12834    runtime: tool
12835    config:
12836"#,
12837        )
12838        .unwrap();
12839
12840        assert_eq!(
12841            manifest.artifacts[0].config,
12842            Some(serde_norway::Value::Null),
12843            "an empty block must survive parsing as a declaration, not vanish into None"
12844        );
12845    }
12846
12847    /// A skill runs no component and holds no grant, so nothing would ever deliver a config
12848    /// block to one — rejected at parse time, with a message naming the roles that do.
12849    #[test]
12850    fn skill_artifact_config_is_rejected() {
12851        let yaml = r#"
12852name: cap
12853version: 0.0.1
12854artifacts:
12855  - name: local/sneaky
12856    version: 1.0.0
12857    runtime: skill
12858    config:
12859      who: me
12860"#;
12861        let err = match RuntimeManifest::from_yaml_str(yaml) {
12862            Ok(_) => panic!("config: must be rejected for runtime: skill"),
12863            Err(err) => err,
12864        };
12865        assert!(matches!(
12866            err,
12867            RuntimeManifestError::InvalidArtifact { index: 0, .. }
12868        ));
12869        let msg = err.to_string();
12870        assert!(msg.contains("sneaky"), "error was: {msg}");
12871        assert!(msg.contains("runtime: skill"), "error was: {msg}");
12872        assert!(
12873            msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
12874            "error was: {msg}"
12875        );
12876    }
12877
12878    /// Config is delivered on one artifact's own grant, so a capsule-wide block reaches nothing.
12879    /// Refused rather than silently ignored, and the message points at the artifact entry.
12880    #[test]
12881    fn a_capsule_wide_config_block_is_rejected() {
12882        let yaml = r#"
12883name: cap
12884version: 0.0.1
12885config:
12886  who: capsule
12887artifacts:
12888  - name: local/git
12889    version: 1.0.0
12890    runtime: tool
12891"#;
12892        let err = match RuntimeManifest::from_yaml_str(yaml) {
12893            Ok(_) => panic!("a capsule-wide config block must be rejected"),
12894            Err(err) => err,
12895        };
12896        let msg = err.to_string();
12897        assert!(msg.contains("config"), "error was: {msg}");
12898        assert!(msg.contains("declared per artifact"), "error was: {msg}");
12899    }
12900
12901    // ── Per-artifact `on_overflow:` (async hook queue policy) ────────────────
12902
12903    /// `on_overflow: block` on a hook entry parses to the blocking policy.
12904    #[test]
12905    fn hook_on_overflow_block_parses() {
12906        let manifest = RuntimeManifest::from_yaml_str(
12907            r#"
12908name: cap
12909version: 0.0.1
12910artifacts:
12911  - name: murmur/hook-grafana
12912    version: 1.0.0
12913    runtime: hook
12914    on_overflow: block
12915"#,
12916        )
12917        .unwrap();
12918
12919        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
12920    }
12921
12922    /// Omitting the key means `drop`: an operator who says nothing gets the loop kept moving,
12923    /// not backpressure they never asked for.
12924    #[test]
12925    fn hook_on_overflow_defaults_to_drop() {
12926        let manifest = RuntimeManifest::from_yaml_str(
12927            r#"
12928name: cap
12929version: 0.0.1
12930artifacts:
12931  - name: murmur/hook-grafana
12932    version: 1.0.0
12933    runtime: hook
12934"#,
12935        )
12936        .unwrap();
12937
12938        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
12939        assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
12940    }
12941
12942    /// Only a hook has a job queue, so the key is rejected on every other role rather than
12943    /// silently ignored — the same rule per-artifact `capabilities:` follows on a skill.
12944    #[test]
12945    fn on_overflow_on_a_non_hook_artifact_is_rejected() {
12946        let yaml = r#"
12947name: cap
12948version: 0.0.1
12949artifacts:
12950  - name: local/git
12951    version: 1.0.0
12952    runtime: tool
12953    on_overflow: block
12954"#;
12955        let err = match RuntimeManifest::from_yaml_str(yaml) {
12956            Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
12957            Err(err) => err,
12958        };
12959        assert!(matches!(
12960            err,
12961            RuntimeManifestError::InvalidArtifact { index: 0, .. }
12962        ));
12963        let msg = err.to_string();
12964        assert!(msg.contains("git"), "error was: {msg}");
12965        assert!(
12966            msg.contains("only recognized on 'runtime: hook' entries"),
12967            "error was: {msg}"
12968        );
12969    }
12970
12971    /// An unknown value is a typo, not a policy — rejected with both spellings named.
12972    #[test]
12973    fn unknown_on_overflow_value_is_rejected() {
12974        let yaml = r#"
12975name: cap
12976version: 0.0.1
12977artifacts:
12978  - name: murmur/hook-grafana
12979    version: 1.0.0
12980    runtime: hook
12981    on_overflow: spill
12982"#;
12983        let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
12984        let msg = err.to_string();
12985        assert!(msg.contains("spill"), "error was: {msg}");
12986        assert!(msg.contains("expected: drop, block"), "error was: {msg}");
12987    }
12988
12989    /// The key is legal on a *blocking* hook entry and simply inert: the operator manifest
12990    /// cannot know a hook's execution mode, which lives in the artifact's own manifest.
12991    #[test]
12992    fn on_overflow_is_accepted_on_any_hook_entry() {
12993        let manifest = RuntimeManifest::from_yaml_str(
12994            r#"
12995name: cap
12996version: 0.0.1
12997artifacts:
12998  - name: murmur/hook-eval
12999    version: 1.0.0
13000    runtime: hook
13001    on_overflow: drop
13002    capabilities:
13003      network:
13004        allow:
13005          - https://telemetry.example.com
13006"#,
13007        )
13008        .unwrap();
13009
13010        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
13011        assert!(manifest.artifacts[0].capabilities.is_some());
13012    }
13013
13014    /// The capsule-wide top-level block is untouched by the per-artifact field: both may be
13015    /// present in one manifest and neither shadows the other.
13016    #[test]
13017    fn capsule_wide_and_hook_capabilities_coexist() {
13018        let manifest = RuntimeManifest::from_yaml_str(
13019            r#"
13020name: cap
13021version: 0.0.1
13022artifacts:
13023  - name: local/telemetry-hook
13024    version: 1.0.0
13025    runtime: hook
13026    capabilities:
13027      network:
13028        allow:
13029          - https://telemetry.example.com
13030capabilities:
13031  network:
13032    allow:
13033      - https://api.anthropic.com
13034"#,
13035        )
13036        .unwrap();
13037
13038        assert_eq!(
13039            manifest.capabilities.unwrap().network.unwrap().allow,
13040            vec!["https://api.anthropic.com".to_string()]
13041        );
13042        assert_eq!(
13043            manifest.artifacts[0]
13044                .capabilities
13045                .as_ref()
13046                .unwrap()
13047                .network
13048                .as_ref()
13049                .unwrap()
13050                .allow,
13051            vec!["https://telemetry.example.com".to_string()]
13052        );
13053    }
13054
13055    /// No self-escalation: the hook artifact's *own* bundled murmur.yaml is parsed only by
13056    /// `parse_hook_config_from_yaml`, which has no capabilities field and no code path that
13057    /// reads one. A self-declared `capabilities:` block there is inert.
13058    #[test]
13059    fn hook_own_manifest_capabilities_are_not_read_into_config() {
13060        let config = parse_hook_config_from_yaml(
13061            r#"
13062name: telemetry-hook
13063version: 1.0.0
13064binding: on-inference
13065execution_mode: blocking
13066capabilities:
13067  network:
13068    allow:
13069      - https://evil.example.com
13070  filesystem:
13071    scope: /
13072"#,
13073        )
13074        .expect("a self-declared capabilities block is ignored, not an error");
13075
13076        assert_eq!(
13077            config,
13078            HookConfig {
13079                binding: HookBinding::OnInference,
13080                execution_mode: HookExecutionMode::Blocking,
13081                commit_policy: HookCommitPolicy::None,
13082            }
13083        );
13084    }
13085
13086    // ── Containment class ────────────────────────────────────────────────────
13087
13088    #[test]
13089    fn containment_classes_order_weakest_to_strongest() {
13090        assert!(ContainmentClass::Unconfined < ContainmentClass::Advisory);
13091        assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
13092        assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
13093        assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
13094        assert_eq!(
13095            ContainmentClass::ALL.iter().copied().max(),
13096            Some(ContainmentClass::Sealed)
13097        );
13098    }
13099
13100    #[test]
13101    fn containment_class_defaults_to_advisory() {
13102        assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
13103    }
13104
13105    #[test]
13106    fn containment_class_round_trips_through_string() {
13107        for class in ContainmentClass::ALL {
13108            assert_eq!(
13109                class.to_string().parse::<ContainmentClass>(),
13110                Ok(class),
13111                "{class} must survive Display -> FromStr"
13112            );
13113        }
13114        assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
13115        assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
13116        assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
13117        assert_eq!(ContainmentClass::Unconfined.as_str(), "unconfined");
13118        assert_eq!(ContainmentClass::Unconfined.to_string(), "unconfined");
13119        assert_eq!(
13120            serde_json::to_string(&ContainmentClass::Unconfined).unwrap(),
13121            "\"unconfined\""
13122        );
13123        assert_eq!(
13124            serde_norway::from_str::<ContainmentClass>("unconfined").unwrap(),
13125            ContainmentClass::Unconfined
13126        );
13127    }
13128
13129    #[test]
13130    fn containment_declarable_classes_exclude_unconfined() {
13131        use ContainmentClass::{Advisory, Scoped, Sealed, Unconfined};
13132        assert_eq!(ContainmentClass::DECLARABLE, [Advisory, Scoped, Sealed]);
13133        assert_eq!(
13134            ContainmentClass::ALL,
13135            [Unconfined, Advisory, Scoped, Sealed]
13136        );
13137        let inputs: Vec<Option<ContainmentClass>> = std::iter::once(None)
13138            .chain(ContainmentClass::DECLARABLE.map(Some))
13139            .collect();
13140        for &workspace in &inputs {
13141            for &manifest in &inputs {
13142                for &cli in &inputs {
13143                    assert_ne!(
13144                        effective_containment_floor(workspace, manifest, cli),
13145                        Unconfined
13146                    );
13147                }
13148            }
13149        }
13150    }
13151
13152    #[test]
13153    fn containment_class_rejects_unknown_names() {
13154        let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
13155        assert_eq!(err.value, "paranoid");
13156        assert_eq!(
13157            err.to_string(),
13158            "must be one of: advisory, scoped, sealed; got 'paranoid'"
13159        );
13160        // Case-sensitive by design: the wire form is lowercase everywhere.
13161        assert!("Sealed".parse::<ContainmentClass>().is_err());
13162    }
13163
13164    #[test]
13165    fn containment_class_serializes_as_its_wire_name() {
13166        assert_eq!(
13167            serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
13168            "\"scoped\""
13169        );
13170        assert_eq!(
13171            serde_norway::from_str::<ContainmentClass>("sealed").unwrap(),
13172            ContainmentClass::Sealed
13173        );
13174    }
13175
13176    #[test]
13177    fn effective_floor_is_advisory_when_nothing_is_declared() {
13178        assert_eq!(
13179            effective_containment_floor(None, None, None),
13180            ContainmentClass::Advisory
13181        );
13182    }
13183
13184    #[test]
13185    fn effective_floor_takes_the_strongest_across_every_presence_combination() {
13186        use ContainmentClass::{Advisory, Scoped, Sealed};
13187
13188        // Exactly one source present: that source wins outright.
13189        assert_eq!(
13190            effective_containment_floor(Some(Scoped), None, None),
13191            Scoped
13192        );
13193        assert_eq!(
13194            effective_containment_floor(None, Some(Scoped), None),
13195            Scoped
13196        );
13197        assert_eq!(
13198            effective_containment_floor(None, None, Some(Scoped)),
13199            Scoped
13200        );
13201        assert_eq!(
13202            effective_containment_floor(Some(Sealed), None, None),
13203            Sealed
13204        );
13205        assert_eq!(
13206            effective_containment_floor(None, Some(Sealed), None),
13207            Sealed
13208        );
13209        assert_eq!(
13210            effective_containment_floor(None, None, Some(Sealed)),
13211            Sealed
13212        );
13213
13214        // Two sources present: the stronger wins regardless of which slot holds it, and a
13215        // weaker source can never pull the stronger one down.
13216        assert_eq!(
13217            effective_containment_floor(Some(Advisory), Some(Sealed), None),
13218            Sealed
13219        );
13220        assert_eq!(
13221            effective_containment_floor(Some(Sealed), Some(Advisory), None),
13222            Sealed
13223        );
13224        assert_eq!(
13225            effective_containment_floor(Some(Advisory), None, Some(Scoped)),
13226            Scoped
13227        );
13228        assert_eq!(
13229            effective_containment_floor(Some(Scoped), None, Some(Advisory)),
13230            Scoped
13231        );
13232        assert_eq!(
13233            effective_containment_floor(None, Some(Advisory), Some(Scoped)),
13234            Scoped
13235        );
13236        assert_eq!(
13237            effective_containment_floor(None, Some(Scoped), Some(Advisory)),
13238            Scoped
13239        );
13240
13241        // All three present: max wins from every slot, and all-equal is a fixed point.
13242        assert_eq!(
13243            effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
13244            Sealed
13245        );
13246        assert_eq!(
13247            effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
13248            Sealed
13249        );
13250        assert_eq!(
13251            effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
13252            Sealed
13253        );
13254        assert_eq!(
13255            effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
13256            Scoped
13257        );
13258        assert_eq!(
13259            effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
13260            Advisory
13261        );
13262    }
13263
13264    #[test]
13265    fn manifest_parses_each_declared_containment_class() {
13266        for class in ContainmentClass::DECLARABLE {
13267            let manifest = RuntimeManifest::from_yaml_str(&format!(
13268                r#"
13269name: cap
13270version: 0.0.1
13271capabilities:
13272  containment: {class}
13273"#
13274            ))
13275            .expect("a valid containment class parses");
13276
13277            assert_eq!(
13278                manifest.capabilities.unwrap().containment,
13279                Some(class),
13280                "capabilities.containment: {class}"
13281            );
13282        }
13283    }
13284
13285    #[test]
13286    fn manifest_without_containment_key_declares_nothing() {
13287        // Absent `capabilities:` block entirely.
13288        let manifest = RuntimeManifest::from_yaml_str(
13289            r#"
13290name: cap
13291version: 0.0.1
13292"#,
13293        )
13294        .unwrap();
13295        assert!(manifest.capabilities.is_none());
13296
13297        // Present `capabilities:` block that simply omits the key: still None, never a
13298        // silently-defaulted Advisory floor stored as if the operator had written it.
13299        let manifest = RuntimeManifest::from_yaml_str(
13300            r#"
13301name: cap
13302version: 0.0.1
13303capabilities:
13304  network:
13305    allow:
13306      - https://api.example.com
13307"#,
13308        )
13309        .unwrap();
13310        assert_eq!(manifest.capabilities.unwrap().containment, None);
13311    }
13312
13313    #[test]
13314    fn manifest_rejects_an_unknown_containment_class() {
13315        let err = RuntimeManifest::from_yaml_str(
13316            r#"
13317name: cap
13318version: 0.0.1
13319capabilities:
13320  containment: paranoid
13321"#,
13322        )
13323        .expect_err("an unknown containment class is rejected at parse time");
13324
13325        match err {
13326            RuntimeManifestError::InvalidCapabilities { field, message } => {
13327                assert_eq!(field, "capabilities.containment");
13328                assert_eq!(
13329                    message,
13330                    "must be one of: advisory, scoped, sealed; got 'paranoid'"
13331                );
13332            }
13333            other => panic!("expected InvalidCapabilities, got {other:?}"),
13334        }
13335    }
13336
13337    #[test]
13338    fn manifest_refuses_an_unconfined_containment_class() {
13339        let err = RuntimeManifest::from_yaml_str(
13340            r#"
13341name: cap
13342version: 0.0.1
13343capabilities:
13344  containment: unconfined
13345"#,
13346        )
13347        .expect_err("a manifest cannot choose the unconfined stance");
13348
13349        match err {
13350            RuntimeManifestError::InvalidCapabilities { field, message } => {
13351                assert_eq!(field, "capabilities.containment");
13352                assert!(message.contains("mur run --unconfined"), "{message}");
13353                assert!(message.contains("~/.murmur/config.yaml"), "{message}");
13354            }
13355            other => panic!("expected InvalidCapabilities, got {other:?}"),
13356        }
13357    }
13358
13359    // ── exports.files ────────────────────────────────────────────────────────
13360
13361    fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
13362        RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
13363    }
13364
13365    fn export_error(block: &str) -> (String, String) {
13366        match exports_manifest(block).expect_err("this exports block must not parse") {
13367            RuntimeManifestError::InvalidExports { field, message } => (field, message),
13368            other => panic!("expected InvalidExports, got {other:?}"),
13369        }
13370    }
13371
13372    fn peer_fetch_error(block: &str) -> (String, String) {
13373        match exports_manifest(block).expect_err("this capabilities block must not parse") {
13374            RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
13375            other => panic!("expected InvalidCapabilities, got {other:?}"),
13376        }
13377    }
13378
13379    // ── exports.peer_files ───────────────────────────────────────────────────
13380
13381    #[test]
13382    fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
13383        assert_eq!(parse_duration_secs("90").unwrap(), 90);
13384        assert_eq!(parse_duration_secs("30s").unwrap(), 30);
13385        assert_eq!(parse_duration_secs("15m").unwrap(), 900);
13386        assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
13387        assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
13388        assert_eq!(parse_duration_secs("  2h  ").unwrap(), 7200);
13389        assert_eq!(parse_duration_secs("0").unwrap(), 0);
13390    }
13391
13392    /// Every spelling that is not the one accepted form, refused rather than guessed at: a
13393    /// manifest meaning 5 seconds and one meaning 5 minutes must not both parse.
13394    #[test]
13395    fn parse_duration_secs_rejects_every_other_spelling() {
13396        for input in [
13397            "",
13398            "  ",
13399            "5 minutes",
13400            "30S",
13401            "1H",
13402            "5min",
13403            "1.5h",
13404            "-1",
13405            "m",
13406            "h",
13407            "1h30m",
13408            "0x10",
13409            "30 s",
13410        ] {
13411            let error = parse_duration_secs(input)
13412                .expect_err(&format!("'{input}' must not parse as a duration"));
13413            assert!(
13414                error.contains(DURATION_ACCEPTED_FORM),
13415                "'{input}' must be refused with the accepted form; got: {error}"
13416            );
13417        }
13418    }
13419
13420    #[test]
13421    fn parse_duration_secs_refuses_to_overflow() {
13422        assert!(parse_duration_secs("18446744073709551615h")
13423            .unwrap_err()
13424            .contains("overflows"));
13425    }
13426
13427    #[test]
13428    fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
13429        let manifest = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
13430            .expect("a minimal peer_files block parses");
13431        let peer = manifest.exports.unwrap().peer_files.unwrap();
13432        assert_eq!(peer.root, "out/");
13433        assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
13434        assert_eq!(peer.max_bytes, 10_485_760);
13435        // Undeclared, not defaulted: the two ephemerality cases answer an absent value
13436        // differently, and substituting here would erase the difference.
13437        assert_eq!(peer.max_ttl_secs, None);
13438        assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
13439        assert_eq!(peer.effective_max_ttl_secs(), 3600);
13440    }
13441
13442    #[test]
13443    fn peer_files_accepts_every_duration_spelling() {
13444        for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
13445            let manifest = exports_manifest(&format!(
13446                "exports:\n  peer_files:\n    root: out/\n    max_ttl: {declared}\n"
13447            ))
13448            .expect("a declared max_ttl parses");
13449            let peer = manifest.exports.unwrap().peer_files.unwrap();
13450            assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
13451            assert_eq!(peer.effective_max_ttl_secs(), expected);
13452        }
13453    }
13454
13455    /// The two export blocks are separate authorisers: declaring one says nothing about the
13456    /// other, and both may be declared side by side over different subtrees.
13457    #[test]
13458    fn the_two_export_blocks_are_independent() {
13459        let both = exports_manifest(
13460            "exports:\n  files:\n    root: out/\n    mode: read-only\n  \
13461             peer_files:\n    root: out/handoff/\n",
13462        )
13463        .expect("both blocks parse together")
13464        .exports
13465        .unwrap();
13466        assert_eq!(both.files.unwrap().root, "out/");
13467        assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
13468
13469        let only_files =
13470            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
13471                .unwrap()
13472                .exports
13473                .unwrap();
13474        assert!(only_files.peer_files.is_none());
13475
13476        let only_peer = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
13477            .unwrap()
13478            .exports
13479            .unwrap();
13480        assert!(only_peer.files.is_none());
13481
13482        let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
13483        assert!(neither.files.is_none() && neither.peer_files.is_none());
13484    }
13485
13486    #[test]
13487    fn peer_files_root_must_be_relative_and_inside_the_workdir() {
13488        for root in ["../out", "/etc", "out/../../etc"] {
13489            let (field, message) =
13490                export_error(&format!("exports:\n  peer_files:\n    root: {root}\n"));
13491            assert_eq!(field, "exports.peer_files.root", "root: {root}");
13492            assert!(
13493                message.contains(EXPORT_ROOT_ACCEPTED_FORM),
13494                "root: {root}; got: {message}"
13495            );
13496        }
13497    }
13498
13499    #[test]
13500    fn peer_files_root_is_required() {
13501        let (field, message) = export_error("exports:\n  peer_files: {}\n");
13502        assert_eq!(field, "exports.peer_files.root");
13503        assert!(message.contains("is required"));
13504        assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
13505    }
13506
13507    #[test]
13508    fn peer_files_max_ttl_states_its_accepted_form() {
13509        for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
13510            let (field, message) = export_error(&format!(
13511                "exports:\n  peer_files:\n    root: out/\n    max_ttl: \"{declared}\"\n"
13512            ));
13513            assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
13514            assert!(
13515                message.contains(DURATION_ACCEPTED_FORM),
13516                "max_ttl: {declared}; got: {message}"
13517            );
13518        }
13519    }
13520
13521    /// A zero ceiling reads like a declared surface and refuses every mint. Better discovered at
13522    /// the line that wrote it than at the first `share-file` call.
13523    #[test]
13524    fn peer_files_max_ttl_rejects_zero() {
13525        let (field, message) =
13526            export_error("exports:\n  peer_files:\n    root: out/\n    max_ttl: 0\n");
13527        assert_eq!(field, "exports.peer_files.max_ttl");
13528        assert!(message.contains("greater than zero"));
13529    }
13530
13531    #[test]
13532    fn peer_files_max_bytes_states_its_accepted_form() {
13533        let (field, message) =
13534            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 10MB\n");
13535        assert_eq!(field, "exports.peer_files.max_bytes");
13536        assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
13537    }
13538
13539    #[test]
13540    fn peer_files_max_bytes_rejects_zero() {
13541        let (field, message) =
13542            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 0\n");
13543        assert_eq!(field, "exports.peer_files.max_bytes");
13544        assert!(message.contains("greater than zero"));
13545    }
13546
13547    // ── exports.peer_tasks ───────────────────────────────────────────────────
13548
13549    /// Absent and `accept: false` are the same posture; only an explicit `true` consents.
13550    #[test]
13551    fn peer_tasks_consents_only_on_an_explicit_true() {
13552        let absent = exports_manifest("artifacts: []\n").expect("no exports block parses");
13553        assert!(absent.exports.is_none());
13554        assert!(!absent.accepts_peer_tasks());
13555
13556        let refused = exports_manifest("exports:\n  peer_tasks:\n    accept: false\n")
13557            .expect("an explicit refusal parses");
13558        assert_eq!(
13559            refused.exports.as_ref().unwrap().peer_tasks,
13560            Some(PeerTasksExport { accept: false })
13561        );
13562        assert!(!refused.accepts_peer_tasks());
13563        assert!(!refused.exports.as_ref().unwrap().accepts_peer_tasks());
13564
13565        let accepted = exports_manifest("exports:\n  peer_tasks:\n    accept: true\n")
13566            .expect("consent parses");
13567        assert_eq!(
13568            accepted.exports.as_ref().unwrap().peer_tasks,
13569            Some(PeerTasksExport { accept: true })
13570        );
13571        assert!(accepted.accepts_peer_tasks());
13572        assert!(accepted.exports.as_ref().unwrap().accepts_peer_tasks());
13573    }
13574
13575    /// An `exports:` block declaring only `peer_tasks` opens neither file plane.
13576    #[test]
13577    fn peer_tasks_alone_declares_no_file_plane() {
13578        let manifest = exports_manifest("exports:\n  peer_tasks:\n    accept: true\n")
13579            .expect("a peer_tasks-only exports block parses");
13580        let exports = manifest.exports.unwrap();
13581        assert_eq!(exports.files, None);
13582        assert_eq!(exports.peer_files, None);
13583    }
13584
13585    /// A file export says nothing about peer tasks.
13586    #[test]
13587    fn a_file_export_does_not_consent_to_peer_tasks() {
13588        let manifest =
13589            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
13590                .expect("a files export parses");
13591        assert_eq!(manifest.exports.as_ref().unwrap().peer_tasks, None);
13592        assert!(!manifest.accepts_peer_tasks());
13593    }
13594
13595    /// Consent is never inferred: a `peer_tasks:` block without `accept:` is refused naming the
13596    /// key, as `capabilities.plan` without `submit:` is.
13597    #[test]
13598    fn peer_tasks_without_accept_is_refused() {
13599        let (field, message) = export_error("exports:\n  peer_tasks: {}\n");
13600        assert_eq!(field, "exports.peer_tasks.accept");
13601        assert_eq!(
13602            message,
13603            "must be set explicitly to true or false — consent to peer tasks is never inferred"
13604        );
13605    }
13606
13607    /// A value of the wrong type is refused at parse, and the refusal names where it is.
13608    #[test]
13609    fn peer_tasks_of_the_wrong_type_is_refused_naming_the_field() {
13610        for (block, field) in [
13611            (
13612                "exports:\n  peer_tasks:\n    accept: \"yes\"\n",
13613                "exports.peer_tasks.accept",
13614            ),
13615            (
13616                "exports:\n  peer_tasks:\n    accept: 1\n",
13617                "exports.peer_tasks.accept",
13618            ),
13619            ("exports:\n  peer_tasks: true\n", "exports.peer_tasks"),
13620            ("exports:\n  peer_tasks: [peer-a]\n", "exports.peer_tasks"),
13621        ] {
13622            let message = exports_manifest(block)
13623                .expect_err(&format!("{block:?} must not parse"))
13624                .to_string();
13625            assert!(message.contains(field), "{block:?}: {message}");
13626        }
13627    }
13628
13629    /// An unknown key under the block is a warning reported by path, not a refusal — and naming
13630    /// a peer there grants nothing.
13631    #[test]
13632    fn an_unknown_peer_tasks_key_is_reported_by_path() {
13633        let manifest =
13634            exports_manifest("exports:\n  peer_tasks:\n    accept: false\n    allow: [peer-a]\n")
13635                .expect("an unknown key is a warning, not a parse error");
13636        assert!(
13637            manifest
13638                .unknown_keys
13639                .iter()
13640                .any(|key| key.block_path == "exports.peer_tasks" && key.key == "allow"),
13641            "{:?}",
13642            manifest.unknown_keys
13643        );
13644        assert!(!manifest.accepts_peer_tasks());
13645    }
13646
13647    // ── capabilities.peer_fetch ──────────────────────────────────────────────
13648
13649    #[test]
13650    fn peer_fetch_allow_carries_its_declared_destinations() {
13651        let manifest = exports_manifest(
13652            "capabilities:\n  peer_fetch:\n    allow:\n      - localhost:41234\n      \
13653             - reporting.internal\n      - https://gateway.example.com\n",
13654        )
13655        .expect("a peer_fetch block parses");
13656        assert_eq!(
13657            manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
13658            vec![
13659                "localhost:41234".to_string(),
13660                "reporting.internal".to_string(),
13661                "https://gateway.example.com".to_string(),
13662            ]
13663        );
13664    }
13665
13666    /// An empty list is a parse error rather than a silent deny: `allow: []` reads as a declared
13667    /// grant and would otherwise refuse every fetch with no line to point at.
13668    #[test]
13669    fn peer_fetch_allow_must_not_be_empty() {
13670        let (field, message) = peer_fetch_error("capabilities:\n  peer_fetch:\n    allow: []\n");
13671        assert_eq!(field, "capabilities.peer_fetch.allow");
13672        assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
13673    }
13674
13675    #[test]
13676    fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
13677        for entry in [
13678            "http://[not a host",
13679            "\"\"",
13680            "localhost:notaport",
13681            "http://example.com/path",
13682            "ftp://example.com",
13683        ] {
13684            let (field, message) = peer_fetch_error(&format!(
13685                "capabilities:\n  peer_fetch:\n    allow:\n      - {entry}\n"
13686            ));
13687            assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
13688            assert!(
13689                message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
13690                "entry: {entry}; got: {message}"
13691            );
13692        }
13693    }
13694
13695    /// `peer_fetch` is a separate authoriser and never an alias: declaring it leaves
13696    /// `network.allow` exactly as the manifest wrote it, and vice versa.
13697    #[test]
13698    fn peer_fetch_does_not_widen_network_allow() {
13699        let capabilities = exports_manifest(
13700            "capabilities:\n  network:\n    allow:\n      - api.example.com\n  \
13701             peer_fetch:\n    allow:\n      - localhost:41234\n",
13702        )
13703        .unwrap()
13704        .capabilities
13705        .unwrap();
13706        assert_eq!(
13707            capabilities.network.unwrap().allow,
13708            vec!["api.example.com".to_string()]
13709        );
13710        assert_eq!(
13711            capabilities.peer_fetch.unwrap().allow,
13712            vec!["localhost:41234".to_string()]
13713        );
13714    }
13715
13716    #[test]
13717    fn an_absent_peer_fetch_block_is_absent() {
13718        assert!(exports_manifest("capabilities: {}\n")
13719            .unwrap()
13720            .capabilities
13721            .unwrap()
13722            .peer_fetch
13723            .is_none());
13724    }
13725
13726    #[test]
13727    fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
13728        assert_eq!(parse_byte_size("0").unwrap(), 0);
13729        assert_eq!(parse_byte_size("4096").unwrap(), 4096);
13730        assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
13731        assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
13732        assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
13733        assert_eq!(parse_byte_size("  10Mi  ").unwrap(), 10 * 1024 * 1024);
13734    }
13735
13736    /// Decimal suffixes are refused rather than guessed at: a manifest meaning 10 000 000 and one
13737    /// meaning 10 485 760 must not both parse.
13738    #[test]
13739    fn parse_byte_size_rejects_every_other_spelling() {
13740        for input in [
13741            "", "  ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
13742            "1Ki1",
13743        ] {
13744            let error = parse_byte_size(input)
13745                .expect_err(&format!("'{input}' must not parse as a byte size"));
13746            assert!(
13747                error.contains(BYTE_SIZE_ACCEPTED_FORM),
13748                "'{input}' must be refused with the accepted form; got: {error}"
13749            );
13750        }
13751    }
13752
13753    #[test]
13754    fn parse_byte_size_refuses_to_overflow() {
13755        assert!(parse_byte_size("18446744073709551615Gi")
13756            .unwrap_err()
13757            .contains("overflows"));
13758    }
13759
13760    #[test]
13761    fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
13762        let manifest =
13763            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
13764                .expect("a minimal exports block parses");
13765        let files = manifest.exports.unwrap().files.unwrap();
13766        assert_eq!(files.root, "out/");
13767        assert_eq!(files.mode, ExportMode::ReadOnly);
13768        assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
13769        assert_eq!(files.max_bytes, 10_485_760);
13770    }
13771
13772    #[test]
13773    fn exports_files_accepts_both_byte_spellings() {
13774        for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
13775            let manifest = exports_manifest(&format!(
13776                "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: {declared}\n"
13777            ))
13778            .expect("a declared max_bytes parses");
13779            assert_eq!(
13780                manifest.exports.unwrap().files.unwrap().max_bytes,
13781                expected,
13782                "max_bytes: {declared}"
13783            );
13784        }
13785    }
13786
13787    /// An `exports:` block with no `files:` is not a resource plane. It parses, and the plane
13788    /// stays undeclared — which is the deny case, not a defaulted-open one.
13789    #[test]
13790    fn an_exports_block_without_files_declares_no_plane() {
13791        let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
13792        assert!(manifest.exports.unwrap().files.is_none());
13793    }
13794
13795    #[test]
13796    fn an_absent_exports_block_is_absent() {
13797        let manifest = exports_manifest("").expect("a manifest without exports parses");
13798        assert!(manifest.exports.is_none());
13799    }
13800
13801    #[test]
13802    fn exports_files_root_must_be_relative_and_inside_the_workdir() {
13803        for root in ["../out", "/etc", "out/../../etc", "''"] {
13804            let (field, message) = export_error(&format!(
13805                "exports:\n  files:\n    root: {root}\n    mode: read-only\n"
13806            ));
13807            assert_eq!(field, "exports.files.root", "root: {root}");
13808            assert!(
13809                message.contains("must be a relative path inside the workdir"),
13810                "root: {root}; got: {message}"
13811            );
13812        }
13813    }
13814
13815    #[test]
13816    fn exports_files_root_is_required() {
13817        let (field, message) = export_error("exports:\n  files:\n    mode: read-only\n");
13818        assert_eq!(field, "exports.files.root");
13819        assert_eq!(
13820            message,
13821            "is required and must be a relative path inside the workdir"
13822        );
13823    }
13824
13825    #[test]
13826    fn exports_files_mode_is_required_and_read_only() {
13827        let (field, message) = export_error("exports:\n  files:\n    root: out/\n");
13828        assert_eq!(field, "exports.files.mode");
13829        assert_eq!(message, "is required and must be 'read-only'");
13830
13831        let (field, message) =
13832            export_error("exports:\n  files:\n    root: out/\n    mode: read-write\n");
13833        assert_eq!(field, "exports.files.mode");
13834        assert_eq!(message, "'read-write' must be 'read-only'");
13835    }
13836
13837    #[test]
13838    fn exports_files_max_bytes_states_its_accepted_form() {
13839        let (field, message) = export_error(
13840            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 10MB\n",
13841        );
13842        assert_eq!(field, "exports.files.max_bytes");
13843        assert_eq!(
13844            message,
13845            "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
13846        );
13847    }
13848
13849    /// A zero ceiling would refuse every read while reading like a declared export, so it is
13850    /// refused where it is written rather than at the first request.
13851    #[test]
13852    fn exports_files_max_bytes_rejects_zero() {
13853        let (field, message) = export_error(
13854            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 0\n",
13855        );
13856        assert_eq!(field, "exports.files.max_bytes");
13857        assert!(message.contains("must be greater than zero"), "{message}");
13858    }
13859
13860    /// A leading `./` is a relative path like any other and is not an escape.
13861    #[test]
13862    fn exports_files_root_accepts_a_current_dir_prefix() {
13863        let manifest =
13864            exports_manifest("exports:\n  files:\n    root: ./out\n    mode: read-only\n")
13865                .expect("./out is a legal root");
13866        assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
13867    }
13868
13869    // ── Unrecognized manifest keys (W-SEC-019) ────────────────────────────────
13870
13871    fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
13872        RuntimeManifest::from_yaml_str(yaml)
13873            .expect("fixture must parse")
13874            .unknown_keys
13875            .into_iter()
13876            .map(|key| (key.key, key.block_path, key.nearest_known))
13877            .collect()
13878    }
13879
13880    /// The typo the whole warning exists for: a hyphen where the key takes an underscore, named
13881    /// with the block that held it and with the key it should have been.
13882    #[test]
13883    fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
13884        assert_eq!(
13885            unknown_keys_of(
13886                "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    read-only:\n      - tests\n"
13887            ),
13888            vec![(
13889                "read-only".to_string(),
13890                "capabilities.filesystem".to_string(),
13891                Some("read_only".to_string())
13892            )]
13893        );
13894    }
13895
13896    /// A manifest exercising every layer the walk descends through — capsule-wide capabilities,
13897    /// a per-artifact block, shell, network and an artifact entry — reports nothing. A warning
13898    /// that fires on correct input is noise, and noise is ignored.
13899    #[test]
13900    fn a_manifest_of_recognized_keys_reports_nothing() {
13901        assert!(unknown_keys_of(
13902            r#"
13903name: cap
13904version: 0.1.0
13905deploy:
13906  mur_version: "0.2.0"
13907artifacts:
13908  - name: local/notes-tool
13909    version: 0.1.0
13910    runtime: tool
13911    capabilities:
13912      filesystem:
13913        scope: notes
13914      network:
13915        allow:
13916          - example.com:443
13917capabilities:
13918  filesystem:
13919    read_only:
13920      - tests
13921      - bench/fixtures
13922    workdir_exec: false
13923  shell:
13924    allow:
13925      - git
13926      - python3
13927  network:
13928    allow:
13929      - example.com:443
13930    unix_sockets: false
13931"#
13932        )
13933        .is_empty());
13934    }
13935
13936    /// A per-artifact capability block reports under its artifact's index, so an operator is not
13937    /// sent to the capsule-wide block for a key they wrote on one entry.
13938    #[test]
13939    fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
13940        assert_eq!(
13941            unknown_keys_of(
13942                "name: cap\nversion: 0.1.0\nartifacts:\n  - name: local/t\n    version: 0.1.0\n    \
13943                 runtime: tool\n    capabilities:\n      shell:\n        allow:\n          - git\n        \
13944                 allwo:\n          - git\n"
13945            ),
13946            vec![(
13947                "allwo".to_string(),
13948                "artifacts[0].capabilities.shell".to_string(),
13949                Some("allow".to_string())
13950            )]
13951        );
13952    }
13953
13954    /// A top-level key with no near neighbour reports an empty path and no suggestion — the two
13955    /// facts the emitter needs to word it as a possibly-newer key rather than a misspelling.
13956    #[test]
13957    fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
13958        assert_eq!(
13959            unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
13960            vec![("quantum_teleport".to_string(), String::new(), None)]
13961        );
13962    }
13963
13964    /// The minimum publishable delegated sub-capsule — `runtime:` plus `execution:` — reports
13965    /// nothing. Every registrant of a delegating capsule must be published, so every one of them
13966    /// carries these keys; warning on them would make the diagnostic unreadable exactly where it
13967    /// is needed most.
13968    #[test]
13969    fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
13970        assert!(unknown_keys_of(
13971            "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
13972        )
13973        .is_empty());
13974    }
13975
13976    /// All four keys `mur build` and `mur publish` read beyond `name:` and `version:`, together.
13977    #[test]
13978    fn every_build_side_key_is_recognized() {
13979        assert!(unknown_keys_of(
13980            "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
13981             wasm\nrequires_files:\n  - tool.wasm\n  - assets/logo.png\n"
13982        )
13983        .is_empty());
13984    }
13985
13986    /// `W-SEC-019` still covers every key no command reads: one sitting beside the four
13987    /// build-side keys reports, and reports alone.
13988    #[test]
13989    fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
13990        assert_eq!(
13991            unknown_keys_of(
13992                "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
13993                 static\nrequires_files:\n  - capsule.wasm\nquantum_teleport: true\n"
13994            ),
13995            vec![("quantum_teleport".to_string(), String::new(), None)]
13996        );
13997    }
13998
13999    /// The four keys are recognized and then dropped: they add no refusal and no run-time meaning,
14000    /// so a manifest carrying them parses to what it parses to without them. A wrong-shaped value
14001    /// is build's business to reject, not the run path's.
14002    #[test]
14003    fn the_build_side_keys_change_nothing_the_run_path_reads() {
14004        let body = "artifacts:\n  - name: local/notes-tool\n    version: 0.1.0\n    runtime: tool\n\
14005                    \x20 - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: \
14006                    driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\n\
14007                    capabilities:\n  filesystem:\n    read_only:\n      - tests\n\
14008                    inference:\n  transport: http\n  model: test-model\n  driver:\n    \
14009                    artifact: murmur/driver-anthropic\n";
14010        let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
14011            .expect("fixture must parse");
14012        let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
14013            "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n  \
14014             any: shape\nrequires_files: not-a-list\n{body}"
14015        ))
14016        .expect("a build-side key of any shape never refuses a manifest");
14017
14018        assert!(with_build_keys.unknown_keys.is_empty());
14019        assert!(
14020            !plain.artifacts.is_empty()
14021                && plain.capabilities.is_some()
14022                && plain.inference.is_some(),
14023            "the fixture must populate the blocks the comparison below rests on, or it \
14024             compares absence against absence"
14025        );
14026        assert_eq!(with_build_keys.name, plain.name);
14027        assert_eq!(with_build_keys.version, plain.version);
14028        assert_eq!(with_build_keys.artifacts, plain.artifacts);
14029        assert_eq!(with_build_keys.capabilities, plain.capabilities);
14030        assert_eq!(with_build_keys.inference, plain.inference);
14031    }
14032
14033    /// The overflow map captures the key without consuming the block: the rest of it still parses
14034    /// and still applies. A capsule with an unrecognized key launches.
14035    #[test]
14036    fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
14037        let manifest = RuntimeManifest::from_yaml_str(
14038            "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    scope: notes\n    \
14039             read-only:\n      - tests\n",
14040        )
14041        .expect("an unrecognized key never refuses a manifest");
14042        assert_eq!(
14043            manifest
14044                .capabilities
14045                .as_ref()
14046                .and_then(|caps| caps.filesystem.as_ref())
14047                .and_then(|fs| fs.scope.as_deref()),
14048            Some("notes")
14049        );
14050        assert_eq!(manifest.unknown_keys.len(), 1);
14051    }
14052
14053    /// Every block on the way down is walked, not just the capability sub-blocks.
14054    #[test]
14055    fn nested_blocks_outside_capabilities_are_walked_too() {
14056        let keys = unknown_keys_of(
14057            "name: cap\nversion: 0.1.0\ncontext:\n  max_tokns: 100\nexports:\n  files:\n    \
14058             root: out\n    mode: read-only\n    max_byts: 10\n",
14059        );
14060        assert_eq!(
14061            keys,
14062            vec![
14063                (
14064                    "max_tokns".to_string(),
14065                    "context".to_string(),
14066                    Some("max_tokens".to_string())
14067                ),
14068                (
14069                    "max_byts".to_string(),
14070                    "exports.files".to_string(),
14071                    Some("max_bytes".to_string())
14072                ),
14073            ]
14074        );
14075    }
14076
14077    /// Source-derived, so a `Raw*` struct added later cannot skip the overflow map, and a field
14078    /// added to one cannot skip its `KNOWN_KEYS`: the suggester would then never propose it.
14079    ///
14080    /// Reads this file's own text rather than a hand-written list for the same reason the
14081    /// `CapabilityPolicy` coverage test in `capsule-runtime` does — a list maintained by hand
14082    /// records what someone remembered, not what the code says.
14083    #[test]
14084    fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
14085        let source = include_str!("runtime_manifest.rs");
14086        let lines: Vec<&str> = source.lines().collect();
14087
14088        let mut checked = 0usize;
14089        for (start, line) in lines.iter().enumerate() {
14090            let Some(name) = line
14091                .strip_prefix("struct Raw")
14092                .and_then(|rest| rest.strip_suffix(" {"))
14093            else {
14094                continue;
14095            };
14096            let name = format!("Raw{name}");
14097            let end = (start + 1..lines.len())
14098                .find(|index| lines[*index] == "}")
14099                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
14100            let body = &lines[start + 1..end];
14101
14102            assert!(
14103                body.contains(&"    #[serde(flatten)]")
14104                    && body.contains(&"    unknown: UnknownKeys,"),
14105                "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
14106                 so a key it does not recognize would be dropped instead of reported"
14107            );
14108
14109            let declared = serde_field_names(body);
14110            let known = known_keys_of(source, &name);
14111            assert_eq!(
14112                declared, known,
14113                "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
14114                 suggester would propose the wrong set for that block"
14115            );
14116            checked += 1;
14117        }
14118        assert!(
14119            checked >= 30,
14120            "expected every Raw* struct to be scanned, found only {checked}"
14121        );
14122    }
14123
14124    /// Source-derived for the same reason its sibling above is: a `Raw*`-typed field added to a
14125    /// block and left unwalked reports none of the keys inside that block, and reports nothing
14126    /// about having done so. The scan is keyed on the *parent's field*, not on the child's type,
14127    /// because one type can sit under several parents — `RawInferenceDriver` is both
14128    /// `inference.driver` and each alternate's `driver` — and a check keyed on the type would call
14129    /// one covered because the other is walked.
14130    #[test]
14131    fn every_raw_struct_descends_into_the_blocks_it_owns() {
14132        let source = include_str!("runtime_manifest.rs");
14133        let lines: Vec<&str> = source.lines().collect();
14134
14135        let mut structs = 0usize;
14136        let mut children = 0usize;
14137        for (start, line) in lines.iter().enumerate() {
14138            let Some(name) = line
14139                .strip_prefix("struct Raw")
14140                .and_then(|rest| rest.strip_suffix(" {"))
14141            else {
14142                continue;
14143            };
14144            let name = format!("Raw{name}");
14145            let end = (start + 1..lines.len())
14146                .find(|index| lines[*index] == "}")
14147                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
14148            let owned = block_typed_fields(&lines[start + 1..end]);
14149            structs += 1;
14150
14151            if owned.is_empty() {
14152                continue;
14153            }
14154            let block = raw_block_impl_of(source, &name);
14155            assert!(
14156                block.contains("fn walk_children"),
14157                "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
14158                 `walk_children`, so every key written inside {} would go unreported",
14159                if owned.len() == 1 { "" } else { "s" },
14160                owned.join(", "),
14161                if owned.len() == 1 { "it" } else { "them" }
14162            );
14163            for field in &owned {
14164                assert!(
14165                    mentions_field(&block, field),
14166                    "{name}'s `walk_children` never touches `self.{field}`, so every key written \
14167                     inside that block would go unreported — descend into it with \
14168                     `collect_block(.., &child_path(path, \"{field}\"), out)`"
14169                );
14170                children += 1;
14171            }
14172        }
14173        assert!(
14174            structs >= 30,
14175            "expected every Raw* struct to be scanned, found only {structs}"
14176        );
14177        assert!(
14178            children >= 31,
14179            "expected every nested block field to be scanned, found only {children}"
14180        );
14181    }
14182
14183    /// Whether `impl` reads `self.<field>` as that whole field, not as the head of a longer one:
14184    /// a plain substring test lets a struct owning both `foo` and `foo_bar` walk only `foo_bar`
14185    /// and still be called covered, which is the silent pass this scan exists to deny.
14186    fn mentions_field(impl_body: &str, field: &str) -> bool {
14187        let needle = format!("self.{field}");
14188        impl_body.match_indices(&needle).any(|(at, _)| {
14189            impl_body[at + needle.len()..]
14190                .chars()
14191                .next()
14192                .is_none_or(|next| !next.is_alphanumeric() && next != '_')
14193        })
14194    }
14195
14196    /// The fields of one struct body whose declared type names another `Raw*` type, in
14197    /// declaration order. Comment and attribute lines are skipped, so a doc comment that mentions
14198    /// a `Raw*` type by name is not read as a field.
14199    fn block_typed_fields(body: &[&str]) -> Vec<String> {
14200        let mut fields = Vec::new();
14201        for line in body {
14202            let trimmed = line.trim();
14203            if trimmed.starts_with("//") || trimmed.starts_with("#[") {
14204                continue;
14205            }
14206            let Some((field, declared)) = line
14207                .strip_prefix("    ")
14208                .and_then(|rest| rest.split_once(':'))
14209            else {
14210                continue;
14211            };
14212            if field.is_empty()
14213                || !field
14214                    .chars()
14215                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
14216            {
14217                continue;
14218            }
14219            if declared.contains("Raw") {
14220                fields.push(field.to_string());
14221            }
14222        }
14223        fields
14224    }
14225
14226    /// The text of `impl RawBlock for <name>`, cut at the next line that is exactly `}` at column
14227    /// 0 so one impl's body cannot be read as the next one's.
14228    fn raw_block_impl_of(source: &str, name: &str) -> String {
14229        let header = format!("impl RawBlock for {name} {{");
14230        let body = source
14231            .split_once(&header)
14232            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
14233            .1;
14234        body.split_once("\n}\n")
14235            .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
14236            .0
14237            .to_string()
14238    }
14239
14240    /// The walk's own result, taken straight off a deserialized `RawRuntimeManifest`.
14241    ///
14242    /// Deliberately not routed through [`RuntimeManifest::from_yaml_str`]: that validates, and a
14243    /// fixture that names every block at once would have to satisfy every semantic rule as well
14244    /// to reach the walk. What is under test here is which blocks are descended into, which the
14245    /// validator has no part in.
14246    fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
14247        let raw: RawRuntimeManifest = serde_norway::from_str(yaml).expect("fixture must parse");
14248        collect_unknown_keys(&raw)
14249            .into_iter()
14250            .map(|key| (key.key, key.block_path))
14251            .collect()
14252    }
14253
14254    #[test]
14255    fn spawn_workdir_snapshot_is_a_known_key_beside_a_sibling_probe() {
14256        let keys = raw_unknown_keys(
14257            "name: cap\nversion: 0.1.0\ncapabilities:\n  spawn:\n    allow: [local/r]\n    \
14258             workdir_snapshot: [local/r]\n    probe_spawn: 1\n",
14259        );
14260        assert_eq!(
14261            keys,
14262            vec![("probe_spawn".to_string(), "capabilities.spawn".to_string())]
14263        );
14264    }
14265
14266    /// Every block the manifest type owns, each carrying one key no field of it claims, reported
14267    /// once and at its own dotted path. A block added after this one is held to the same rule by
14268    /// `every_raw_struct_descends_into_the_blocks_it_owns`; this is what "descended into" means.
14269    #[test]
14270    fn every_block_reports_its_own_unknown_key_at_its_own_path() {
14271        let keys = raw_unknown_keys(
14272            r#"
14273name: every-block
14274version: 0.1.0
14275probe_root: 1
14276artifacts:
14277  - name: local/t
14278    version: 0.1.0
14279    runtime: tool
14280    probe_artifact: 1
14281    capabilities:
14282      probe_artifact_capabilities: 1
14283      shell:
14284        probe_artifact_shell: 1
14285capabilities:
14286  probe_capabilities: 1
14287  network:
14288    probe_network: 1
14289  peer_fetch:
14290    probe_peer_fetch: 1
14291  filesystem:
14292    probe_filesystem: 1
14293  shell:
14294    probe_shell: 1
14295    interpreter_runtime:
14296      - binary: python3
14297        probe_interpreter_runtime: 1
14298        dirs:
14299          - path: /usr/lib/python3
14300            probe_interpreter_runtime_dir: 1
14301    staged_runtime:
14302      - binary: rg
14303        probe_staged_runtime: 1
14304  spawn:
14305    probe_spawn: 1
14306  env:
14307    probe_env: 1
14308  limits:
14309    probe_limits: 1
14310  resources:
14311    probe_resources: 1
14312  state:
14313    probe_state: 1
14314  task_io:
14315    probe_task_io: 1
14316  conversation:
14317    probe_conversation: 1
14318  install:
14319    probe_install: 1
14320inference:
14321  probe_inference: 1
14322  driver:
14323    probe_driver: 1
14324  compaction:
14325    probe_compaction: 1
14326context:
14327  probe_context: 1
14328observability:
14329  probe_observability: 1
14330  eval:
14331    probe_eval: 1
14332    scorers:
14333      - type: exact_match
14334        probe_scorer: 1
14335trace:
14336  probe_trace: 1
14337network:
14338  probe_network_config: 1
14339lifecycle:
14340  probe_lifecycle: 1
14341exports:
14342  probe_exports: 1
14343  files:
14344    probe_files: 1
14345  peer_files:
14346    probe_peer_files: 1
14347control:
14348  probe_control: 1
14349"#,
14350        );
14351
14352        let expected: Vec<(String, String)> = [
14353            ("probe_root", ""),
14354            ("probe_artifact", "artifacts[0]"),
14355            ("probe_artifact_capabilities", "artifacts[0].capabilities"),
14356            ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
14357            ("probe_capabilities", "capabilities"),
14358            ("probe_network", "capabilities.network"),
14359            ("probe_peer_fetch", "capabilities.peer_fetch"),
14360            ("probe_filesystem", "capabilities.filesystem"),
14361            ("probe_shell", "capabilities.shell"),
14362            (
14363                "probe_interpreter_runtime",
14364                "capabilities.shell.interpreter_runtime[0]",
14365            ),
14366            (
14367                "probe_interpreter_runtime_dir",
14368                "capabilities.shell.interpreter_runtime[0].dirs[0]",
14369            ),
14370            (
14371                "probe_staged_runtime",
14372                "capabilities.shell.staged_runtime[0]",
14373            ),
14374            ("probe_spawn", "capabilities.spawn"),
14375            ("probe_env", "capabilities.env"),
14376            ("probe_limits", "capabilities.limits"),
14377            ("probe_resources", "capabilities.resources"),
14378            ("probe_state", "capabilities.state"),
14379            ("probe_task_io", "capabilities.task_io"),
14380            ("probe_conversation", "capabilities.conversation"),
14381            ("probe_install", "capabilities.install"),
14382            ("probe_inference", "inference"),
14383            ("probe_driver", "inference.driver"),
14384            ("probe_compaction", "inference.compaction"),
14385            ("probe_context", "context"),
14386            ("probe_observability", "observability"),
14387            ("probe_eval", "observability.eval"),
14388            ("probe_scorer", "observability.eval.scorers[0]"),
14389            ("probe_trace", "trace"),
14390            ("probe_network_config", "network"),
14391            ("probe_lifecycle", "lifecycle"),
14392            ("probe_exports", "exports"),
14393            ("probe_files", "exports.files"),
14394            ("probe_peer_files", "exports.peer_files"),
14395            ("probe_control", "control"),
14396        ]
14397        .into_iter()
14398        .map(|(key, path)| (key.to_string(), path.to_string()))
14399        .collect();
14400
14401        assert_eq!(keys, expected);
14402    }
14403
14404    /// The names serde matches on for one struct body: field names in declaration order, with a
14405    /// `#[serde(rename = "...")]` field under its renamed spelling, and the overflow map itself
14406    /// excluded because it claims no name of its own.
14407    fn serde_field_names(body: &[&str]) -> Vec<String> {
14408        let mut names = Vec::new();
14409        let mut rename: Option<String> = None;
14410        for line in body {
14411            let trimmed = line.trim();
14412            if trimmed.starts_with("#[") {
14413                if let Some(rest) = trimmed.split("rename = \"").nth(1) {
14414                    if !trimmed.contains("rename_all") {
14415                        rename = rest.split('"').next().map(str::to_string);
14416                    }
14417                }
14418                continue;
14419            }
14420            if trimmed.starts_with("//") {
14421                continue;
14422            }
14423            let Some(field) = line
14424                .strip_prefix("    ")
14425                .and_then(|rest| rest.split(':').next())
14426            else {
14427                continue;
14428            };
14429            if field.is_empty()
14430                || !field
14431                    .chars()
14432                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
14433            {
14434                continue;
14435            }
14436            if field == "unknown" {
14437                rename = None;
14438                continue;
14439            }
14440            names.push(rename.take().unwrap_or_else(|| field.to_string()));
14441        }
14442        names
14443    }
14444
14445    /// The `KNOWN_KEYS` array `impl RawBlock for <name>` declares, read out of the source text so
14446    /// the assertion compares two independent readings of the same file rather than one value
14447    /// against itself.
14448    fn known_keys_of(source: &str, name: &str) -> Vec<String> {
14449        let header = format!("impl RawBlock for {name} {{");
14450        let body = source
14451            .split_once(&header)
14452            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
14453            .1;
14454        // rustfmt moves an array too long for the declaration's line onto the next one.
14455        let array = body
14456            .split_once("KNOWN_KEYS: &'static [&'static str] =")
14457            .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
14458            .1
14459            .trim_start()
14460            .strip_prefix("&[")
14461            .unwrap_or_else(|| panic!("{name}'s KNOWN_KEYS is not an array literal"))
14462            .split_once("];")
14463            .expect("KNOWN_KEYS array is unterminated")
14464            .0;
14465        array
14466            .split('"')
14467            .skip(1)
14468            .step_by(2)
14469            .map(str::to_string)
14470            .collect()
14471    }
14472}
14473
14474#[cfg(test)]
14475mod control_tests {
14476    use super::*;
14477
14478    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";
14479
14480    /// An `http` agent capsule whose driver is keyed by `${DRIVER_KEY}` and whose `card-api` tool
14481    /// is keyed by `${CARD_TOKEN}`, with `control` spliced in at the top level.
14482    fn agent(transport: &str, control: &str) -> String {
14483        let driver = if transport == "http" {
14484            "  - 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"
14485        } else {
14486            "  - name: local/drv\n    version: 0.1.0\n    runtime: driver\n"
14487        };
14488        format!(
14489            "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}"
14490        )
14491    }
14492
14493    fn refusal(yaml: &str) -> (String, String) {
14494        match RuntimeManifest::from_yaml_str(yaml) {
14495            Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14496            other => panic!("expected InvalidControl, got {other:?}"),
14497        }
14498    }
14499
14500    #[test]
14501    fn control_absent_empty_and_empty_lists_load_as_none() {
14502        for control in [
14503            "",
14504            "control: {}\n",
14505            "control:\n",
14506            "control:\n  settings: []\n  secrets: []\n",
14507        ] {
14508            let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
14509                .unwrap_or_else(|err| panic!("{control:?}: {err}"));
14510            assert_eq!(manifest.control, None, "{control:?}");
14511        }
14512    }
14513
14514    #[test]
14515    fn control_parses_settings_and_secrets_in_manifest_order() {
14516        let yaml = agent(
14517            "http",
14518            "control:\n  settings: [inference.max_tokens]\n  secrets: [CARD_TOKEN]\n",
14519        );
14520        for manifest in [
14521            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14522            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14523        ] {
14524            let control = manifest.control.unwrap();
14525            assert_eq!(
14526                control.settings,
14527                vec![ControllableSetting::InferenceMaxTokens]
14528            );
14529            assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
14530            assert!(control.declares_secret("CARD_TOKEN"));
14531            assert!(!control.declares_secret("DRIVER_KEY"));
14532        }
14533    }
14534
14535    #[test]
14536    fn control_setting_wire_names_round_trip() {
14537        for setting in ControllableSetting::ALL {
14538            assert_eq!(
14539                ControllableSetting::from_wire_name(setting.wire_name()),
14540                Some(*setting)
14541            );
14542        }
14543        assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
14544    }
14545
14546    /// Every refusal names `control.settings` or `control.secrets` as its field and quotes the
14547    /// entry it refused.
14548    #[test]
14549    fn control_manifest_refusals_name_the_field() {
14550        let cases: &[(&str, &str, &str, &str)] = &[
14551            (
14552                "http",
14553                "control:\n  settings: [inference.model]\n",
14554                "control.settings",
14555                "'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
14556            ),
14557            (
14558                "process",
14559                "control:\n  settings: [inference.max_tokens]\n",
14560                "control.settings",
14561                "'inference.max_tokens' is controllable only under inference.transport: http",
14562            ),
14563            (
14564                "http",
14565                "control:\n  settings: [inference.max_tokens, inference.max_tokens]\n",
14566                "control.settings",
14567                "'inference.max_tokens' is listed more than once",
14568            ),
14569            (
14570                "http",
14571                "control:\n  secrets: [CARD_TOKEN, CARD_TOKEN]\n",
14572                "control.secrets",
14573                "'CARD_TOKEN' is listed more than once",
14574            ),
14575            (
14576                "http",
14577                "control:\n  secrets: [card-token]\n",
14578                "control.secrets",
14579                "'card-token' is not a credential name",
14580            ),
14581            (
14582                "http",
14583                "control:\n  secrets: [OTHER_TOKEN]\n",
14584                "control.secrets",
14585                "'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
14586            ),
14587            (
14588                "http",
14589                "control:\n  secrets: [DRIVER_KEY]\n",
14590                "control.secrets",
14591                "'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.local/drv.gateway.api_key)",
14592            ),
14593            (
14594                "http",
14595                "control:\n  settings: inference.max_tokens\n",
14596                "control.settings",
14597                "must be a list of names",
14598            ),
14599            (
14600                "http",
14601                "control:\n  secrets: [1]\n",
14602                "control.secrets",
14603                "must be a list of names",
14604            ),
14605        ];
14606        for (transport, control, field, message) in cases {
14607            let (got_field, got_message) = refusal(&agent(transport, control));
14608            assert_eq!(&got_field, field, "{control:?}");
14609            assert!(
14610                got_message.starts_with(message),
14611                "{control:?}: {got_message}"
14612            );
14613            let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
14614                .unwrap_err()
14615                .to_string();
14616            assert!(
14617                rendered.contains(&format!("invalid control config for '{field}'")),
14618                "{rendered}"
14619            );
14620        }
14621    }
14622
14623    #[test]
14624    fn control_on_a_capsule_with_no_inference_is_refused() {
14625        let yaml = format!(
14626            "name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n  secrets: [CARD_TOKEN]\n"
14627        );
14628        let (field, message) = refusal(&yaml);
14629        assert_eq!(field, "control");
14630        assert!(message.contains("no inference: block"), "{message}");
14631    }
14632
14633    #[test]
14634    fn control_unknown_key_is_reported_once_at_its_block() {
14635        let manifest = RuntimeManifest::from_yaml_str(&agent(
14636            "http",
14637            "control:\n  settings: [inference.max_tokens]\n  agent_may_call: true\n",
14638        ))
14639        .unwrap();
14640        let keys: Vec<(&str, &str)> = manifest
14641            .unknown_keys
14642            .iter()
14643            .map(|key| (key.key.as_str(), key.block_path.as_str()))
14644            .collect();
14645        assert_eq!(keys, vec![("agent_may_call", "control")]);
14646        assert_eq!(
14647            manifest.control.unwrap().settings,
14648            vec![ControllableSetting::InferenceMaxTokens]
14649        );
14650    }
14651}
14652
14653#[cfg(test)]
14654mod driver_choice_tests {
14655    use super::*;
14656
14657    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";
14658    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";
14659    const ALT_NO_GATEWAY: &str = "  - name: local/alt\n    version: 0.1.0\n    runtime: driver\n";
14660    const TOOL: &str = "  - name: local/card-api\n    version: 0.1.0\n    runtime: tool\n";
14661    const GPT: &str =
14662        "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: local/alt\n";
14663    const HAIKU: &str =
14664        "    - name: haiku\n      model: haiku\n      driver:\n        artifact: local/drv\n";
14665    const SWITCHABLE: &str = "control:\n  settings: [inference.driver]\n";
14666
14667    /// An `http` capsule on `drv` with model `m`, `artifacts` after `drv`, `alternates` as the
14668    /// `inference.alternates` list body (empty for no key), and `control` at the top level.
14669    fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
14670        let alternates = if alternates.is_empty() {
14671            String::new()
14672        } else {
14673            format!("  alternates:\n{alternates}")
14674        };
14675        format!(
14676            "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}"
14677        )
14678    }
14679
14680    fn refusal(yaml: &str) -> (String, String) {
14681        match RuntimeManifest::from_yaml_str(yaml) {
14682            Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
14683            | Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14684            other => panic!("expected an inference or control refusal, got {other:?}"),
14685        }
14686    }
14687
14688    #[test]
14689    fn driver_choice_alternates_parse_in_manifest_order() {
14690        let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
14691        for manifest in [
14692            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14693            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14694        ] {
14695            let inference = manifest.inference.unwrap();
14696            assert_eq!(
14697                inference.alternates,
14698                vec![
14699                    InferenceAlternate {
14700                        name: "gpt".to_string(),
14701                        model: "gpt-5".to_string(),
14702                        driver: "local/alt".to_string(),
14703                    },
14704                    InferenceAlternate {
14705                        name: "haiku".to_string(),
14706                        model: "haiku".to_string(),
14707                        driver: "local/drv".to_string(),
14708                    },
14709                ]
14710            );
14711            let control = manifest.control.unwrap();
14712            assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
14713            assert!(control.agent_settings.is_empty());
14714        }
14715    }
14716
14717    #[test]
14718    fn driver_choice_absent_alternates_are_empty() {
14719        let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
14720        assert!(manifest.inference.unwrap().alternates.is_empty());
14721    }
14722
14723    #[test]
14724    fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
14725        let manifest = RuntimeManifest::from_yaml_str(&capsule(
14726            ALT,
14727            GPT,
14728            "control:\n  agent_settings: [inference.driver]\n",
14729        ))
14730        .unwrap();
14731        let control = manifest.control.unwrap();
14732        assert!(control.settings.is_empty());
14733        assert_eq!(
14734            control.agent_settings,
14735            vec![ControllableSetting::InferenceDriver]
14736        );
14737        assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
14738        assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
14739        assert!(control.secrets.is_empty());
14740    }
14741
14742    #[test]
14743    fn driver_choice_wire_name_round_trips() {
14744        assert_eq!(
14745            ControllableSetting::from_wire_name("inference.driver"),
14746            Some(ControllableSetting::InferenceDriver)
14747        );
14748        assert_eq!(
14749            ControllableSetting::InferenceDriver.wire_name(),
14750            "inference.driver"
14751        );
14752        assert_eq!(
14753            ControllableSetting::AGENT_SETTABLE,
14754            &[ControllableSetting::InferenceDriver]
14755        );
14756    }
14757
14758    /// An alternate's driver may carry `gateway:`, which a driver entry nothing names may not.
14759    #[test]
14760    fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
14761        RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
14762            .expect("an alternate's driver carries its own gateway");
14763        match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
14764            Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
14765                assert!(message.contains("inference.alternates"), "{message}")
14766            }
14767            other => panic!("expected InvalidArtifact, got {other:?}"),
14768        }
14769    }
14770
14771    /// Two models of one provider: the alternate shares the primary's driver and gateway.
14772    #[test]
14773    fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
14774        let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
14775        assert_eq!(
14776            manifest.inference.unwrap().alternates[0].driver,
14777            "local/drv"
14778        );
14779    }
14780
14781    #[test]
14782    fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
14783        let manifest = RuntimeManifest::from_yaml_str(&capsule(
14784            ALT,
14785            GPT,
14786            "control:\n  settings: [inference.driver]\n  secrets: [ALT_KEY]\n",
14787        ))
14788        .unwrap();
14789        assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
14790        let (field, message) = refusal(&capsule(
14791            ALT,
14792            GPT,
14793            "control:\n  settings: [inference.driver]\n  secrets: [DRIVER_KEY]\n",
14794        ));
14795        assert_eq!(field, "control.secrets");
14796        assert!(
14797            message.contains("keys the configured inference driver's gateway"),
14798            "{message}"
14799        );
14800    }
14801
14802    #[test]
14803    fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
14804        let alternate = "    - name: gpt\n      model: gpt-5\n      temperature: 1\n      driver:\n        artifact: local/alt\n";
14805        let manifest =
14806            RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
14807        let keys: Vec<(&str, &str)> = manifest
14808            .unknown_keys
14809            .iter()
14810            .map(|key| (key.key.as_str(), key.block_path.as_str()))
14811            .collect();
14812        assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
14813    }
14814
14815    /// Every refusal the alternates and the driver setting can draw, each naming its field and
14816    /// rendering as the manifest error `E-MAN-003` maps from.
14817    #[test]
14818    fn driver_choice_manifest_refusals_name_the_field() {
14819        let alternate = |name: &str, model: &str, driver: &str| {
14820            format!("    - name: {name}\n      model: {model}\n      driver:\n        artifact: {driver}\n")
14821        };
14822        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";
14823        let cases: Vec<(String, &str, &str)> = vec![
14824            (
14825                format!("{process}  alternates:\n{GPT}"),
14826                "inference.alternates",
14827                "is not valid with transport: process",
14828            ),
14829            (
14830                format!("{process}control:\n  settings: [inference.driver]\n"),
14831                "control.settings",
14832                "'inference.driver' is controllable only under inference.transport: http",
14833            ),
14834            (
14835                capsule(ALT, &alternate("primary", "gpt-5", "local/alt"), SWITCHABLE),
14836                "inference.alternates[0].name",
14837                "'primary' is reserved",
14838            ),
14839            (
14840                capsule(
14841                    ALT,
14842                    &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt", "gpt-4", "local/alt")),
14843                    SWITCHABLE,
14844                ),
14845                "inference.alternates[1].name",
14846                "'gpt' names more than one alternate",
14847            ),
14848            (
14849                capsule(ALT, &alternate("GPT", "gpt-5", "local/alt"), SWITCHABLE),
14850                "inference.alternates[0].name",
14851                "'GPT' is not a driver choice name",
14852            ),
14853            (
14854                capsule(ALT, &alternate("5gpt", "gpt-5", "local/alt"), SWITCHABLE),
14855                "inference.alternates[0].name",
14856                "'5gpt' is not a driver choice name",
14857            ),
14858            (
14859                capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "local/alt"), SWITCHABLE),
14860                "inference.alternates[0].name",
14861                "is not a driver choice name",
14862            ),
14863            (
14864                capsule(ALT, "    - name: gpt\n      model: \"\"\n      driver:\n        artifact: local/alt\n", SWITCHABLE),
14865                "inference.alternates[0].model",
14866                "missing required field",
14867            ),
14868            (
14869                capsule(ALT, "    - name: gpt\n      model: gpt-5\n", SWITCHABLE),
14870                "inference.alternates[0].driver.artifact",
14871                "missing required field",
14872            ),
14873            (
14874                capsule("", &alternate("gpt", "gpt-5", "local/nowhere"), SWITCHABLE),
14875                "inference.alternates[0].driver.artifact",
14876                "artifact 'local/nowhere' is not declared in artifacts:",
14877            ),
14878            (
14879                capsule(TOOL, &alternate("gpt", "gpt-5", "local/card-api"), SWITCHABLE),
14880                "inference.alternates[0].driver.artifact",
14881                "artifact 'local/card-api' is declared with runtime: tool",
14882            ),
14883            (
14884                capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "local/alt"), SWITCHABLE),
14885                "inference.alternates[0].driver.artifact",
14886                "artifact 'local/alt' declares no gateway.endpoint",
14887            ),
14888            (
14889                capsule(
14890                    ALT,
14891                    "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: local/alt\n        config:\n          thinking: enabled\n",
14892                    SWITCHABLE,
14893                ),
14894                "inference.alternates[0].driver.config",
14895                "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",
14896            ),
14897            (
14898                capsule("", &alternate("same", "m", "local/drv"), SWITCHABLE),
14899                "inference.alternates[0]",
14900                "'same' is driver 'local/drv' with model 'm', which is the primary choice",
14901            ),
14902            (
14903                capsule(
14904                    ALT,
14905                    &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt-too", "gpt-5", "local/alt")),
14906                    SWITCHABLE,
14907                ),
14908                "inference.alternates[1]",
14909                "'gpt-too' is driver 'local/alt' with model 'gpt-5', which alternate 'gpt' already is",
14910            ),
14911            (
14912                capsule(ALT, GPT, ""),
14913                "inference.alternates",
14914                "declares alternates that nothing could ever select",
14915            ),
14916            (
14917                capsule(ALT, GPT, "control:\n  settings: [inference.max_tokens]\n"),
14918                "inference.alternates",
14919                "declares alternates that nothing could ever select",
14920            ),
14921            (
14922                capsule("", "", SWITCHABLE),
14923                "control.settings",
14924                "'inference.driver' selects among inference.alternates, and this capsule declares none",
14925            ),
14926            (
14927                capsule("", "", "control:\n  agent_settings: [inference.driver]\n"),
14928                "control.agent_settings",
14929                "'inference.driver' selects among inference.alternates, and this capsule declares none",
14930            ),
14931            (
14932                capsule(ALT, GPT, "control:\n  agent_settings: [inference.max_tokens]\n"),
14933                "control.agent_settings",
14934                "'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
14935            ),
14936            (
14937                capsule(
14938                    ALT,
14939                    GPT,
14940                    "control:\n  agent_settings: [inference.driver, inference.driver]\n",
14941                ),
14942                "control.agent_settings",
14943                "'inference.driver' is listed more than once",
14944            ),
14945            (
14946                capsule(ALT, GPT, "control:\n  agent_settings: inference.driver\n"),
14947                "control.agent_settings",
14948                "must be a list of names",
14949            ),
14950        ];
14951        for (yaml, field, message) in &cases {
14952            let (got_field, got_message) = refusal(yaml);
14953            assert_eq!(&got_field, field, "{yaml}");
14954            assert!(got_message.contains(message), "{yaml}\n{got_message}");
14955            let rendered = RuntimeManifest::from_yaml_str(yaml)
14956                .unwrap_err()
14957                .to_string();
14958            assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
14959        }
14960    }
14961}
14962
14963#[cfg(test)]
14964mod network_authentication_tests {
14965    use super::*;
14966
14967    fn network_authentication_of(
14968        block: &str,
14969    ) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
14970        RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
14971            .map(|manifest| manifest.network.and_then(|network| network.authentication))
14972    }
14973
14974    fn network_authentication_refusal(block: &str) -> (String, String) {
14975        match network_authentication_of(block) {
14976            Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
14977            other => panic!("expected InvalidNetworkConfig, got {other:?}"),
14978        }
14979    }
14980
14981    #[test]
14982    fn network_authentication_with_credentials_parses_sorted_by_name() {
14983        let auth = network_authentication_of(
14984            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
14985             watcher:\n        scopes: [GetTask, murmur.WatchSession]\n      \
14986             reader:\n        scopes: [resources/files]\n",
14987        )
14988        .unwrap()
14989        .expect("declared");
14990        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
14991        assert_eq!(
14992            auth.credentials,
14993            vec![
14994                DoorCredential {
14995                    name: "reader".to_string(),
14996                    scopes: vec!["resources/files".to_string()],
14997                },
14998                DoorCredential {
14999                    name: "watcher".to_string(),
15000                    // Declared order, not sorted.
15001                    scopes: vec!["GetTask".to_string(), "murmur.WatchSession".to_string()],
15002                },
15003            ]
15004        );
15005    }
15006
15007    #[test]
15008    fn network_authentication_scheme_alone_parses() {
15009        let auth = network_authentication_of("network:\n  authentication:\n    scheme: bearer\n")
15010            .unwrap()
15011            .expect("declared");
15012        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
15013        assert!(auth.credentials.is_empty());
15014    }
15015
15016    #[test]
15017    fn network_authentication_absent_is_a_public_door() {
15018        assert_eq!(network_authentication_of("").unwrap(), None);
15019        assert_eq!(
15020            network_authentication_of("network:\n  internal_port: 8080\n").unwrap(),
15021            None
15022        );
15023        let manifest = RuntimeManifest::from_yaml_str(
15024            "name: cap\nversion: 0.1.0\nnetwork:\n  internal_port: 8080\n",
15025        )
15026        .unwrap();
15027        assert_eq!(
15028            manifest.network,
15029            Some(NetworkConfig {
15030                internal_port: Some(8080),
15031                authentication: None,
15032            })
15033        );
15034    }
15035
15036    #[test]
15037    fn network_authentication_without_a_scheme_is_refused() {
15038        let (field, message) = network_authentication_refusal(
15039            "network:\n  authentication:\n    credentials:\n      w:\n        scopes: [GetTask]\n",
15040        );
15041        assert_eq!(field, "network.authentication.scheme");
15042        assert!(message.contains("required"), "{message}");
15043    }
15044
15045    #[test]
15046    fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
15047        for scheme in ["mtls", "Bearer", "basic"] {
15048            let (field, message) = network_authentication_refusal(&format!(
15049                "network:\n  authentication:\n    scheme: {scheme}\n"
15050            ));
15051            assert_eq!(field, "network.authentication.scheme");
15052            assert!(message.contains("bearer"), "{message}");
15053            assert!(message.contains(scheme), "{message}");
15054        }
15055    }
15056
15057    #[test]
15058    fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
15059        let long = format!("a{}", "b".repeat(32));
15060        for name in [
15061            "operator",
15062            "Watcher",
15063            "1st",
15064            "_x",
15065            "-x",
15066            "wat.cher",
15067            long.as_str(),
15068        ] {
15069            let (field, message) = network_authentication_refusal(&format!(
15070                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15071                 '{name}':\n        scopes: [GetTask]\n"
15072            ));
15073            assert_eq!(field, "network.authentication.credentials", "{name}");
15074            assert!(message.contains(name), "{message}");
15075        }
15076        let longest = format!("a{}", "b".repeat(31));
15077        for name in ["w", "a-b_c9", longest.as_str()] {
15078            network_authentication_of(&format!(
15079                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15080                 {name}:\n        scopes: [GetTask]\n"
15081            ))
15082            .unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
15083        }
15084    }
15085
15086    #[test]
15087    fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
15088        let credential = |body: &str| {
15089            format!(
15090                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15091                 watcher:\n{body}"
15092            )
15093        };
15094        let field = "network.authentication.credentials.watcher.scopes";
15095
15096        let (got, message) = network_authentication_refusal(&credential("        {}\n"));
15097        assert_eq!(got, field);
15098        assert!(message.contains("required"), "{message}");
15099
15100        let (got, message) = network_authentication_refusal(&credential("        scopes: []\n"));
15101        assert_eq!(got, field);
15102        assert!(message.contains("no scope"), "{message}");
15103
15104        let (got, message) =
15105            network_authentication_refusal(&credential("        scopes: [tasks/list]\n"));
15106        assert_eq!(got, field);
15107        assert!(message.contains("tasks/list"), "{message}");
15108        assert!(message.contains("resources/files"), "{message}");
15109
15110        let (got, message) =
15111            network_authentication_refusal(&credential("        scopes: [GetExtendedAgentCard]\n"));
15112        assert_eq!(got, field);
15113        assert!(message.contains("GetExtendedAgentCard"), "{message}");
15114
15115        let (got, message) = network_authentication_refusal(&credential(
15116            "        scopes: [GetTask, murmur.WatchSession, GetTask]\n",
15117        ));
15118        assert_eq!(got, field);
15119        assert!(message.contains("more than once"), "{message}");
15120    }
15121
15122    #[test]
15123    fn network_authentication_refuses_a_retired_scope_naming_its_replacement() {
15124        let field = "network.authentication.credentials.watcher.scopes";
15125        for (retired, replacement) in [
15126            ("message/send", "SendMessage"),
15127            ("message/stream", "SendStreamingMessage"),
15128            ("tasks/get", "GetTask"),
15129            ("tasks/cancel", "CancelTask"),
15130            ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
15131            ("stream/watch", "murmur.WatchSession"),
15132            ("session/stop", "murmur.StopSession"),
15133        ] {
15134            let error = network_authentication_of(&format!(
15135                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15136                 watcher:\n        scopes: [murmur.WatchSession, {retired}]\n"
15137            ))
15138            .unwrap_err();
15139            let rendered = error.to_string();
15140            match error {
15141                RuntimeManifestError::RetiredDoorScope {
15142                    field: got_field,
15143                    scope,
15144                    replacement: got_replacement,
15145                } => {
15146                    assert_eq!(got_field, field);
15147                    assert_eq!(scope, retired);
15148                    assert_eq!(got_replacement, replacement);
15149                }
15150                other => panic!("expected RetiredDoorScope for {retired}, got {other:?}"),
15151            }
15152            assert!(
15153                rendered.contains(&format!(
15154                    "'{retired}' is a retired name of a door method, and the door answers only \
15155                     the names its card lists"
15156                )),
15157                "{rendered}"
15158            );
15159            assert!(rendered.contains(field), "{rendered}");
15160        }
15161    }
15162
15163    #[test]
15164    fn network_authentication_accepts_the_v1_scopes() {
15165        let auth = network_authentication_of(
15166            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15167             watcher:\n        scopes: [GetTask, murmur.WatchSession, murmur.StopSession]\n",
15168        )
15169        .unwrap()
15170        .unwrap();
15171        assert_eq!(
15172            auth.credentials[0].scopes,
15173            ["GetTask", "murmur.WatchSession", "murmur.StopSession"]
15174        );
15175    }
15176
15177    /// The two task methods beside `GetTask` are scopes a credential may list, in the method
15178    /// table's order.
15179    #[test]
15180    fn door_scope_list_tasks_and_subscribe_to_task_load_on_a_credential() {
15181        let auth = network_authentication_of(
15182            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15183             alice:\n        scopes: [SendMessage, ListTasks, SubscribeToTask]\n",
15184        )
15185        .unwrap()
15186        .unwrap();
15187        assert_eq!(
15188            auth.credentials[0].scopes,
15189            ["SendMessage", "ListTasks", "SubscribeToTask"]
15190        );
15191        assert_eq!(
15192            DOOR_SCOPES,
15193            [
15194                "SendMessage",
15195                "SendStreamingMessage",
15196                "murmur.WatchSession",
15197                "GetTask",
15198                "ListTasks",
15199                "CancelTask",
15200                "SubscribeToTask",
15201                "murmur.StopSession",
15202                "resources/files",
15203            ]
15204        );
15205    }
15206
15207    #[test]
15208    fn network_authentication_accepts_every_door_scope() {
15209        let auth = network_authentication_of(&format!(
15210            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15211             everything:\n        scopes: [{}]\n",
15212            DOOR_SCOPES.join(", ")
15213        ))
15214        .unwrap()
15215        .unwrap();
15216        assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
15217        assert_eq!(OPERATOR_CREDENTIAL, "operator");
15218    }
15219
15220    #[test]
15221    fn network_authentication_is_refused_beside_spawn_allow() {
15222        let (field, message) = network_authentication_refusal(
15223            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
15224             allow: [local/worker]\n",
15225        );
15226        assert_eq!(field, "network.authentication");
15227        assert!(message.contains("capabilities.spawn.allow"), "{message}");
15228        assert!(message.contains("token"), "{message}");
15229        let error = RuntimeManifest::from_yaml_str(
15230            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n\
15231             capabilities:\n  spawn:\n    allow: [local/worker]\n",
15232        )
15233        .unwrap_err()
15234        .to_string();
15235        assert!(error.contains("network.authentication"), "{error}");
15236        assert!(error.contains("capabilities.spawn.allow"), "{error}");
15237
15238        // An empty allow-list delegates nothing, so it does not conflict.
15239        network_authentication_of(
15240            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
15241             allow: []\n",
15242        )
15243        .unwrap();
15244    }
15245
15246    #[test]
15247    fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
15248        let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
15249            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n    \
15250             schemes: x\n    credentials:\n      watcher:\n        scopes: [GetTask]\n        \
15251             scope: [GetTask]\n",
15252        )
15253        .unwrap()
15254        .unknown_keys
15255        .into_iter()
15256        .map(|key| (key.key, key.block_path, key.nearest_known))
15257        .collect();
15258        assert_eq!(
15259            keys,
15260            vec![
15261                (
15262                    "schemes".to_string(),
15263                    "network.authentication".to_string(),
15264                    Some("scheme".to_string())
15265                ),
15266                (
15267                    "scope".to_string(),
15268                    "network.authentication.credentials.watcher".to_string(),
15269                    Some("scopes".to_string())
15270                ),
15271            ]
15272        );
15273    }
15274}
15275
15276#[cfg(test)]
15277mod registry_block_tests {
15278    use super::*;
15279
15280    const REGISTRY_BLOCK: &str = "registry:\n  default: local\n";
15281
15282    #[test]
15283    fn registry_block_is_refused_by_both_entry_points() {
15284        let input = format!("name: cap\nversion: 0.0.1\nartifacts: []\n{REGISTRY_BLOCK}");
15285        for result in [
15286            RuntimeManifest::from_yaml_str(&input),
15287            RuntimeManifest::from_yaml_str_without_secrets(&input),
15288        ] {
15289            let err = result.unwrap_err();
15290            assert!(
15291                matches!(err, RuntimeManifestError::RegistryBlock),
15292                "{err:?}"
15293            );
15294            assert_eq!(err.to_string(), REGISTRY_BLOCK_MESSAGE);
15295        }
15296    }
15297
15298    #[test]
15299    fn registry_block_is_refused_before_any_other_field() {
15300        // `name` is missing and `lifecycle.queue_depth: 0` under `queue` is refused on its own;
15301        // the registry refusal still wins.
15302        let input = format!(
15303            "version: 0.0.1\nlifecycle:\n  task_acceptance: queue\n  queue_depth: 0\n\
15304             {REGISTRY_BLOCK}"
15305        );
15306        let err = RuntimeManifest::from_yaml_str(&input).unwrap_err();
15307        assert!(
15308            matches!(err, RuntimeManifestError::RegistryBlock),
15309            "{err:?}"
15310        );
15311    }
15312
15313    #[test]
15314    fn registry_block_refusal_applies_whatever_the_block_holds() {
15315        for block in ["registry: local\n", "registry: {}\n", "registry: []\n"] {
15316            let input = format!("name: cap\nversion: 0.0.1\n{block}");
15317            assert!(
15318                matches!(
15319                    RuntimeManifest::from_yaml_str(&input),
15320                    Err(RuntimeManifestError::RegistryBlock)
15321                ),
15322                "{block}"
15323            );
15324        }
15325    }
15326
15327    #[test]
15328    fn a_manifest_without_a_registry_block_parses_unchanged() {
15329        let manifest =
15330            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
15331        assert_eq!(manifest.name, "cap");
15332        assert_eq!(manifest.version, "0.0.1");
15333    }
15334}
15335
15336#[cfg(test)]
15337mod scoped_name_tests {
15338    use super::*;
15339
15340    fn name_refusal(yaml: &str) -> (String, String, Option<String>) {
15341        match RuntimeManifest::from_yaml_str(yaml) {
15342            Err(RuntimeManifestError::InvalidArtifactName {
15343                field,
15344                message,
15345                hint,
15346            }) => (field, message, hint),
15347            other => panic!("expected InvalidArtifactName, got {other:?}"),
15348        }
15349    }
15350
15351    #[test]
15352    fn scoped_name_a_bare_artifact_entry_is_refused_with_the_scoped_forms() {
15353        let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: tool-editor\n    \
15354                    version: 0.1.0\n    runtime: tool\n";
15355        let (field, message, hint) = name_refusal(yaml);
15356        assert_eq!(field, "artifacts[0].name");
15357        assert_eq!(
15358            message,
15359            "'tool-editor' is not a full artifact name: an artifact is named <scope>/<name>"
15360        );
15361        assert_eq!(
15362            hint.as_deref(),
15363            Some(
15364                "name it with its scope: 'murmur/tool-editor' for a Murmur default artifact, \
15365                 '<owner>/tool-editor' for one published from github.com/<owner>, or \
15366                 'local/tool-editor' for one installed from a .mur.zip file"
15367            )
15368        );
15369        let rendered = RuntimeManifest::from_yaml_str(yaml)
15370            .unwrap_err()
15371            .to_string();
15372        assert!(
15373            rendered.starts_with("murmur.yaml: invalid artifact name in 'artifacts[0].name': "),
15374            "{rendered}"
15375        );
15376    }
15377
15378    #[test]
15379    fn scoped_name_a_source_entry_keeps_its_bare_name() {
15380        let bare = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: style\n    \
15381                    source: ./skills/style\n    runtime: skill\n";
15382        RuntimeManifest::from_yaml_str(bare).expect("a source: entry is named by its bare name");
15383
15384        let (field, message, hint) = name_refusal(&bare.replace("name: style", "name: ana/style"));
15385        assert_eq!(field, "artifacts[0].name");
15386        assert!(
15387            message
15388                .starts_with("'ana/style' declares 'source:', so it is named by its bare name; "),
15389            "{message}"
15390        );
15391        assert_eq!(hint.as_deref(), Some("drop the scope: name it 'style'"));
15392    }
15393
15394    #[test]
15395    fn scoped_name_spawn_allow_refuses_a_bare_entry_and_a_shared_bare_part() {
15396        let with_allow = |allow: &str| {
15397            format!("name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    allow: [{allow}]\n")
15398        };
15399        let manifest = RuntimeManifest::from_yaml_str(&with_allow("local/worker, ana/reviewer"))
15400            .expect("full names in two scopes with distinct bare parts parse");
15401        assert_eq!(
15402            manifest.capabilities.unwrap().spawn.unwrap().allow,
15403            vec!["local/worker".to_string(), "ana/reviewer".to_string()]
15404        );
15405
15406        let (field, message, hint) = name_refusal(&with_allow("worker"));
15407        assert_eq!(field, "capabilities.spawn.allow[0]");
15408        assert!(
15409            message.contains("'worker' is not a full artifact name"),
15410            "{message}"
15411        );
15412        assert!(hint.unwrap().contains("local/worker"));
15413
15414        let (field, message, hint) = name_refusal(&with_allow("local/worker, ana/worker"));
15415        assert_eq!(field, "capabilities.spawn.allow[1]");
15416        assert_eq!(
15417            message,
15418            "'local/worker' and 'ana/worker' share the name 'worker', which is how delegate-task \
15419             offers a capsule to the model"
15420        );
15421        assert_eq!(
15422            hint.as_deref(),
15423            Some("keep one of 'local/worker' and 'ana/worker' in capabilities.spawn.allow")
15424        );
15425    }
15426
15427    #[test]
15428    fn spawn_workdir_snapshot_lowers_beside_allow_and_is_empty_when_absent() {
15429        let yaml = "name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    \
15430                    allow: [local/reviewer, local/worker]\n    \
15431                    workdir_snapshot: [local/reviewer]\n";
15432        let spawn = RuntimeManifest::from_yaml_str(yaml)
15433            .expect("a snapshot entry that is also in allow parses")
15434            .capabilities
15435            .unwrap()
15436            .spawn
15437            .unwrap();
15438        assert_eq!(
15439            spawn,
15440            SpawnCapabilities {
15441                allow: vec!["local/reviewer".to_string(), "local/worker".to_string()],
15442                workdir_snapshot: vec!["local/reviewer".to_string()],
15443            }
15444        );
15445
15446        let absent = "name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    \
15447                      allow: [local/reviewer]\n";
15448        let spawn = RuntimeManifest::from_yaml_str(absent)
15449            .unwrap()
15450            .capabilities
15451            .unwrap()
15452            .spawn
15453            .unwrap();
15454        assert!(spawn.workdir_snapshot.is_empty());
15455    }
15456
15457    #[test]
15458    fn spawn_workdir_snapshot_refuses_an_entry_outside_allow_a_bare_name_and_a_duplicate() {
15459        let with = |snapshot: &str| {
15460            format!(
15461                "name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    \
15462                 allow: [local/reviewer, local/worker]\n    workdir_snapshot: [{snapshot}]\n"
15463            )
15464        };
15465
15466        let (field, message, hint) = name_refusal(&with("local/reviewer, local/other"));
15467        assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
15468        assert_eq!(
15469            message,
15470            "'local/other' is not in capabilities.spawn.allow, so this capsule cannot delegate \
15471             to it"
15472        );
15473        assert!(
15474            hint.as_deref()
15475                .is_some_and(|hint| hint.contains("capabilities.spawn.allow")),
15476            "{hint:?}"
15477        );
15478
15479        let (field, message, _) = name_refusal(&with("reviewer"));
15480        assert_eq!(field, "capabilities.spawn.workdir_snapshot[0]");
15481        assert!(
15482            message.contains("'reviewer' is not a full artifact name"),
15483            "{message}"
15484        );
15485
15486        let (field, message, hint) = name_refusal(&with("local/reviewer, local/reviewer"));
15487        assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
15488        assert_eq!(message, "'local/reviewer' is listed more than once");
15489        assert_eq!(
15490            hint.as_deref(),
15491            Some("keep one 'local/reviewer' in capabilities.spawn.workdir_snapshot")
15492        );
15493    }
15494
15495    #[test]
15496    fn scoped_name_a_reference_by_bare_part_names_the_declared_entry() {
15497        let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    \
15498                    version: 0.1.0\n    runtime: driver\n    gateway:\n      \
15499                    endpoint: https://api.anthropic.com\n      api_key: k\ninference:\n  \
15500                    transport: http\n  model: m\n  driver:\n    artifact: driver-anthropic\n";
15501        let message = RuntimeManifest::from_yaml_str(yaml)
15502            .unwrap_err()
15503            .to_string();
15504        assert!(
15505            message.contains(
15506                "artifact 'driver-anthropic' is not declared in artifacts: (the declared \
15507                 'murmur/driver-anthropic' has that name; refer to it as \
15508                 'murmur/driver-anthropic')"
15509            ),
15510            "{message}"
15511        );
15512    }
15513}
15514
15515#[cfg(test)]
15516mod inference_retry_tests {
15517    use super::*;
15518
15519    const HTTP: &str = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/drv\n    \
15520                        version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: \
15521                        http://127.0.0.1:8080\n      api_key: sk-test\ninference:\n  transport: \
15522                        http\n  model: m\n  driver:\n    artifact: local/drv\n";
15523    const PROCESS: &str = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/drv\n    \
15524                           version: 0.1.0\n    runtime: driver\ninference:\n  transport: \
15525                           process\n  driver:\n    artifact: local/drv\n";
15526
15527    fn retry(yaml: &str) -> InferenceRetry {
15528        RuntimeManifest::from_yaml_str(yaml)
15529            .unwrap()
15530            .inference
15531            .unwrap()
15532            .retry
15533    }
15534
15535    fn refusal(yaml: &str) -> (String, String) {
15536        match RuntimeManifest::from_yaml_str(yaml) {
15537            Err(RuntimeManifestError::InvalidInferenceConfig { field, message }) => {
15538                (field, message)
15539            }
15540            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
15541        }
15542    }
15543
15544    #[test]
15545    fn inference_retry_defaults_when_absent_and_per_key_when_partial() {
15546        assert_eq!(retry(HTTP), InferenceRetry::default());
15547        assert_eq!(
15548            InferenceRetry::default(),
15549            InferenceRetry {
15550                max_retries: 6,
15551                initial_delay_ms: 1_000,
15552                max_delay_ms: 60_000,
15553            }
15554        );
15555        assert_eq!(
15556            retry(&format!("{HTTP}  retry:\n    max_retries: 2\n")),
15557            InferenceRetry {
15558                max_retries: 2,
15559                ..InferenceRetry::default()
15560            }
15561        );
15562        assert_eq!(
15563            retry(&format!(
15564                "{HTTP}  retry:\n    initial_delay_ms: 10\n    max_delay_ms: 2000\n"
15565            )),
15566            InferenceRetry {
15567                max_retries: 6,
15568                initial_delay_ms: 10,
15569                max_delay_ms: 2000,
15570            }
15571        );
15572        assert_eq!(
15573            retry(&format!(
15574                "{HTTP}  retry:\n    max_retries: 0\n    initial_delay_ms: 60000\n    \
15575                 max_delay_ms: 3600000\n"
15576            )),
15577            InferenceRetry {
15578                max_retries: 0,
15579                initial_delay_ms: 60_000,
15580                max_delay_ms: 3_600_000,
15581            }
15582        );
15583        assert_eq!(retry(PROCESS), InferenceRetry::default());
15584    }
15585
15586    #[test]
15587    fn inference_retry_bounds_are_refused_naming_the_field() {
15588        for (block, field) in [
15589            ("max_retries: 21", "inference.retry.max_retries"),
15590            ("initial_delay_ms: 0", "inference.retry.initial_delay_ms"),
15591            (
15592                "initial_delay_ms: 60001",
15593                "inference.retry.initial_delay_ms",
15594            ),
15595            ("max_delay_ms: 0", "inference.retry.max_delay_ms"),
15596            ("max_delay_ms: 3600001", "inference.retry.max_delay_ms"),
15597            (
15598                "initial_delay_ms: 5000\n    max_delay_ms: 1000",
15599                "inference.retry.max_delay_ms",
15600            ),
15601        ] {
15602            let (got, message) = refusal(&format!("{HTTP}  retry:\n    {block}\n"));
15603            assert_eq!(got, field, "{block}: {message}");
15604        }
15605        let (_, message) = refusal(&format!(
15606            "{HTTP}  retry:\n    initial_delay_ms: 5000\n    max_delay_ms: 1000\n"
15607        ));
15608        assert!(message.contains("initial_delay_ms"), "{message}");
15609    }
15610
15611    #[test]
15612    fn inference_retry_is_refused_under_transport_process() {
15613        let (field, message) = refusal(&format!("{PROCESS}  retry:\n    max_retries: 2\n"));
15614        assert_eq!(field, "inference.retry");
15615        assert_eq!(
15616            message,
15617            "is not valid with transport: process; the harness retries its own provider calls"
15618        );
15619    }
15620
15621    #[test]
15622    fn inference_retry_unknown_key_is_reported_at_its_block() {
15623        let manifest = RuntimeManifest::from_yaml_str(&format!(
15624            "{HTTP}  retry:\n    max_retries: 2\n    jitter: full\n"
15625        ))
15626        .unwrap();
15627        let keys: Vec<(&str, &str)> = manifest
15628            .unknown_keys
15629            .iter()
15630            .map(|key| (key.key.as_str(), key.block_path.as_str()))
15631            .collect();
15632        assert_eq!(keys, vec![("jitter", "inference.retry")]);
15633    }
15634}
15635
15636#[cfg(test)]
15637mod host_paths_tests {
15638    use super::*;
15639
15640    const ENTRY: &str = "  - path: ~/.cargo\n    access: read-write\n";
15641
15642    /// The dotted field both entry points refuse `input` for; they must agree.
15643    fn refused_field(input: &str) -> String {
15644        let fields: Vec<String> = [
15645            RuntimeManifest::from_yaml_str(input),
15646            RuntimeManifest::from_yaml_str_without_secrets(input),
15647        ]
15648        .into_iter()
15649        .map(|result| match result {
15650            Err(RuntimeManifestError::HostPathsDeclared { field }) => field,
15651            other => panic!("expected HostPathsDeclared, got {other:?}"),
15652        })
15653        .collect();
15654        assert_eq!(
15655            fields[0], fields[1],
15656            "both entry points refuse the same field"
15657        );
15658        fields[0].clone()
15659    }
15660
15661    #[test]
15662    fn host_paths_is_refused_wherever_a_manifest_writes_it() {
15663        let top = format!("name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths:\n{ENTRY}");
15664        assert_eq!(refused_field(&top), "host_paths");
15665
15666        let capabilities = format!(
15667            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  host_paths:\n  {}",
15668            ENTRY.replace('\n', "\n  ").trim_end()
15669        );
15670        assert_eq!(refused_field(&capabilities), "capabilities.host_paths");
15671
15672        let filesystem = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  \
15673                          filesystem:\n    host_paths: []\n";
15674        assert_eq!(
15675            refused_field(filesystem),
15676            "capabilities.filesystem.host_paths"
15677        );
15678
15679        let artifact = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/tool-a\n    \
15680                        version: 0.1.0\n    runtime: tool\n  - name: murmur/tool-b\n    \
15681                        version: 0.1.0\n    runtime: tool\n    capabilities:\n      \
15682                        host_paths: []\n";
15683        assert_eq!(
15684            refused_field(artifact),
15685            "artifacts[1].capabilities.host_paths"
15686        );
15687    }
15688
15689    #[test]
15690    fn the_refusal_names_the_field_and_is_never_an_unknown_key_warning() {
15691        let input = "name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths: []\n";
15692        let error = RuntimeManifest::from_yaml_str(input).unwrap_err();
15693        assert!(error.to_string().contains("'host_paths'"), "{error}");
15694        assert!(error.to_string().contains("murmur.yaml"), "{error}");
15695    }
15696
15697    #[test]
15698    fn a_manifest_without_host_paths_parses_unchanged() {
15699        let input = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  filesystem:\n    \
15700                     workdir_exec: false\n";
15701        assert!(RuntimeManifest::from_yaml_str(input).is_ok());
15702    }
15703}