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    /// Defaults to 10 seconds. A command that exits inside this window returns its output to the
64    /// turn as it always has; one that outruns it hands the turn a handle, keeps running, and
65    /// reports back later as a `completion`-origin task. `0` demotes at the first poll after the
66    /// spawn, so effectively every command detaches.
67    pub shell_grace_secs: u64,
68    /// The single bound on a delegation, covering both waits one can have: how long a sub-capsule
69    /// started by `delegate-task` is watched, and how long a plan `capsule` step waits for its
70    /// answer. Defaults to 600 seconds. Absent is a ceiling rather than an absence: a capsule that
71    /// never declares this one is still bounded, because an unbounded delegation leaves a wedged
72    /// sub-capsule running for as long as the capsule that started it. `0` gives up at the first
73    /// poll after the task is delivered, the same reading `shell_grace_secs: 0` has.
74    ///
75    /// Reaching the deadline ends the sub-capsule. A `delegate-task` caller is told in a
76    /// `completion`-origin task carrying `status: terminated` and a detail naming the bound; a plan
77    /// step's call returns `timed_out`. The clock starts when the sub-capsule reports itself ready,
78    /// not when its process is spawned, so a slow host does not spend this window on staging.
79    /// `MURMUR_DELEGATION_TIMEOUT_SECS` overrides this value when it names a positive number of
80    /// seconds.
81    pub delegation_deadline_secs: u64,
82    /// How long before a `mur run --deadline` the runtime tells the agent, once, that its session
83    /// is about to end. `None` takes the default the runtime derives from the deadline: five
84    /// minutes, or a tenth of the deadline when that is shorter, and at least one second. `0`
85    /// sends no notice. Inert for a session launched with no deadline.
86    pub deadline_notice_secs: Option<u64>,
87    /// Keeps a task's agent working past its turn ends: each time an attempt ends at a turn end,
88    /// the runtime sends [`KeepWorking::message`] as the next user message in the same
89    /// conversation, up to [`KeepWorking::limit`] times per task. `None` (the default, and what a
90    /// manifest without `lifecycle.keep_working` gets) ends a task at its first turn end.
91    pub keep_working: Option<KeepWorking>,
92    /// What "done" means for a task: each time an attempt stops at a turn end, the runtime runs
93    /// [`DoneCheck::command`] in the work folder and, while it fails, reopens the task with its
94    /// output, up to [`DoneCheck::limit`] times per task. `None` (the default, and what a manifest
95    /// without `lifecycle.done_check` gets) ends a task when the model stops.
96    pub done_check: Option<DoneCheck>,
97}
98
99/// `lifecycle.keep_working`: what the runtime sends when a task's attempt ends at a turn end, and
100/// how many times per task it does so. Both fields are required; see
101/// [`LifecycleConfig::keep_working`].
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct KeepWorking {
104    /// Sent verbatim, unfenced, as the next user message. Never blank.
105    pub message: String,
106    /// How many continuations one task may receive. At least 1.
107    pub limit: u32,
108}
109
110/// `lifecycle.done_check`: the command whose exit status says whether a task's work is done, and
111/// how many times per task a failing run may reopen the task; see [`LifecycleConfig::done_check`].
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct DoneCheck {
114    /// As declared. Split with [`split_shell_words`]; its first word is a
115    /// `capabilities.shell.allow` entry.
116    pub command: String,
117    /// How many times one task may be reopened by a failing run, counted across hook reopens and
118    /// separately from `max_task_reopens`. `0` runs and records the check without reopening.
119    pub limit: u32,
120    /// How long one run may take before the runtime kills it. At least 1.
121    pub timeout_secs: u64,
122}
123
124impl DoneCheck {
125    /// The default [`DoneCheck::timeout_secs`].
126    pub const DEFAULT_TIMEOUT_SECS: u64 = 600;
127
128    /// [`DoneCheck::command`] as the program and its arguments.
129    pub fn argv(&self) -> Vec<String> {
130        split_shell_words(&self.command)
131    }
132}
133
134impl LifecycleConfig {
135    /// Whether a session under this lifecycle is still there to receive work that arrives after
136    /// the task which started it ended — a demoted shell command's completion, a delegation's
137    /// outcome.
138    ///
139    /// `queue` + `sleep` is the only combination that qualifies, and the delegation lane and the
140    /// shell lane must agree on it: a capsule warned about one and not the other would be told its
141    /// lifecycle is both adequate and inadequate for the same reason.
142    pub fn can_receive_background_tasks(&self) -> bool {
143        self.task_acceptance == TaskAcceptance::Queue && self.after_task == AfterTask::Sleep
144    }
145
146    /// Whether this lifecycle's queue has room for a waiting task: `false` exactly for
147    /// `task_acceptance: queue` at `queue_depth: 0`, which the A2A door would turn into a door
148    /// refusing every task, with nowhere for background completions to wait.
149    ///
150    /// The manifest refuses such a lifecycle at load
151    /// ([`RuntimeManifestError::QueueWithoutRoom`]) and staging refuses one a
152    /// `--lifecycle-task-acceptance queue` override produces; both ask this. `queue_depth` means
153    /// nothing under `single` or `none`, so any value passes there.
154    pub fn queue_has_room(&self) -> bool {
155        !(self.task_acceptance == TaskAcceptance::Queue && self.queue_depth == 0)
156    }
157
158    /// How many tasks a capsule under this lifecycle holds at once: one running task plus
159    /// `queue_depth` waiting ones under `queue`, a single task under `single`, none under `none`.
160    ///
161    /// A `queue` lifecycle at `queue_depth: 0` is refused at load and at staging
162    /// ([`Self::queue_has_room`]), so no running capsule has one. The A2A door is the authority
163    /// this mirrors: the task registry in `capsule-runtime` (`TaskRegistry::can_accept`) decides
164    /// what is rejected, and a test there fails if the two drift apart.
165    pub fn tasks_held_at_once(&self) -> usize {
166        match self.task_acceptance {
167            TaskAcceptance::None => 0,
168            TaskAcceptance::Single => 1,
169            TaskAcceptance::Queue => self.queue_depth + 1,
170        }
171    }
172}
173
174impl Default for LifecycleConfig {
175    fn default() -> Self {
176        Self {
177            task_acceptance: TaskAcceptance::Single,
178            after_task: AfterTask::Exit,
179            queue_depth: 1,
180            input_timeout_secs: None,
181            conversation_mode: ConversationMode::Stateless,
182            max_task_reopens: 1,
183            shell_grace_secs: 10,
184            delegation_deadline_secs: 600,
185            deadline_notice_secs: None,
186            keep_working: None,
187            done_check: None,
188        }
189    }
190}
191
192/// Lifecycle override: allows a parent (mur-roost) or CLI flag to constrain
193/// the lifecycle down from what the manifest declares.
194#[derive(Debug, Clone, Default)]
195pub struct LifecycleOverride {
196    pub task_acceptance: Option<TaskAcceptance>,
197    pub after_task: Option<AfterTask>,
198}
199
200/// Artifact taxonomy for entries declared in a capsule manifest.
201///
202/// Variant meanings:
203///
204/// - `Tool`: agent-callable tool (WASM component or native binary). Visible in MURMUR.md and the
205///   LLM tool list. Implementation details (wasm vs native) are declared in the artifact's own
206///   murmur.yaml via `implementation:`.
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/native 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/// How a `runtime: tool` artifact is implemented.
252///
253/// Declared via `implementation:` in the artifact's own murmur.yaml.
254/// Defaults to `Wasm` when the field is absent.
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum ArtifactImplementation {
257    Wasm,
258    Native,
259}
260
261/// Read the `implementation:` field from an artifact's murmur.yaml.
262/// Defaults to `Wasm` when the field is absent or unparseable.
263pub fn read_tool_implementation(
264    artifact_manifest_path: &Path,
265) -> Result<ArtifactImplementation, RuntimeManifestError> {
266    let content =
267        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
268            path: artifact_manifest_path.display().to_string(),
269            source,
270        })?;
271    Ok(parse_tool_implementation_from_yaml(&content))
272}
273
274/// Parse `implementation:` from an in-memory manifest YAML string.
275/// Defaults to `Wasm` when the field is absent or unparseable.
276pub fn parse_tool_implementation_from_yaml(yaml: &str) -> ArtifactImplementation {
277    serde_norway::from_str::<serde_norway::Value>(yaml)
278        .map(|v| {
279            v.as_mapping()
280                .and_then(|m| m.get(serde_norway::Value::String("implementation".to_string())))
281                .and_then(serde_norway::Value::as_str)
282                .map(|s| match s {
283                    "native" => ArtifactImplementation::Native,
284                    _ => ArtifactImplementation::Wasm,
285                })
286                .unwrap_or(ArtifactImplementation::Wasm)
287        })
288        .unwrap_or(ArtifactImplementation::Wasm)
289}
290
291// ── Hook configuration types ──────────────────────────────────────────────────
292
293/// Which lifecycle event(s) a hook binds to.
294///
295/// Declared via `binding:` in the artifact's own murmur.yaml.
296/// Defaults to `All` when the field is absent (receives all session events).
297#[derive(Debug, Clone, PartialEq, Eq)]
298pub enum HookBinding {
299    /// `"on-stage"` — fires during `stage_session`; always blocking.
300    OnStage,
301    /// `"on-session-start"` — fires once before the first inference turn.
302    OnSessionStart,
303    /// `"on-task-start"` — fires once per task, before that task's first inference turn.
304    OnTaskStart,
305    /// `"on-inference"` — fires after each inference response is parsed.
306    OnInference,
307    /// `"on-tool-call"` — fires after each model-requested tool invocation.
308    OnToolCall,
309    /// `"on-shell"` — fires after each shell command returns.
310    OnShell,
311    /// `"on-compaction"` — fires when the session token threshold is reached.
312    OnCompaction,
313    /// `"on-task-end"` — fires once per task, after that task's agent loop returns.
314    OnTaskEnd,
315    /// `"on-session-end"` — fires at session teardown.
316    OnSessionEnd,
317    /// No `binding:` field — receives all session events (not on-stage).
318    All,
319}
320
321impl HookBinding {
322    /// The manifest spelling of this binding, which is also the WIT lifecycle function
323    /// name the runtime dispatches to — the key `capsule-runtime`'s honored-arm table is
324    /// written against. [`HookBinding::All`] has no spelling of its own (it is what an
325    /// omitted `binding:` means), so it renders as `"all"` for diagnostics only.
326    #[must_use]
327    pub fn as_str(&self) -> &'static str {
328        match self {
329            Self::OnStage => "on-stage",
330            Self::OnSessionStart => "on-session-start",
331            Self::OnTaskStart => "on-task-start",
332            Self::OnInference => "on-inference",
333            Self::OnToolCall => "on-tool-call",
334            Self::OnShell => "on-shell",
335            Self::OnCompaction => "on-compaction",
336            Self::OnTaskEnd => "on-task-end",
337            Self::OnSessionEnd => "on-session-end",
338            Self::All => "all",
339        }
340    }
341}
342
343/// Whether the runtime waits for the hook result.
344///
345/// Declared via `execution_mode:` in the artifact's own murmur.yaml.
346/// Defaults to `Blocking` when the field is absent.
347#[derive(Debug, Clone, PartialEq, Eq)]
348pub enum HookExecutionMode {
349    /// `"blocking"` — runtime waits for result before proceeding.
350    Blocking,
351    /// `"async"` — runtime proceeds immediately; result not committed.
352    Async,
353}
354
355/// What the runtime does with a successful hook output.
356///
357/// Declared via `commit_policy:` in the artifact's own murmur.yaml.
358/// Defaults to `None` when the field is absent.
359#[derive(Debug, Clone, PartialEq, Eq)]
360pub enum HookCommitPolicy {
361    /// `"none"` — output discarded.
362    None,
363    /// `"replace-context"` — runtime replaces conversation history. Valid only with on-compaction.
364    ReplaceContext,
365    /// `"write-manifests"` — runtime writes tool manifests to the runtime directory's tools/. Valid
366    /// only with on-stage.
367    WriteManifests,
368    /// `"reopen-task"` — runtime re-runs the task's agent loop with the hook's feedback.
369    /// Valid only with on-task-end.
370    ReopenTask,
371    /// `"seed-context"` — runtime places the hook's messages at the head of the task's
372    /// first message list, under the `context.seed_budget` ceiling. Valid only with
373    /// on-task-start.
374    SeedContext,
375    /// `"deny"` — the hook decides, immediately before the call is dispatched, whether the
376    /// call happens at all. Valid only with on-shell and on-tool-call, and only with an
377    /// explicit `binding:` naming one of them.
378    ///
379    /// The only policy that *subtracts*: every other value commits something the hook
380    /// produced, this one refuses something the capsule asked for. It narrows the manifest's
381    /// grant and can never widen it.
382    Deny,
383}
384
385impl HookCommitPolicy {
386    /// The manifest spelling of this policy, for diagnostics.
387    #[must_use]
388    pub fn as_str(&self) -> &'static str {
389        match self {
390            Self::None => "none",
391            Self::ReplaceContext => "replace-context",
392            Self::WriteManifests => "write-manifests",
393            Self::ReopenTask => "reopen-task",
394            Self::SeedContext => "seed-context",
395            Self::Deny => "deny",
396        }
397    }
398}
399
400/// What the runtime does with a lifecycle event for an `execution_mode: async` hook
401/// whose job queue is already full.
402///
403/// Declared via `on_overflow:` on the hook's entry in the **capsule operator's own**
404/// manifest — not in the hook artifact's bundled murmur.yaml, because keeping up with
405/// the agent loop is the operator's trade-off, not the hook author's. Inert on a hook
406/// that turns out to be `execution_mode: blocking` (a blocking hook is never queued);
407/// the operator-manifest parser cannot tell the two apart, since the mode lives in the
408/// artifact's own manifest and is only known at staging time.
409#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum HookOverflowPolicy {
412    /// `"drop"` (the default) — discard the event and count it. The agent loop never
413    /// waits on a slow hook; telemetry is lossy under sustained overload, and the loss
414    /// is reported rather than silent.
415    #[default]
416    Drop,
417    /// `"block"` — the agent loop waits for the hook's worker to make room. No event is
418    /// lost, at the cost of putting a slow hook back on the critical path.
419    Block,
420}
421
422impl HookOverflowPolicy {
423    /// The manifest spelling of this policy, for diagnostics.
424    #[must_use]
425    pub fn as_str(&self) -> &'static str {
426        match self {
427            Self::Drop => "drop",
428            Self::Block => "block",
429        }
430    }
431}
432
433/// Behavioral contract for a hook artifact, read from its own murmur.yaml.
434#[derive(Debug, Clone, PartialEq, Eq)]
435pub struct HookConfig {
436    pub binding: HookBinding,
437    pub execution_mode: HookExecutionMode,
438    pub commit_policy: HookCommitPolicy,
439}
440
441impl Default for HookConfig {
442    fn default() -> Self {
443        Self {
444            binding: HookBinding::All,
445            execution_mode: HookExecutionMode::Blocking,
446            commit_policy: HookCommitPolicy::None,
447        }
448    }
449}
450
451/// The one `commit_policy` a hook bound to `binding` can actually have committed, or
452/// `None` when no `commit_policy` value other than `none` is ever honored for it.
453///
454/// This is the manifest-side mirror of `capsule-runtime`'s `HONORED_OUTPUT_ARM` table,
455/// which decides — keyed on the same lifecycle function names [`HookBinding::as_str`]
456/// produces — which `hook-output` arm the runtime commits for each event. Two bindings
457/// return `None` for different reasons, and the distinction matters:
458///
459/// - `on-session-start` and `on-session-end` honor no arm at all; every output from them
460///   is discarded.
461/// - `on-tool-call` and `on-shell` honor `deny`, and only at their decision-point dispatch
462///   — the one that runs before the call. Their post-call observation dispatch honors
463///   nothing, which is why the runtime's table is keyed on the dispatch phase as well as
464///   the event name.
465/// - `on-inference` *does* honor an arm (`artifact`), but that arm has no
466///   `commit_policy` spelling — [`HookCommitPolicy`] has no `Artifact` variant — so no
467///   non-`none` policy is declarable for it either.
468///
469/// [`HookBinding::All`] (an omitted `binding:`) also returns `None`, and that is a third
470/// reason again: an `All`-bound hook is dispatched to *every* event, so there is no single
471/// honored policy to name. It is deliberately **not** validated against this function —
472/// [`parse_hook_config_from_yaml`] accepts every `commit_policy` for it, with the single
473/// exception of [`HookCommitPolicy::Deny`], which that function rejects outright for `All`.
474#[must_use]
475pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
476    match binding {
477        HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
478        HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
479        HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
480        HookBinding::OnTaskStart => Some(HookCommitPolicy::SeedContext),
481        HookBinding::OnToolCall | HookBinding::OnShell => Some(HookCommitPolicy::Deny),
482        HookBinding::OnSessionStart
483        | HookBinding::OnInference
484        | HookBinding::OnSessionEnd
485        | HookBinding::All => None,
486    }
487}
488
489/// Read a hook's behavioral contract from its murmur.yaml at the given path.
490pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
491    let content =
492        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
493            path: artifact_manifest_path.display().to_string(),
494            source,
495        })?;
496    parse_hook_config_from_yaml(&content).map_err(|msg| {
497        RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
498    })
499}
500
501/// Parse hook behavioral contract from an in-memory manifest YAML string.
502///
503/// `commit_policy: deny` is the one policy an omitted `binding:` cannot carry. Every other
504/// policy is accepted for [`HookBinding::All`] because an `All`-bound hook reaches every
505/// event and the runtime simply discards what a given event does not honor. `deny` is not
506/// discardable in that way: it is answered at a *decision point* standing in front of a call,
507/// and an `All`-bound hook would be asked to decide on shell and tool calls it was never
508/// written to judge — with every failure to answer, including a trap or a timeout, refusing
509/// the call. The binding is therefore required to name which of the two events the hook gates.
510pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
511    let v: serde_norway::Value =
512        serde_norway::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
513
514    let binding = v
515        .as_mapping()
516        .and_then(|m| m.get(serde_norway::Value::String("binding".to_string())))
517        .and_then(serde_norway::Value::as_str)
518        .map(|s| match s {
519            "on-stage" => Ok(HookBinding::OnStage),
520            "on-session-start" => Ok(HookBinding::OnSessionStart),
521            "on-task-start" => Ok(HookBinding::OnTaskStart),
522            "on-inference" => Ok(HookBinding::OnInference),
523            "on-tool-call" => Ok(HookBinding::OnToolCall),
524            "on-shell" => Ok(HookBinding::OnShell),
525            "on-compaction" => Ok(HookBinding::OnCompaction),
526            "on-task-end" => Ok(HookBinding::OnTaskEnd),
527            "on-session-end" => Ok(HookBinding::OnSessionEnd),
528            other => Err(format!(
529                "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
530                 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
531            )),
532        })
533        .transpose()?
534        .unwrap_or(HookBinding::All);
535
536    let execution_mode = v
537        .as_mapping()
538        .and_then(|m| m.get(serde_norway::Value::String("execution_mode".to_string())))
539        .and_then(serde_norway::Value::as_str)
540        .map(|s| match s {
541            "blocking" => Ok(HookExecutionMode::Blocking),
542            "async" => Ok(HookExecutionMode::Async),
543            other => Err(format!(
544                "unknown execution_mode '{other}'; expected: blocking, async"
545            )),
546        })
547        .transpose()?
548        .unwrap_or(HookExecutionMode::Blocking);
549
550    let commit_policy = v
551        .as_mapping()
552        .and_then(|m| m.get(serde_norway::Value::String("commit_policy".to_string())))
553        .and_then(serde_norway::Value::as_str)
554        .map(|s| match s {
555            "none" => Ok(HookCommitPolicy::None),
556            "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
557            "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
558            "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
559            "seed-context" => Ok(HookCommitPolicy::SeedContext),
560            "deny" => Ok(HookCommitPolicy::Deny),
561            other => Err(format!(
562                "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
563                 reopen-task, seed-context, deny"
564            )),
565        })
566        .transpose()?
567        .unwrap_or(HookCommitPolicy::None);
568
569    // Validation
570    if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
571        return Err(format!(
572            "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
573             (got '{}')",
574            commit_policy.as_str()
575        ));
576    }
577    if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
578        return Err(
579            "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
580             'on-stage'"
581                .to_string(),
582        );
583    }
584    // The one policy `All` cannot carry: a hook that does not name which of the two gated
585    // events it decides on would be dispatched at decision points it was never written for,
586    // and every non-`none` answer there refuses a call.
587    if binding == HookBinding::All && commit_policy == HookCommitPolicy::Deny {
588        return Err(
589            "commit_policy 'deny' requires an explicit binding: 'on-shell' or 'on-tool-call'; \
590             a hook with no binding: is dispatched at every event, including decision points \
591             it was not written to decide"
592                .to_string(),
593        );
594    }
595    // `binding` is the single source of truth for what the runtime commits; a declared
596    // `commit_policy` the binding can never honor is a mistake that is fully knowable here,
597    // at staging time, rather than mid-session as a `hook_dispatch_error`. An omitted
598    // `binding:` (`All`) reaches every event, so every other policy stays valid for it.
599    if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
600        let honored = commit_policy_for_binding(&binding);
601        if honored.as_ref() != Some(&commit_policy) {
602            let note = if binding == HookBinding::OnInference {
603                " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
604            } else {
605                ""
606            };
607            return Err(format!(
608                "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
609                 commit_policy '{}'{note}",
610                commit_policy.as_str(),
611                binding.as_str(),
612                binding.as_str(),
613                honored.as_ref().map_or("none", HookCommitPolicy::as_str)
614            ));
615        }
616    }
617
618    Ok(HookConfig {
619        binding,
620        execution_mode,
621        commit_policy,
622    })
623}
624
625#[derive(Debug, Clone, PartialEq, Eq)]
626pub struct RuntimeManifest {
627    pub name: String,
628    pub version: String,
629    pub artifacts: Vec<RuntimeArtifact>,
630    pub capabilities: Option<Capabilities>,
631    pub inference: Option<InferenceConfig>,
632    pub context: Option<ContextConfig>,
633    pub observability: Option<ObservabilityConfig>,
634    pub trace: Option<TraceConfig>,
635    pub network: Option<NetworkConfig>,
636    pub lifecycle: Option<LifecycleConfig>,
637    /// Read-only views onto the workdir that the operator opens to processes outside the capsule.
638    /// `None` means nothing is exported and every request to the resource plane is denied.
639    pub exports: Option<Exports>,
640    /// What a controller holding this session's control token may change while it runs. `None`
641    /// — the block absent, `control: {}`, or both lists empty — means the capsule has no control
642    /// surface and mints no token.
643    pub control: Option<ControlConfig>,
644    /// The `deploy:` block. `None` when the manifest declares none.
645    pub deploy: Option<DeployConfig>,
646    /// Every key the manifest declared that this build does not recognize, captured by each
647    /// `Raw*` block's `#[serde(flatten)]` overflow map instead of dropped.
648    ///
649    /// Ordered by the walk rather than by the manifest's own line order: a containing block's own
650    /// keys precede the blocks nested inside it, and one block's keys are alphabetical.
651    ///
652    /// Empty for a manifest written entirely in keys this build knows. Never a reason to refuse a
653    /// manifest — it is reported as `W-SEC-019` by
654    /// [`crate::unknown_manifest_keys::warn_on_unknown_manifest_keys`] and nothing else reads it.
655    pub unknown_keys: Vec<UnknownManifestKey>,
656}
657
658impl RuntimeManifest {
659    pub fn effective_lifecycle(&self) -> LifecycleConfig {
660        self.lifecycle.clone().unwrap_or_default()
661    }
662
663    /// Whether this capsule's door serves tasks a peer capsule's runtime sends it: `true` only
664    /// when the manifest declares `exports.peer_tasks.accept: true`. See
665    /// [`Exports::accepts_peer_tasks`].
666    ///
667    /// The capsule's own manifest is the only input. Nothing a launcher, an operator or a
668    /// formation passes can change the answer.
669    pub fn accepts_peer_tasks(&self) -> bool {
670        self.exports
671            .as_ref()
672            .is_some_and(Exports::accepts_peer_tasks)
673    }
674
675    /// The `mur` release `mur deploy` installs on the target: `deploy.mur_version`, or `None`
676    /// when the block or the key is absent or the value is blank.
677    pub fn deploy_mur_version(&self) -> Option<&str> {
678        self.deploy.as_ref()?.mur_version.as_deref()
679    }
680}
681
682/// The top-level `deploy:` block, read by `mur deploy` only. `mur run` takes no meaning from it.
683#[derive(Debug, Clone, Default, PartialEq, Eq)]
684pub struct DeployConfig {
685    /// The `mur` release `mur deploy` downloads, caches at `~/.murmur/bin/mur-{version}-{platform}`
686    /// and installs on the target. `None` for an absent or blank value, in which case the
687    /// deploying `mur`'s own version is installed.
688    pub mur_version: Option<String>,
689}
690
691/// A setting of a running session that a controller may change over the control surface, when
692/// `control.settings` names it.
693///
694/// Each variant is one manifest key, spelled by [`Self::wire_name`] in `control.settings`, in the
695/// control surface's paths and bodies, and in the trace. What a value must be, and where the agent
696/// loop reads it, is the runtime's to decide per variant.
697#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
698pub enum ControllableSetting {
699    /// `inference.max_tokens`: the per-turn output cap the agent loop sends as `max_tokens`.
700    /// `transport: http` only, on the same terms as the manifest key.
701    InferenceMaxTokens,
702    /// `inference.driver`: which driver choice serves the agent loop's inference calls, by name —
703    /// [`PRIMARY_DRIVER_CHOICE`] or an `inference.alternates` entry's `name`. `transport: http`
704    /// only, and only on a capsule that declares at least one alternate.
705    InferenceDriver,
706}
707
708impl ControllableSetting {
709    /// Every setting a manifest may list, in the order refusals name them.
710    pub const ALL: &'static [ControllableSetting] =
711        &[Self::InferenceMaxTokens, Self::InferenceDriver];
712
713    /// Every setting `control.agent_settings` may list: the ones the agent may change about
714    /// itself through a runtime-provided tool.
715    pub const AGENT_SETTABLE: &'static [ControllableSetting] = &[Self::InferenceDriver];
716
717    /// The manifest key this setting is, as written in `control.settings`.
718    pub fn wire_name(self) -> &'static str {
719        match self {
720            Self::InferenceMaxTokens => "inference.max_tokens",
721            Self::InferenceDriver => "inference.driver",
722        }
723    }
724
725    /// The setting `name` spells, or `None` for anything outside [`Self::ALL`].
726    pub fn from_wire_name(name: &str) -> Option<Self> {
727        Self::ALL
728            .iter()
729            .copied()
730            .find(|setting| setting.wire_name() == name)
731    }
732}
733
734/// The top-level `control:` block: what a controller may change on a running session.
735///
736/// Parsed only to a non-empty declaration; `control: {}` and two empty lists parse as no block.
737/// Nothing a controller sets is persisted, so this is also everything a restart starts from.
738#[derive(Debug, Clone, PartialEq, Eq, Default)]
739pub struct ControlConfig {
740    /// Settings a controller may change, in manifest order, each listed once.
741    pub settings: Vec<ControllableSetting>,
742    /// Credential names a controller supplies at run time, in manifest order, each listed once.
743    /// Every name backs some artifact's `gateway.api_key: ${NAME}`, and none backs the configured
744    /// inference driver's. Such a name is never looked up in the config or the environment.
745    pub secrets: Vec<String>,
746    /// Settings the agent may change about itself, in manifest order, each listed once. Only
747    /// [`ControllableSetting::AGENT_SETTABLE`] members. The grant is the existence of the
748    /// runtime-provided tool that changes the setting, and never a route to the control surface.
749    pub agent_settings: Vec<ControllableSetting>,
750}
751
752impl ControlConfig {
753    /// Whether `name` is a credential a controller supplies.
754    pub fn declares_secret(&self, name: &str) -> bool {
755        self.secrets.iter().any(|secret| secret == name)
756    }
757
758    /// Whether a controller may change `setting`.
759    pub fn controller_may_set(&self, setting: ControllableSetting) -> bool {
760        self.settings.contains(&setting)
761    }
762
763    /// Whether the agent may change `setting`.
764    pub fn agent_may_set(&self, setting: ControllableSetting) -> bool {
765        self.agent_settings.contains(&setting)
766    }
767}
768
769/// The top-level `network:` block, which configures the A2A door. Unrelated to
770/// `capabilities.network`, which is the guest's egress.
771#[derive(Debug, Clone, PartialEq, Eq)]
772pub struct NetworkConfig {
773    /// Internal port the capsule expects to listen on. Default 14159 when absent.
774    pub internal_port: Option<u16>,
775    /// `network.authentication`: how a caller of the door proves it may call. `None` is a public
776    /// door, which answers every caller that reaches the port.
777    pub authentication: Option<NetworkAuthentication>,
778}
779
780/// Every scope a door token can carry: the door's JSON-RPC methods that a token gates, by wire
781/// name, and `resources/files`, the operator resource plane. The runtime's method table is held
782/// to this list by a test, in both directions.
783pub const DOOR_SCOPES: &[&str] = &[
784    "SendMessage",
785    "SendStreamingMessage",
786    "murmur.WatchSession",
787    "GetTask",
788    "ListTasks",
789    "CancelTask",
790    "SubscribeToTask",
791    "murmur.StopSession",
792    "resources/files",
793];
794
795/// The retired door method names a credential's `scopes` may not list, each with the method that
796/// replaces it: the A2A 0.3 names of the A2A methods, and the 0.3-style names murmur's own methods
797/// had. A manifest naming one is refused with [`RuntimeManifestError::RetiredDoorScope`]: the door
798/// serves no retired name, and no token carries one.
799///
800/// A replacement outside [`DOOR_SCOPES`] is a method every authenticated caller may call, which a
801/// credential lists nothing for. A test holds every replacement to the runtime's method table and
802/// every retired name outside it.
803pub const RETIRED_DOOR_SCOPES: &[(&str, &str)] = &[
804    ("message/send", "SendMessage"),
805    ("message/stream", "SendStreamingMessage"),
806    ("tasks/get", "GetTask"),
807    ("tasks/cancel", "CancelTask"),
808    ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
809    ("stream/watch", "murmur.WatchSession"),
810    ("session/stop", "murmur.StopSession"),
811];
812
813/// The credential the runtime always mints for an authenticated door, holding every scope in
814/// [`DOOR_SCOPES`]. Reserved: no declared credential may take the name.
815pub const OPERATOR_CREDENTIAL: &str = "operator";
816
817/// Longest credential name `network.authentication.credentials` accepts.
818const MAX_CREDENTIAL_NAME_LEN: usize = 32;
819
820/// How a caller of the door authenticates. A required field with one value, so that an
821/// authenticated door is an explicit statement and a second scheme is an addition, not a default
822/// change.
823#[derive(Debug, Clone, Copy, PartialEq, Eq)]
824pub enum AuthenticationScheme {
825    /// `Authorization: Bearer <token>`, with a token the capsule's runtime mints at launch.
826    Bearer,
827}
828
829impl AuthenticationScheme {
830    /// Every scheme a manifest may name, in the order refusals list them.
831    pub const ALL: &'static [AuthenticationScheme] = &[Self::Bearer];
832
833    /// The scheme as written in `network.authentication.scheme`.
834    pub fn wire_name(self) -> &'static str {
835        match self {
836            Self::Bearer => "bearer",
837        }
838    }
839}
840
841/// One declared credential: a token the runtime mints beside the operator token, reaching only
842/// `scopes`.
843#[derive(Debug, Clone, PartialEq, Eq)]
844pub struct DoorCredential {
845    /// Matches `^[a-z][a-z0-9_-]{0,31}$` and is never [`OPERATOR_CREDENTIAL`].
846    pub name: String,
847    /// Non-empty, each entry in [`DOOR_SCOPES`] and listed once, in declared order.
848    pub scopes: Vec<String>,
849}
850
851/// `network.authentication`: the door refuses every caller that presents no token this session
852/// minted.
853#[derive(Debug, Clone, PartialEq, Eq)]
854pub struct NetworkAuthentication {
855    pub scheme: AuthenticationScheme,
856    /// The declared credentials, sorted by name. Empty when only the operator token is minted.
857    pub credentials: Vec<DoorCredential>,
858}
859
860// ── Exports ───────────────────────────────────────────────────────────────────
861
862/// Per-file read ceiling applied when `exports.files.max_bytes` is absent: 10Mi.
863pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
864
865/// What the operator discloses to processes *outside* the capsule, declared under the top-level
866/// `exports:` key.
867///
868/// A sibling of `capabilities:` rather than a member of it: a capability is something the guest
869/// holds and an export is a disclosure the operator makes. Declaring one
870/// gives the agent nothing it did not already have — it widens only the operator's reach inward,
871/// which is why it never enters the achieved-containment computation.
872#[derive(Debug, Clone, PartialEq, Eq)]
873pub struct Exports {
874    /// The declared read-only file surface. `None` — an `exports:` block with no `files:` —
875    /// means the resource plane is not declared and every request to it is denied.
876    pub files: Option<FileExport>,
877    /// The declared peer-handoff surface. A separate authoriser from [`Self::files`], over a
878    /// separate subtree: declaring one grants nothing about the other, and a capsule may declare
879    /// either, both or neither. `None` means the capsule mints no handles and its peer plane
880    /// answers `no_peer_plane`.
881    pub peer_files: Option<PeerFilesExport>,
882    /// The declared willingness to take tasks from other capsules. `None` — no `peer_tasks:` —
883    /// refuses them exactly as `accept: false` does; read it through
884    /// [`Self::accepts_peer_tasks`] rather than matching on it.
885    pub peer_tasks: Option<PeerTasksExport>,
886}
887
888impl Exports {
889    /// Whether this capsule consents to tasks from peer capsules: `true` only for an explicit
890    /// `exports.peer_tasks.accept: true`. Absent and `false` are the same answer.
891    ///
892    /// The one reading rule. [`RuntimeManifest::accepts_peer_tasks`] delegates here, so a caller
893    /// holding only the lowered `exports:` block decides exactly as one holding the manifest.
894    pub fn accepts_peer_tasks(&self) -> bool {
895        self.peer_tasks.is_some_and(|peer_tasks| peer_tasks.accept)
896    }
897}
898
899/// The `exports.peer_tasks` block: whether the capsule's A2A door serves a task another capsule's
900/// runtime sends it.
901///
902/// An export rather than a capability: tasks already arrive through the door, so consenting gives
903/// the agent nothing it did not have and opens the door to one more class of caller. It names no
904/// peer, and has no field to name one — it is a posture of this capsule, not a grant to anyone.
905/// It is independent of `network.authentication`: consenting neither requires nor implies a
906/// token.
907///
908/// A block rather than a bare boolean so that it can gain fields without a breaking change.
909#[derive(Debug, Clone, Copy, PartialEq, Eq)]
910pub struct PeerTasksExport {
911    /// Required, never inferred. `false` is the same posture as declaring no block at all.
912    pub accept: bool,
913}
914
915/// The `exports.files` block: one subtree of the workdir, readable and nothing else.
916#[derive(Debug, Clone, PartialEq, Eq)]
917pub struct FileExport {
918    /// Subtree of the capsule's accessible workdir this export opens, verbatim as declared
919    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
920    /// directory belongs to the runtime, which is the only component that may decide a path is
921    /// inside the root.
922    ///
923    /// Not required to exist when the capsule launches — the agent may create it during a task.
924    pub root: String,
925    /// Required, with exactly one legal value. A required field with one value is what makes the
926    /// read-only posture an explicit operator statement rather than an unstated default, and
927    /// leaves a future write mode somewhere to be declared rather than somewhere to leak in.
928    pub mode: ExportMode,
929    /// Per-file read ceiling in bytes, defaulting to [`DEFAULT_EXPORT_MAX_BYTES`]. Never an
930    /// aggregate budget: each file is judged on its own size, and a subtree whose total exceeds
931    /// this is still listed in full.
932    pub max_bytes: u64,
933}
934
935/// Handle lifetime applied when `exports.peer_files.max_ttl` is absent and the capsule is
936/// ephemeral (`lifecycle.after_task: exit`): 1h.
937pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
938
939/// The largest `exports.peer_files.max_ttl` a *persistent* capsule
940/// (`lifecycle.after_task: sleep`) may declare: 15m.
941///
942/// An ephemeral capsule needs no ceiling because teardown destroys the minting key and with it
943/// every outstanding handle. A persistent one has withdrawn that bound, so the declared lifetime
944/// becomes the only one — and a handle is not a durability mechanism. Enforced by the runtime,
945/// not here, because the rule reads `lifecycle.after_task` and this parser sees one block at a
946/// time.
947pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
948
949/// Per-file read ceiling applied when `exports.peer_files.max_bytes` is absent: 10Mi.
950pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
951
952/// The `exports.peer_files` block: the one subtree a `share-file` handle may name.
953///
954/// Deliberately not a mode of [`FileExport`]. The operator plane addresses files by path and
955/// enumerates them; this plane has no `list` verb and no path addressing at all, and its audience
956/// is another capsule rather than the operator. Two authorisers over two subtrees, so opening one
957/// never widens the other.
958#[derive(Debug, Clone, PartialEq, Eq)]
959pub struct PeerFilesExport {
960    /// Subtree of the capsule's accessible workdir a handle may name, verbatim as declared
961    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
962    /// directory belongs to the runtime. Not required to exist when the capsule launches.
963    pub root: String,
964    /// The declared `max_ttl`, in seconds, or `None` when the manifest declared none.
965    ///
966    /// Kept undefaulted because the two ephemerality cases answer an absent value differently:
967    /// an ephemeral capsule falls back to [`DEFAULT_PEER_HANDLE_TTL_SECS`], and a persistent one
968    /// refuses to launch. Substituting the default here would erase the difference before
969    /// anything could act on it. See [`Self::effective_max_ttl_secs`].
970    pub max_ttl_secs: Option<u64>,
971    /// Per-file ceiling on a redeemed read, defaulting to [`DEFAULT_PEER_FILES_MAX_BYTES`].
972    /// Per file, never an aggregate budget — the same terms as [`FileExport::max_bytes`], and a
973    /// separate value from it.
974    pub max_bytes: u64,
975}
976
977impl PeerFilesExport {
978    /// The handle lifetime ceiling this export actually applies: the declared `max_ttl`, or the
979    /// ephemeral default when none was declared.
980    ///
981    /// Correct for a persistent capsule too, because one that declared no `max_ttl` never
982    /// launches — the runtime refuses it before a plane is built.
983    pub fn effective_max_ttl_secs(&self) -> u64 {
984        self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
985    }
986}
987
988/// The `capabilities.peer_fetch` block: which peers this capsule may redeem a handle against.
989///
990/// A sibling of [`NetworkCapabilities`] rather than a member of it. Fetching a peer's bytes lands
991/// a file in this capsule's own workdir, so it is an ingestion path and a prompt-injection
992/// surface, and it gets its own operator control. Declaring a destination here does not widen
993/// `capabilities.network.allow`, and a destination allowed there is not redeemable unless it is
994/// also named here.
995#[derive(Debug, Clone, PartialEq, Eq)]
996pub struct PeerFetchCapabilities {
997    /// Network destinations a handle may be redeemed against. Required and non-empty when the
998    /// block is present: an empty list is a parse error, never a silent deny. Same syntax as
999    /// `capabilities.network.allow`, and matched by the same runtime rule matcher.
1000    pub allow: Vec<String>,
1001}
1002
1003/// The accepted spelling of every duration in the manifest, stated once so the parser and the
1004/// error it produces cannot drift apart.
1005pub const DURATION_ACCEPTED_FORM: &str =
1006    "must be a duration: an integer, optionally suffixed s/m/h/d";
1007
1008/// Parses `90`, `30s`, `15m`, `1h`, `14d` into a whole number of seconds.
1009///
1010/// A bare integer is seconds. Suffixes are lowercase and single-character; `5 minutes` and `30S`
1011/// are rejected rather than guessed at, on the same terms as [`parse_byte_size`]. Returns the
1012/// accepted-form sentence as its error so every call site reports the same rule.
1013pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
1014    let trimmed = input.trim();
1015    let (digits, multiplier) = match trimmed.strip_suffix('s') {
1016        Some(digits) => (digits, 1u64),
1017        None => match trimmed.strip_suffix('m') {
1018            Some(digits) => (digits, 60),
1019            None => match trimmed.strip_suffix('h') {
1020                Some(digits) => (digits, 3600),
1021                None => match trimmed.strip_suffix('d') {
1022                    Some(digits) => (digits, 86_400),
1023                    None => (trimmed, 1),
1024                },
1025            },
1026        },
1027    };
1028    // Not trimmed again: the whole input was trimmed above, so a space surviving here is *inside*
1029    // the value (`5 minutes`, `30 m`), which is not a spelling this accepts.
1030    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
1031        return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
1032    }
1033    digits
1034        .parse::<u64>()
1035        .ok()
1036        .and_then(|value| value.checked_mul(multiplier))
1037        .ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
1038}
1039
1040#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1041pub enum ExportMode {
1042    #[serde(rename = "read-only")]
1043    ReadOnly,
1044}
1045
1046impl ExportMode {
1047    pub fn as_str(self) -> &'static str {
1048        match self {
1049            ExportMode::ReadOnly => "read-only",
1050        }
1051    }
1052}
1053
1054impl std::fmt::Display for ExportMode {
1055    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1056        f.write_str(self.as_str())
1057    }
1058}
1059
1060/// The accepted spelling of every byte count in the manifest, stated once so the parser and the
1061/// error it produces cannot drift apart.
1062pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
1063
1064/// Parses `4096`, `1Ki`, `10Mi`, `2Gi` into a byte count.
1065///
1066/// Binary suffixes only, and case-sensitively: `10MB` is rejected rather than guessed at, because
1067/// a manifest that means 10 000 000 and a manifest that means 10 485 760 must not both parse.
1068/// Returns the accepted-form sentence as its error so every call site reports the same rule.
1069pub fn parse_byte_size(input: &str) -> Result<u64, String> {
1070    let trimmed = input.trim();
1071    let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
1072        Some(digits) => (digits, 1024u64),
1073        None => match trimmed.strip_suffix("Mi") {
1074            Some(digits) => (digits, 1024 * 1024),
1075            None => match trimmed.strip_suffix("Gi") {
1076                Some(digits) => (digits, 1024 * 1024 * 1024),
1077                None => (trimmed, 1),
1078            },
1079        },
1080    };
1081    // Deliberately not trimmed again: the whole input was trimmed above, so any space left here
1082    // is *inside* the value (`10 Mi`), which is not a spelling this accepts.
1083    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
1084        return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
1085    }
1086    digits
1087        .parse::<u64>()
1088        .ok()
1089        .and_then(|value| value.checked_mul(multiplier))
1090        .ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
1091}
1092
1093#[derive(Debug, Clone, PartialEq, Eq)]
1094pub struct RuntimeArtifact {
1095    pub name: String,
1096    pub version: String,
1097    pub runtime: ArtifactRuntime,
1098    /// Optional local source path. When set, the runtime resolves the artifact directly from
1099    /// this path instead of from the registry — no `.mur.zip`, no publish. For a skill the path
1100    /// points at a `skill.md` file or at a directory containing one (case-insensitive).
1101    /// Relative paths resolve against the directory containing `murmur.yaml`.
1102    /// Only permitted when `local_source` is true.
1103    pub source: Option<String>,
1104    /// Whether this artifact may be resolved from a local `source:` path. Declared via
1105    /// `local_source:` in the project manifest's artifact entry; when absent it defaults to
1106    /// true for `runtime: skill` and false for every other role, which is exactly the role-based
1107    /// gate this field replaced.
1108    pub local_source: bool,
1109    /// Whether this artifact's payload may be bound as the system prompt via
1110    /// `inference.system_prompt_artifact`. Declared via `prompt_payload:` in the project
1111    /// manifest's artifact entry; when absent it defaults to true for `runtime: skill` and
1112    /// false for every other role.
1113    pub prompt_payload: bool,
1114    /// Per-artifact capability grant, declared via `capabilities:` on this entry in the
1115    /// **capsule operator's own** manifest. Recognized on `runtime: hook`, `runtime: tool`,
1116    /// and `runtime: driver` entries; a `runtime: skill` entry carrying the key is rejected
1117    /// at parse time, because nothing would enforce it and a silently-ignored grant reads
1118    /// like a scoped artifact.
1119    ///
1120    /// What `None` (the key absent) means depends on the role, deliberately:
1121    /// - `hook`: full default-deny — no network and no preopened directory.
1122    /// - `tool`/`driver`: the unchanged capsule-wide ceiling. A declared block *narrows*
1123    ///   from that ceiling and can never widen past it.
1124    ///
1125    /// Deliberately never sourced from the artifact's own bundled `murmur.yaml` (see
1126    /// [`parse_hook_config_from_yaml`], which parses that file and knows nothing about
1127    /// capabilities) — an artifact pulled from a registry cannot self-grant.
1128    ///
1129    /// Reuses the whole [`Capabilities`] type for vocabulary consistency with the
1130    /// capsule-wide block, but only `network` and `filesystem` are consumed by the runtime
1131    /// per-artifact; the other sub-blocks are inert here.
1132    pub capabilities: Option<Capabilities>,
1133    /// What the runtime does when this hook's async job queue is full, declared via
1134    /// `on_overflow:` on this entry. Defaults to [`HookOverflowPolicy::Drop`] when absent.
1135    /// Accepted only on `runtime: hook` entries — every other role is rejected at parse
1136    /// time, since nothing would ever consult it there.
1137    pub on_overflow: HookOverflowPolicy,
1138    /// Operator-authored configuration for this artifact alone, declared via `config:` on this
1139    /// entry. Held as the raw YAML node: this crate accepts any mapping, and the runtime is what
1140    /// lowers it to the JSON delivered as `MURMUR_ARTIFACT_CONFIG` and refuses a shape that
1141    /// cannot travel that way.
1142    ///
1143    /// `None` is the key being absent, which means the variable is absent from the guest
1144    /// environment. `Some(Value::Null)` is `config:` written with nothing under it — a
1145    /// declaration that carries nothing, refused by the runtime rather than treated as absent.
1146    ///
1147    /// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
1148    /// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
1149    /// [`Self::capabilities`]. Operator-sourced only, and never read from the artifact's own
1150    /// bundled `murmur.yaml` — an artifact pulled from a registry cannot configure itself.
1151    ///
1152    /// Plaintext in a file that is also an audit record: secrets belong in `${VAR}` references
1153    /// and the credential-stripping path, not here.
1154    pub config: Option<serde_norway::Value>,
1155    /// The third-party upstream this artifact reaches through the credential gateway, and the
1156    /// key the runtime presents there on its behalf, declared via `gateway:` on this entry.
1157    ///
1158    /// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
1159    /// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
1160    /// [`Self::capabilities`]. A `runtime: driver` entry may carry it only when it is the
1161    /// configured `transport: http` driver, whose gateway is the metered inference gateway.
1162    ///
1163    /// Operator-sourced only, and never read from the artifact's own bundled `murmur.yaml`: the
1164    /// artifact says how the key is presented (`upstream_auth:`), never where it goes.
1165    pub gateway: Option<ArtifactGateway>,
1166}
1167
1168impl RuntimeArtifact {
1169    /// The name a staged session knows this artifact by: the bare part of its full name, or a
1170    /// `source:` entry's own name. It is the model-facing tool name and the directory under
1171    /// `<runtime dir>/tools/`.
1172    #[must_use]
1173    pub fn tool_name(&self) -> &str {
1174        tool_name_of(&self.name)
1175    }
1176
1177    /// The full name this entry resolves by. A `source:` entry, named by its bare name, has none.
1178    pub fn artifact_name(&self) -> Result<ArtifactName, ArtifactNameError> {
1179        ArtifactName::parse(&self.name)
1180    }
1181}
1182
1183/// The bare part of an artifact reference as a manifest writes it: the text after the `/` of a
1184/// full name, or the whole of a bare one.
1185#[must_use]
1186pub fn tool_name_of(name: &str) -> &str {
1187    name.split_once('/').map_or(name, |(_, bare)| bare)
1188}
1189
1190/// One artifact's credential gateway, as the operator declared it on the artifact's entry.
1191///
1192/// Debug never prints a literal key: [`ApiKeyReference`]'s own `Debug` redacts it.
1193#[derive(Debug, Clone, PartialEq, Eq)]
1194pub struct ArtifactGateway {
1195    /// Absolute `http`/`https` upstream URL with a host and no query or fragment. Plain `http`
1196    /// only to `localhost` or a loopback IP literal.
1197    pub endpoint: String,
1198    /// The key presented upstream. `None` for a keyless gateway, or when the manifest was parsed
1199    /// with [`RuntimeManifest::from_yaml_str_without_secrets`].
1200    pub api_key: Option<ApiKeyReference>,
1201    /// `keyless: true` as the operator wrote it: this upstream takes no key, and the gateway
1202    /// forwards with no credential header. Exclusive with `api_key`; kept as written in both
1203    /// secret modes. Never derived from the endpoint's address.
1204    pub keyless: bool,
1205}
1206
1207#[derive(Debug, Clone, PartialEq, Eq)]
1208pub struct InferenceDriver {
1209    pub artifact: String,
1210    pub config: Option<String>,
1211}
1212
1213/// The name of the driver choice `inference.driver.artifact` with `inference.model` forms. Reserved:
1214/// no `inference.alternates` entry may take it.
1215pub const PRIMARY_DRIVER_CHOICE: &str = "primary";
1216
1217/// One `inference.alternates` entry: a named pair of a declared driver and a model that the
1218/// agent loop may be switched to while the capsule runs.
1219///
1220/// Takes no `driver.config` of its own: `inference.driver.config` reaches every declared driver,
1221/// and per-driver settings are that driver's `artifacts[].config`.
1222#[derive(Debug, Clone, PartialEq, Eq)]
1223pub struct InferenceAlternate {
1224    /// `[a-z][a-z0-9-]{0,62}`, unique among the alternates, and never [`PRIMARY_DRIVER_CHOICE`].
1225    pub name: String,
1226    /// The model string the driver sends, never empty.
1227    pub model: String,
1228    /// `driver.artifact`: an `artifacts:` entry with `runtime: driver`, which carries its own
1229    /// `gateway:` unless it is the primary's own driver.
1230    pub driver: String,
1231}
1232
1233/// What a `gateway.api_key` names, as the manifest wrote it. Parsing never resolves it: the
1234/// runtime looks the value up at staging, where it knows where credentials are kept.
1235#[derive(Clone, PartialEq, Eq)]
1236pub enum ApiKeyReference {
1237    /// `${NAME}`: a credential name, looked up in the global config's `credentials:` map and
1238    /// then in the launching environment.
1239    Environment(String),
1240    /// Any other non-empty value, used verbatim as the key.
1241    Literal(String),
1242}
1243
1244impl std::fmt::Debug for ApiKeyReference {
1245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1246        match self {
1247            Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
1248            Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
1249        }
1250    }
1251}
1252
1253#[derive(Debug, Clone, PartialEq, Eq)]
1254pub struct InferenceConfig {
1255    pub transport: String,
1256    pub model: String,
1257    /// WASM driver artifact. Always present: the inference driver under `transport: http`, the
1258    /// process driver under `transport: process`.
1259    pub driver: Option<InferenceDriver>,
1260    /// The executable to run instead of the one the process driver's `describe()` names. Absent
1261    /// for `transport: http`, and optional under `transport: process`, where the driver names its
1262    /// own binary.
1263    pub command: Option<String>,
1264    pub compaction: Option<CompactionConfig>,
1265    pub system_prompt: Option<String>,
1266    pub system_prompt_file: Option<String>,
1267    /// Name of a skill artifact declared in `artifacts:` whose `skill.md` is used as the system
1268    /// prompt. Mutually exclusive with `system_prompt` and `system_prompt_file`. The skill is
1269    /// excluded from the callable tool inventory (it's already in the system prompt).
1270    pub system_prompt_artifact: Option<String>,
1271    /// `inference.max_turns`: the most inference turns one task may take, across its reopens and
1272    /// continuations, enforced as a hard ceiling by the runtime. `None` sets no cap.
1273    pub max_turns: Option<u32>,
1274    /// Maximum output tokens the model may generate per turn (`max_tokens` in the driver wire
1275    /// payload). `None` means the manifest didn't set it, and each driver choice's cap is the
1276    /// `max_output_tokens` its driver's `models:` table declares for its model.
1277    /// `transport: http` only — rejected at parse time under `transport: process`.
1278    ///
1279    /// Unrelated to [`ContextConfig::max_tokens`], which is the session-wide token budget that
1280    /// drives compaction. This one is a per-turn *output* cap.
1281    pub max_tokens: Option<u32>,
1282    /// `inference.max_session_tokens`: the most input plus output tokens this session may spend.
1283    /// `None` sets no session ceiling.
1284    ///
1285    /// Valid on both transports, measured differently on each: under `transport: http` it counts
1286    /// the runtime's own measurement of every driver call, and under `transport: process` the
1287    /// counts the harness's driver reports for each turn. Setting it against a process driver
1288    /// that reports no usage is refused at load.
1289    pub max_session_tokens: Option<u64>,
1290    /// `inference.tool_refresh`: when an artifact installed mid-session reaches the tool array
1291    /// the agent loop sends the model. Always [`ToolRefresh::Compaction`] under
1292    /// `transport: process`, where the key is refused at parse time and nothing reads it.
1293    pub tool_refresh: ToolRefresh,
1294    /// `inference.alternates`: the other driver choices the agent loop may be switched to, in
1295    /// manifest order. Empty under `transport: process`, where the key is refused at parse time,
1296    /// and for every capsule that declares none.
1297    pub alternates: Vec<InferenceAlternate>,
1298    /// `inference.retry`: how a driver call that failed transiently is made again. Always
1299    /// [`InferenceRetry::default`] under `transport: process`, where the key is refused at parse
1300    /// time and the harness retries its own provider calls.
1301    pub retry: InferenceRetry,
1302}
1303
1304/// `inference.retry`: the bounds on making a `transport: http` driver call again after a
1305/// transient failure. Every key is optional and takes its default when omitted.
1306#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1307pub struct InferenceRetry {
1308    /// The most retries one call makes after its first attempt, 0–20. 0 disables retrying.
1309    pub max_retries: u32,
1310    /// The backoff before the first retry, in milliseconds, 1–60000. Each later retry doubles it.
1311    pub initial_delay_ms: u32,
1312    /// The longest backoff, and the longest provider-requested wait the runtime honours, in
1313    /// milliseconds: 1–3600000, and at least `initial_delay_ms`.
1314    pub max_delay_ms: u32,
1315}
1316
1317impl InferenceRetry {
1318    /// The most `max_retries` may be.
1319    pub const MAX_RETRIES: u32 = 20;
1320    /// The most `initial_delay_ms` may be.
1321    pub const MAX_INITIAL_DELAY_MS: u32 = 60_000;
1322    /// The most `max_delay_ms` may be.
1323    pub const MAX_DELAY_MS: u32 = 3_600_000;
1324}
1325
1326impl Default for InferenceRetry {
1327    fn default() -> Self {
1328        Self {
1329            max_retries: 6,
1330            initial_delay_ms: 1_000,
1331            max_delay_ms: 60_000,
1332        }
1333    }
1334}
1335
1336/// `inference.tool_refresh`: the turn boundary at which the http agent loop rebuilds the tool
1337/// array it sends the model, once the session's installed set has changed.
1338///
1339/// The array comes first in every provider's cached prompt prefix, so rebuilding it is a full
1340/// prompt-cache miss on the call that carries it. The start of the next task rebuilds it under
1341/// either value.
1342#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1343pub enum ToolRefresh {
1344    /// Rebuild before the first inference call after a compaction commits a replacement
1345    /// context, whose message history is already a full cache miss.
1346    #[default]
1347    Compaction,
1348    /// Rebuild before the next inference call after the install.
1349    Immediate,
1350}
1351
1352impl ToolRefresh {
1353    /// Every value, in the order refusals name them.
1354    pub const ALL: [ToolRefresh; 2] = [Self::Compaction, Self::Immediate];
1355
1356    /// The value as written in the manifest and in `trace.jsonl`.
1357    #[must_use]
1358    pub fn wire_name(self) -> &'static str {
1359        match self {
1360            Self::Compaction => "compaction",
1361            Self::Immediate => "immediate",
1362        }
1363    }
1364}
1365
1366#[derive(Debug, Clone, PartialEq)]
1367pub struct CompactionConfig {
1368    /// Fraction of context_window at which compaction fires (0.0–1.0]. Default: 0.98.
1369    pub threshold: Option<f32>,
1370    /// Model for compaction. None means use the primary inference model.
1371    pub model: Option<String>,
1372    /// System prompt for compaction. None means the compaction hook picks its own default.
1373    /// Mutually exclusive with `system_prompt_file`.
1374    pub system_prompt: Option<String>,
1375    /// Path to a local file whose contents are used as the compaction system prompt,
1376    /// resolved relative to the manifest directory when the session launches. Mutually
1377    /// exclusive with `system_prompt`.
1378    pub system_prompt_file: Option<String>,
1379    /// When true, every committed compaction appends one JSON line recording the
1380    /// replacement summary to `out/compaction-summaries.jsonl` in the session's records
1381    /// directory.
1382    /// None (the key absent) is equivalent to false: nothing is written.
1383    pub dump_summaries: Option<bool>,
1384    /// Output cap of every `run-inference` call made while a hook serves `on-compaction`, and
1385    /// the amount the spend meter reserves for each. Never 0. None keeps the cap other hook
1386    /// calls get: the primary driver's `models:` ceiling for the model called, else the
1387    /// primary choice's cap.
1388    pub max_tokens: Option<u32>,
1389    /// Compact JSON of `inference.compaction.driver_config`, always an object. Its top-level
1390    /// keys replace or add to those of `inference.driver.config` for the calls `max_tokens`
1391    /// caps, and for no other call.
1392    pub driver_config: Option<String>,
1393}
1394
1395// Threshold values are validated to (0.0, 1.0] at parse time so NaN is impossible.
1396impl Eq for CompactionConfig {}
1397
1398/// `context.seed_budget` when the manifest leaves it out: a tenth of the window.
1399/// Small enough that a seed cannot crowd out the task it precedes, large enough that a
1400/// memory hook has room to say something.
1401pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
1402
1403/// `context.seed_overflow_margin` when the manifest leaves it out. Slack above
1404/// `seed_budget` within which an over-budget seed is trimmed rather than summarized —
1405/// a few tokens over must not buy an inference call.
1406pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
1407
1408#[derive(Debug, Clone, PartialEq)]
1409pub struct ContextConfig {
1410    /// `context.max_tokens`: the context window compaction and the seed budget measure against,
1411    /// for every driver choice. `None` leaves each choice the `context_window` its driver's
1412    /// `models:` table declares for its model, and a choice with neither has no window.
1413    pub max_tokens: Option<u32>,
1414    /// Whether the runtime keeps a durable conversation record for this capsule. `true` unless
1415    /// the manifest says `record: off`; `false` turns the mechanism off entirely, creating
1416    /// nothing under `~/.murmur/conversations/`.
1417    pub record: bool,
1418    /// Directory segment under `~/.murmur/conversations/` this capsule's records live in.
1419    /// `None` means the capsule name. Validated as one path segment by the runtime, on the same
1420    /// terms as `capabilities.state.store`. Inert when [`Self::record`] is `false`.
1421    pub record_store: Option<String>,
1422    /// Fraction of [`Self::max_tokens`] an `on-task-start` hook's `seed-context` may
1423    /// occupy, in `0.0..=1.0`. Always populated; [`DEFAULT_SEED_BUDGET`] when the key is
1424    /// absent. Inert without `max_tokens`: a fraction of no ceiling is no ceiling, and a
1425    /// seed with no ceiling is rejected rather than committed unbounded.
1426    pub seed_budget: f32,
1427    /// Slack above the seed budget, as a fraction of it, within which an over-budget seed
1428    /// is trimmed from its front rather than handed to the compaction hook, in
1429    /// `0.0..=1.0`. Always populated; [`DEFAULT_SEED_OVERFLOW_MARGIN`] when the key is
1430    /// absent.
1431    pub seed_overflow_margin: f32,
1432    /// What bounds this capsule's conversation records. `None` — the `retain:` block was absent
1433    /// — keeps every record whole and forever. Inert when [`Self::record`] is `false`.
1434    pub retain: Option<ContextRetainConfig>,
1435}
1436
1437// Both fractions are validated to 0.0..=1.0 at parse time, so NaN is impossible.
1438impl Eq for ContextConfig {}
1439
1440#[derive(Debug, Clone, PartialEq, Eq)]
1441pub struct ObservabilityConfig {
1442    pub otel_endpoint: Option<String>,
1443    pub eval: Option<EvalConfig>,
1444}
1445
1446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1447pub struct TraceConfig {
1448    /// How much of each turn's driver request this session's trace keeps — resolved from
1449    /// `trace.capture` by [`crate::trace_capture::resolve_trace_capture`].
1450    pub capture: TraceCapture,
1451    /// What bounds the session directories under the workdir. `None` — the `retain:` block was
1452    /// absent — keeps every session forever; there is no default policy, because a mechanism
1453    /// that deletes an operator's traces unless they opt out is the one default-allow in a
1454    /// runtime that is default-deny everywhere else.
1455    pub retain: Option<TraceRetainConfig>,
1456}
1457
1458/// The `trace.retain` block: what bounds the set of session directories beside the running one.
1459///
1460/// Both keys are optional and ANDed — a session survives only if it is inside every limit
1461/// declared. An empty block is a parse error, not a no-op: omitting `retain:` is how a capsule
1462/// says "keep everything".
1463#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1464pub struct TraceRetainConfig {
1465    /// Newest session directories to keep, counting the running session itself. `None` means
1466    /// the count is unbounded. Never `Some(0)`: truncating the set to nothing would delete the
1467    /// running session's own trace, so it is refused at parse time.
1468    pub max_sessions: Option<u32>,
1469    /// Age beyond which a session directory is removed, in seconds, measured from the
1470    /// millisecond timestamp inside its own uuid-v7 `ses_` id. `None` means no age limit.
1471    pub max_age_secs: Option<u64>,
1472}
1473
1474/// The `context.retain` block: what bounds this capsule's conversation records.
1475///
1476/// Both keys are optional and ANDed. `max_messages` truncates the front of the record the
1477/// launch opens; `max_age_secs` removes a context directory this capsule owns and has not
1478/// written to inside the window.
1479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1480pub struct ContextRetainConfig {
1481    /// Messages to keep at the tail of a record. `None` means the record grows without bound.
1482    /// Never `Some(0)`: truncating a record to nothing is `mur conversation rm`, not retention.
1483    pub max_messages: Option<u32>,
1484    /// Age beyond which a record this capsule owns is removed, in seconds, measured from the
1485    /// last write to its `conversation.jsonl`. `None` means no age limit.
1486    pub max_age_secs: Option<u64>,
1487}
1488
1489#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1490pub struct EvalConfig {
1491    #[serde(skip_serializing_if = "Option::is_none")]
1492    pub dataset_id: Option<String>,
1493    pub scorers: Vec<ScorerConfig>,
1494}
1495
1496#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1497#[serde(tag = "type", rename_all = "snake_case")]
1498pub enum ScorerConfig {
1499    ExitOk { name: String },
1500    MaxTurns { name: String, max: u32 },
1501    MaxTokens { name: String, max: u64 },
1502    ToolSequence { name: String, expected: Vec<String> },
1503    LlmJudge { name: String },
1504}
1505
1506#[derive(Debug, Clone, PartialEq, Eq)]
1507pub struct NetworkCapabilities {
1508    pub allow: Vec<String>,
1509    /// Whether the capsule's shell subprocess tree may create `AF_UNIX` sockets. `false` unless
1510    /// the manifest says otherwise: a local daemon socket — `/var/run/docker.sock` above all —
1511    /// is an unmediated path to host root, and the `allow` list above only governs IP
1512    /// destinations, so nothing else in this block constrains it. Declaring `true` is the
1513    /// deliberate, auditable widening for a capsule that genuinely needs a local daemon socket;
1514    /// it is capsule-wide and coarse (a domain, not a per-path allowlist). `AF_NETLINK` and
1515    /// `AF_PACKET` have no corresponding key and are always denied.
1516    pub unix_sockets: bool,
1517}
1518
1519#[derive(Debug, Clone, PartialEq, Eq)]
1520pub struct FilesystemCapabilities {
1521    pub scope: Option<String>,
1522    /// Whether anything written *into* the session workdir may be executed. `false` unless the
1523    /// manifest says otherwise, and that default is the whole point: with it, the workdir's own
1524    /// Landlock `PathBeneath` rule withholds the `Execute` right, so the kernel — evaluating the
1525    /// resolved path itself, with no userspace round trip — refuses to exec a binary the capsule
1526    /// produced, whatever it is named. That is what makes `capabilities.shell.allow` a complete
1527    /// and sound statement rather than a name-matching convention.
1528    ///
1529    /// `true` takes the `Execute` right back for compile-and-run workflows (a capsule that
1530    /// `gcc`/`cargo build`s inside its workdir and then runs the result). The cost is not
1531    /// negotiable and is not hidden: anything the capsule writes into the workdir can then run
1532    /// regardless of `shell.allow`, so the allowlist stops being an enforceable property of that
1533    /// capsule. A capsule declaring it can therefore never achieve
1534    /// [`ContainmentClass::Scoped`] — see `capsule_runtime::containment` — and pairing it with
1535    /// `capabilities.containment: scoped` is refused at launch.
1536    pub workdir_exec: bool,
1537    /// Workdir-relative subtrees the capsule may read but must not write, in the same vocabulary
1538    /// [`Self::scope`] uses. Empty when the key is absent, which is the whole workdir writable —
1539    /// the behaviour of every capsule that declares nothing here.
1540    ///
1541    /// Entries are trimmed and empty ones dropped at parse; path *shape* is judged by the
1542    /// runtime (`capsule_runtime::protected_paths`), exactly as `scope`'s shape is.
1543    pub read_only: Vec<String>,
1544    /// Whether a `runtime: hook` entry's `"."` is the session's records directory's `hooks/`,
1545    /// read-write, in place of a workdir [`Self::scope`], so what the hook writes is outside the
1546    /// workdir the capsule's commands write. `false` when the key is absent. Refused at parse on any other role, in the capsule-wide
1547    /// block, and beside a `scope` on the same entry.
1548    pub records: bool,
1549}
1550
1551/// The `capabilities.task_io` block on a `runtime: hook` artifact entry — the operator's
1552/// grant of the `murmur:task-io/read` host import to that one hook. Recognized nowhere else:
1553/// on any other role, and in the capsule-wide block, it is rejected at parse time rather than
1554/// accepted and silently inert.
1555#[derive(Debug, Clone, PartialEq, Eq)]
1556pub struct TaskIoCapabilities {
1557    /// Whether this hook may read the in-scope task's input and result text. Never inferred:
1558    /// a `task_io:` block that omits `read:` is rejected, on the same terms as
1559    /// `capabilities.shell.interpreter_runtime[].dirs[].list_dir`.
1560    pub read: bool,
1561}
1562
1563/// The `capabilities.conversation` block on a `runtime: hook` artifact entry — the operator's
1564/// grant of the `murmur:conversation/read` host import to that one hook. Rejected at parse time
1565/// on any other artifact role; accepted but inert in the capsule-wide block, which warns
1566/// `W-SEC-016` at staging.
1567#[derive(Debug, Clone, PartialEq, Eq)]
1568pub struct ConversationCapabilities {
1569    /// Whether this hook may read the capsule's durable conversation record. Never inferred: a
1570    /// `conversation:` block that omits `read:` is rejected, exactly as `task_io:` is.
1571    pub read: bool,
1572}
1573
1574/// The `capabilities.plan` block — the operator's grant of the runtime-provided `submit-plan`
1575/// tool to the capsule's agent loop. Read from the capsule-wide block only: an artifact entry
1576/// declaring it grants nothing, because a plan is submitted by the agent and not by an artifact.
1577#[derive(Debug, Clone, PartialEq, Eq)]
1578pub struct PlanCapabilities {
1579    /// Whether this capsule's model may submit a plan for the runtime to schedule. Never
1580    /// inferred: a `plan:` block that omits `submit:` is rejected, exactly as `task_io:` is.
1581    pub submit: bool,
1582}
1583
1584/// The `capabilities.install` block — the operator's grant to pull artifacts into a running
1585/// session through `murmur:artifact-manager/manage.pull`. Read from the capsule-wide block only.
1586/// Each entry is an exact artifact name, a `<prefix>*` or `*`, as
1587/// [`crate::install_pattern::validate`] accepts it, kept verbatim in declaration order. At least
1588/// one list is non-empty: omitting the block, not declaring an empty one, is how a capsule grants
1589/// nothing.
1590#[derive(Debug, Clone, PartialEq, Eq)]
1591pub struct InstallCapabilities {
1592    /// Entries admitting skill artifacts (`skill.md`, no executable payload).
1593    pub skill: Vec<String>,
1594    /// Entries admitting tool artifacts, WASM or native alike.
1595    pub tool: Vec<String>,
1596    /// `capabilities.install.from`: where a pull may find what it installs.
1597    pub from: InstallFrom,
1598}
1599
1600/// `capabilities.install.from`: the places a pull is answered from. Absent is
1601/// [`InstallFrom::Stores`].
1602#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1603pub enum InstallFrom {
1604    /// `[stores]`: the project store, then the global store. A pull fetches nothing.
1605    #[default]
1606    Stores,
1607    /// `[stores, sources]`: the stores first, then the GitHub source of the artifact's scope,
1608    /// fetched by the runtime itself and written to no store.
1609    StoresAndSources,
1610}
1611
1612impl InstallFrom {
1613    /// Whether a pull may fetch from a scope's source.
1614    #[must_use]
1615    pub fn sources(self) -> bool {
1616        self == Self::StoresAndSources
1617    }
1618
1619    /// The list as a manifest writes it, without brackets: `stores` or `stores, sources`.
1620    #[must_use]
1621    pub fn as_list(self) -> &'static str {
1622        match self {
1623            Self::Stores => "stores",
1624            Self::StoresAndSources => "stores, sources",
1625        }
1626    }
1627}
1628
1629#[derive(Debug, Clone, PartialEq, Eq)]
1630pub struct ShellCapabilities {
1631    pub allow: Vec<String>,
1632    pub strip_env: Option<Vec<String>>,
1633    pub baseline_env: Option<Vec<String>>,
1634    /// Typed grants that widen an already-allowlisted `allow` binary's Landlock filesystem
1635    /// scope to the exact host directories its import machinery needs *outside* the workdir
1636    /// (a path-based interpreter like CPython, whose stdlib the `DT_NEEDED` closure cannot
1637    /// reach). Empty unless the manifest declares `capabilities.shell.interpreter_runtime`.
1638    /// This can only ever name specific directories with an explicit per-directory
1639    /// enumerability flag — it has no field that expands a whole install prefix.
1640    pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1641    /// Typed staged-runtime grants that name a pinned host runtime tree to bind-mount read-only
1642    /// into a `sealed` capsule's composed root. Empty unless the manifest declares
1643    /// `capabilities.shell.staged_runtime`. Mutually exclusive with [`Self::interpreter_runtime`]
1644    /// per binary: staging the tree *into* the root is what makes widening Landlock *out to* the
1645    /// host unnecessary, so declaring both for one binary is a contradiction, not a layering.
1646    pub staged_runtime: Vec<StagedRuntimeGrant>,
1647}
1648
1649/// One `capabilities.shell.interpreter_runtime` entry: an already-allowlisted binary plus the
1650/// exact host directories outside the workdir its import machinery must reach. Declaring one
1651/// couples the capsule to a specific host interpreter-version layout, so it fires `W-SEC-009`
1652/// at staging; it exists only to bridge until the staged runtime bind-mount ships.
1653#[derive(Debug, Clone, PartialEq, Eq)]
1654pub struct InterpreterRuntimeGrant {
1655    /// A binary that MUST already appear in this same block's `allow`. This mechanism can only
1656    /// narrow filesystem access alongside an exec grant that already exists — it never itself
1657    /// grants exec.
1658    pub binary: String,
1659    /// The host directories to grant. Never empty (a grant with no directories is rejected at
1660    /// parse time).
1661    pub dirs: Vec<InterpreterRuntimeDir>,
1662}
1663
1664/// One directory inside an [`InterpreterRuntimeGrant`], with its author-declared enumerability.
1665#[derive(Debug, Clone, PartialEq, Eq)]
1666pub struct InterpreterRuntimeDir {
1667    /// An absolute host path (must start with `/`) outside the workdir.
1668    pub path: String,
1669    /// `true` grants `Execute + ReadFile + ReadDir` — the directory's own entries can be
1670    /// enumerated (what CPython's `FileFinder` needs for a `sys.path` entry). `false` grants
1671    /// `Execute + ReadFile` only — files inside can still be opened by exact name, but the
1672    /// directory itself cannot be listed. Never inferred: the author must write it explicitly.
1673    pub list_dir: bool,
1674}
1675
1676/// One `capabilities.shell.staged_runtime` entry: an already-allowlisted binary, the absolute host
1677/// path of a pinned runtime tree that already exists on the launch host, and the `pin` string
1678/// identifying which build that tree is.
1679///
1680/// The mechanism this describes is the inverse of [`InterpreterRuntimeGrant`]. That one widens a
1681/// capsule's Landlock scope *outwards* so an interpreter can reach its stdlib where the host
1682/// happens to keep it — which couples the capsule to one host's directory layout and is why it
1683/// fires `W-SEC-009`. This one moves the tree *inwards*: the composed root of a `sealed` capsule
1684/// carries the runtime at the same absolute path, bind-mounted read-only, so nothing outside the
1685/// root has to stay reachable. Declaring it therefore requires an effective `sealed` floor — there
1686/// is no composed root to stage into below that — and it is refused alongside an
1687/// `interpreter_runtime` grant for the same binary.
1688///
1689/// `source_path` is deliberately a path to an *already-pinned tree* (a vendored toolchain
1690/// directory, a baked-in conda env), never a bare install prefix guessed at random: the runtime
1691/// does not resolve, discover or version-sniff it.
1692#[derive(Debug, Clone, PartialEq, Eq)]
1693pub struct StagedRuntimeGrant {
1694    /// A binary that MUST already appear in this same block's `allow`. Like
1695    /// [`InterpreterRuntimeGrant::binary`], this mechanism never itself grants exec — it only says
1696    /// where the runtime behind an existing exec grant comes from.
1697    pub binary: String,
1698    /// Absolute host path of the runtime tree to stage. Bind-mounted read-only at this same
1699    /// absolute path inside the composed root, so `sys.prefix`, shebangs and anything a previous
1700    /// turn recorded keep resolving.
1701    pub source_path: String,
1702    /// Non-empty, explicit identifier of *which build* the tree at `source_path` is. Never
1703    /// inferred from the tree's contents: it exists so a human can compare the declared pin across
1704    /// two hosts and confirm the same interpreter build shipped to both. The runtime treats it as
1705    /// an opaque string and does not parse it.
1706    pub pin: String,
1707}
1708
1709#[derive(Debug, Clone, PartialEq, Eq)]
1710pub struct SpawnCapabilities {
1711    pub allow: Vec<String>,
1712}
1713
1714/// Host environment variables a WASM guest may observe, from `capabilities.env`.
1715///
1716/// An empty `allow` is legitimate (a declared-but-empty block is a no-op), so unlike
1717/// `capabilities.shell.allow` this is not rejected at parse time.
1718#[derive(Debug, Clone, PartialEq, Eq, Default)]
1719pub struct EnvCapabilities {
1720    pub allow: Vec<String>,
1721}
1722
1723/// Per-guest execution limits from `capabilities.limits`.
1724///
1725/// Every field is `Option` because this type carries only what the manifest actually
1726/// declared — an omitted field stays `None` here and the runtime substitutes its own
1727/// default (see `capsule_runtime::limits::ExecutionLimits`). Keeping the "absent" state
1728/// distinct from "explicitly set" is what lets the runtime own the defaults in one place
1729/// instead of duplicating them into the manifest parser.
1730#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1731pub struct ResourceLimits {
1732    /// Cap on a guest's linear-memory growth, in bytes.
1733    pub memory_bytes: Option<usize>,
1734    /// Cap on a guest's table growth, in elements.
1735    pub table_elements: Option<usize>,
1736    /// Cap on the number of instances a single store may create.
1737    pub instances: Option<usize>,
1738    /// Wall-clock budget for a single guest invocation, in seconds.
1739    pub deadline_seconds: Option<u64>,
1740}
1741
1742/// Host-process (OS-level) resource bounds from `capabilities.resources`.
1743///
1744/// Distinct from [`ResourceLimits`] (`capabilities.limits`) in both mechanism and subject:
1745/// that block bounds a WASM *guest* inside its wasmtime store, this one bounds every *native
1746/// subprocess* the runtime spawns — `rlimit(2)` ceilings applied before `execve`, a Linux
1747/// cgroup v2 scope around the whole process tree, and a periodic workdir-size check. A capsule
1748/// that cannot escape containment can still wedge its host by forking, allocating, or writing
1749/// without bound; this is the block that stops that.
1750///
1751/// Every field is `Option` for the same reason [`ResourceLimits`]'s are: this type carries only
1752/// what the manifest actually declared, and the runtime substitutes its own default for
1753/// anything omitted (see `capsule_runtime::resources::HostResourceLimits`). An omitted field —
1754/// or an omitted block — means defaults, never "unlimited".
1755#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1756pub struct ResourceCapabilities {
1757    /// `RLIMIT_NPROC` headroom for a spawned subprocess that has no cgroup scope: the uid's task
1758    /// count at that spawn plus this value, counted in threads on Linux and processes on macOS.
1759    /// No effect on Linux when the subprocess tree has a cgroup scope, which `cgroup_pids_max`
1760    /// bounds instead.
1761    pub max_processes: Option<u64>,
1762    /// `RLIMIT_NOFILE` hard ceiling on each spawned subprocess.
1763    pub max_open_files: Option<u64>,
1764    /// `RLIMIT_FSIZE` hard ceiling, in bytes, on each spawned subprocess.
1765    pub max_file_size_bytes: Option<u64>,
1766    /// CPU time, in CPU-seconds, after which `RLIMIT_CPU` sends each spawned subprocess
1767    /// `SIGXCPU`, with `SIGKILL` one second later.
1768    pub cpu_seconds: Option<u64>,
1769    /// `RLIMIT_AS` (Linux) / `RLIMIT_DATA` (macOS) hard ceiling, in bytes, on each spawned
1770    /// subprocess.
1771    pub memory_bytes: Option<u64>,
1772    /// cgroup v2 `memory.max`, in bytes — aggregate across the whole subprocess tree. Linux only.
1773    pub cgroup_memory_bytes: Option<u64>,
1774    /// cgroup v2 `pids.max` — aggregate across the whole subprocess tree. Linux only.
1775    pub cgroup_pids_max: Option<u64>,
1776    /// cgroup v2 `cpu.max` quota as a percentage of one core (200 = two cores). Linux only.
1777    pub cgroup_cpu_percent: Option<u32>,
1778    /// cgroup v2 `io.max` read+write bytes/sec on the workdir's backing device. Linux only,
1779    /// best-effort (the backing device cannot always be resolved).
1780    pub cgroup_io_bytes_per_sec: Option<u64>,
1781    /// Ceiling on total workdir size, in bytes, enforced by a periodic check on every platform.
1782    pub workdir_max_bytes: Option<u64>,
1783}
1784
1785/// Durable, capsule-scoped state for one tool, driver or hook: a host directory outside every
1786/// session workdir, preopened into the guest as `state/`.
1787///
1788/// Absent is deny — no declaration means no second preopen and no directory anywhere. The store
1789/// is keyed by capsule rather than by workdir precisely so it survives a launch that gets a fresh
1790/// session workdir, and so two capsules launched in the same directory never share one.
1791#[derive(Debug, Clone, PartialEq, Eq, Default)]
1792pub struct StateCapabilities {
1793    /// Directory name under `~/.murmur/state/`. `None` means "use the capsule name", which is
1794    /// what the overwhelming majority of declarations want.
1795    ///
1796    /// Read from the capsule operator's own manifest entry and never from the artifact's bundled
1797    /// manifest, so a registry-pulled tool cannot claim a store that already exists. The *shape*
1798    /// of the name is not checked here — a store name is a runtime concern, refused as
1799    /// `E-CAP-009`, exactly as `capabilities.filesystem.scope`'s shape is refused as `E-CAP-002`.
1800    pub store: Option<String>,
1801}
1802
1803// ── Containment class ─────────────────────────────────────────────────────────
1804
1805/// How strongly the host must contain a capsule's *subprocess* tree, declared as a floor
1806/// by the operator and satisfied (or not) by the host's kernel.
1807///
1808/// Declaration order is enforcement order — the `Ord` derive is load-bearing, so variants
1809/// must stay listed weakest-first. Combining floors from several sources is `max`, never
1810/// `min` (see [`effective_containment_floor`]): no source can lower what another raised.
1811///
1812/// The class an operator *declares* is a requirement. The class a host actually *achieves*
1813/// is derived only from a live kernel probe (`capsule_runtime::containment`), never from
1814/// this declaration.
1815#[derive(
1816    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1817)]
1818#[serde(rename_all = "snake_case")]
1819pub enum ContainmentClass {
1820    /// No kernel mechanism required. Capability declarations are honored by the runtime's
1821    /// own dispatch layer and by convention; anything the host cannot mediate is warned
1822    /// about, not refused. Every host satisfies this, including macOS. The default.
1823    #[default]
1824    Advisory,
1825    /// Landlock filesystem mediation + seccomp exec/network allowlisting over the *host*
1826    /// filesystem: every path outside the workdir must be an explicit manifest grant.
1827    /// Requires Linux 5.13+ with a usable Landlock ABI.
1828    Scoped,
1829    /// A private filesystem root: mount-namespace + `pivot_root` isolation onto a composed
1830    /// root, so the host filesystem is not merely mediated but absent. Achievable on an
1831    /// uncontainerised Linux host with a usable Landlock ABI, unprivileged user namespaces,
1832    /// and — where AppArmor's `restrict_unprivileged_userns` is active — the shipped
1833    /// `mur-sealed` profile loaded. A host that cannot back it refuses the launch with a
1834    /// mechanism-specific reason rather than silently degrading to `Scoped` or `Advisory`.
1835    /// See `docs/content/reference/sealed-containment-manual-verification.md`.
1836    Sealed,
1837}
1838
1839impl ContainmentClass {
1840    /// The lowercase wire name, identical in the manifest, the workspace config, the
1841    /// `--containment` flag, `trace.jsonl`, and `--explain-scope` output.
1842    pub fn as_str(self) -> &'static str {
1843        match self {
1844            Self::Advisory => "advisory",
1845            Self::Scoped => "scoped",
1846            Self::Sealed => "sealed",
1847        }
1848    }
1849
1850    /// Every value, weakest first — the single source for the "must be one of: …" text.
1851    pub const ALL: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1852}
1853
1854impl std::fmt::Display for ContainmentClass {
1855    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1856        f.write_str(self.as_str())
1857    }
1858}
1859
1860/// A string that is not one of the three containment class names.
1861#[derive(Debug, Clone, PartialEq, Eq)]
1862pub struct ParseContainmentClassError {
1863    pub value: String,
1864}
1865
1866impl std::fmt::Display for ParseContainmentClassError {
1867    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1868        write!(
1869            f,
1870            "must be one of: advisory, scoped, sealed; got '{}'",
1871            self.value
1872        )
1873    }
1874}
1875
1876impl std::error::Error for ParseContainmentClassError {}
1877
1878impl std::str::FromStr for ContainmentClass {
1879    type Err = ParseContainmentClassError;
1880
1881    fn from_str(value: &str) -> Result<Self, Self::Err> {
1882        match value.trim() {
1883            "advisory" => Ok(Self::Advisory),
1884            "scoped" => Ok(Self::Scoped),
1885            "sealed" => Ok(Self::Sealed),
1886            other => Err(ParseContainmentClassError {
1887                value: other.to_string(),
1888            }),
1889        }
1890    }
1891}
1892
1893/// The one floor a single `mur run` invocation must clear: the *strongest* class any source
1894/// asked for, defaulting to [`ContainmentClass::Advisory`] when no source declares one.
1895///
1896/// Monotonic by construction — an absent source does not participate, and no present source
1897/// can lower what another raised. Argument order carries no precedence.
1898pub fn effective_containment_floor(
1899    workspace_default: Option<ContainmentClass>,
1900    manifest_declared: Option<ContainmentClass>,
1901    cli_flag: Option<ContainmentClass>,
1902) -> ContainmentClass {
1903    [workspace_default, manifest_declared, cli_flag]
1904        .into_iter()
1905        .flatten()
1906        .max()
1907        .unwrap_or_default()
1908}
1909
1910#[derive(Debug, Clone, PartialEq, Eq)]
1911pub struct Capabilities {
1912    pub network: Option<NetworkCapabilities>,
1913    /// Which peers this capsule may redeem a peer-file handle against. `None` means no
1914    /// `fetch-peer-file` tool exists and nothing can be fetched — absent is deny.
1915    pub peer_fetch: Option<PeerFetchCapabilities>,
1916    pub filesystem: Option<FilesystemCapabilities>,
1917    pub shell: Option<ShellCapabilities>,
1918    pub spawn: Option<SpawnCapabilities>,
1919    pub env: Option<EnvCapabilities>,
1920    pub limits: Option<ResourceLimits>,
1921    pub resources: Option<ResourceCapabilities>,
1922    /// Durable state store for this artifact. `None` — the overwhelmingly common case — is deny:
1923    /// no `state/` preopen, and no directory created anywhere. See [`StateCapabilities`].
1924    pub state: Option<StateCapabilities>,
1925    /// Per-hook grant of the `murmur:task-io/read` host import. Only ever `Some` on a
1926    /// `runtime: hook` artifact entry — see [`TaskIoCapabilities`].
1927    pub task_io: Option<TaskIoCapabilities>,
1928    /// Per-hook grant of the `murmur:conversation/read` host import — see
1929    /// [`ConversationCapabilities`].
1930    pub conversation: Option<ConversationCapabilities>,
1931    /// Capsule-wide grant of the runtime-provided `submit-plan` tool. `None` is deny: the tool's
1932    /// synthetic manifest is not written, so it is absent from the model's inventory entirely.
1933    pub plan: Option<PlanCapabilities>,
1934    /// Capsule-wide grant to pull skills and tools at runtime. `None` is deny: every
1935    /// `manage.pull` is refused. See [`InstallCapabilities`].
1936    pub install: Option<InstallCapabilities>,
1937    /// Minimum containment class this capsule declares. `None` (the overwhelmingly common
1938    /// case) means the capsule states no requirement and inherits whatever the workspace
1939    /// config or `--containment` asks for, defaulting to `advisory`.
1940    pub containment: Option<ContainmentClass>,
1941}
1942
1943#[derive(Debug, Error)]
1944pub enum RuntimeManifestError {
1945    #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
1946    NotFound(String),
1947    #[error("{0}")]
1948    YamlSyntax(String),
1949    #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
1950    MissingField { field: String },
1951    #[error(
1952        "{}: invalid artifact declaration at index {index}: {message}",
1953        MANIFEST_FILENAME
1954    )]
1955    InvalidArtifact { index: usize, message: String },
1956    #[error(
1957        "{}: invalid inference config for '{field}': {message}",
1958        MANIFEST_FILENAME
1959    )]
1960    InvalidInferenceConfig { field: String, message: String },
1961    #[error(
1962        "{}: invalid capability config for '{field}': {message}",
1963        MANIFEST_FILENAME
1964    )]
1965    InvalidCapabilities { field: String, message: String },
1966    /// A `capabilities.shell.allow` entry that is not one bare binary name. `reason` completes
1967    /// "'<entry>' …".
1968    #[error(
1969        "{}: invalid capability config for 'capabilities.shell.allow[{index}]': '{}' {reason}; \
1970         an entry is a bare binary name",
1971        MANIFEST_FILENAME,
1972        entry.escape_debug()
1973    )]
1974    ShellAllowEntry {
1975        index: usize,
1976        entry: String,
1977        reason: &'static str,
1978    },
1979    #[error(
1980        "{}: invalid exports config for '{field}': {message}",
1981        MANIFEST_FILENAME
1982    )]
1983    InvalidExports { field: String, message: String },
1984    #[error(
1985        "{}: invalid control config for '{field}': {message}",
1986        MANIFEST_FILENAME
1987    )]
1988    InvalidControl { field: String, message: String },
1989    #[error(
1990        "{}: invalid network config for '{field}': {message}",
1991        MANIFEST_FILENAME
1992    )]
1993    InvalidNetworkConfig { field: String, message: String },
1994    /// A credential's `scopes` lists a retired door method name, one of [`RETIRED_DOOR_SCOPES`].
1995    /// `replacement` is the method name that replaces it.
1996    #[error(
1997        "{}: invalid network config for '{field}': '{scope}' is a retired name of a door \
1998         method, and the door answers only the names its card lists",
1999        MANIFEST_FILENAME
2000    )]
2001    RetiredDoorScope {
2002        field: String,
2003        scope: String,
2004        replacement: &'static str,
2005    },
2006    /// `lifecycle.task_acceptance: queue` with `lifecycle.queue_depth: 0`; see
2007    /// [`LifecycleConfig::queue_has_room`].
2008    #[error(
2009        "{}: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves task_acceptance: \
2010         queue no room for a waiting task",
2011        MANIFEST_FILENAME
2012    )]
2013    QueueWithoutRoom,
2014    /// A top-level `mur_version` key, whatever its value. The message names the replacement so a
2015    /// surface that prints only the message still points at it.
2016    #[error(
2017        "{}: 'mur_version' is not a top-level key — the mur release a deploy installs is pinned \
2018         as 'deploy.mur_version'",
2019        MANIFEST_FILENAME
2020    )]
2021    TopLevelMurVersion,
2022    #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
2023    InvalidTraceConfig { field: String, message: String },
2024    /// A `lifecycle` field holds a value the runtime cannot act on; `field` is its dotted path.
2025    #[error(
2026        "{}: invalid lifecycle config for '{field}': {message}",
2027        MANIFEST_FILENAME
2028    )]
2029    InvalidLifecycleConfig { field: String, message: String },
2030    /// A field naming an artifact to resolve does not hold a full `<scope>/<name>`, or a
2031    /// `source:` entry's name is not a bare name. `hint` names the form to write instead.
2032    #[error("{}: invalid artifact name in '{field}': {message}", MANIFEST_FILENAME)]
2033    InvalidArtifactName {
2034        field: String,
2035        message: String,
2036        hint: Option<String>,
2037    },
2038    /// The manifest declares a top-level [`REGISTRY_BLOCK_KEY`].
2039    #[error("{}", REGISTRY_BLOCK_MESSAGE)]
2040    RegistryBlock,
2041    /// A `gateway:` names an upstream but neither binds a non-blank `api_key` nor declares
2042    /// `keyless: true`. Decided on what the manifest wrote, so it fires identically with and
2043    /// without secret resolution.
2044    #[error(
2045        "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
2046         gateway.endpoint '{endpoint}' but binds no credential",
2047        MANIFEST_FILENAME
2048    )]
2049    GatewayWithoutCredential {
2050        index: usize,
2051        name: String,
2052        endpoint: String,
2053    },
2054    #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
2055    Io {
2056        path: String,
2057        #[source]
2058        source: std::io::Error,
2059    },
2060}
2061
2062#[derive(Debug, Deserialize)]
2063struct RawRuntimeManifest {
2064    name: Option<String>,
2065    version: Option<String>,
2066    #[serde(default)]
2067    artifacts: Vec<RawArtifact>,
2068    #[serde(default)]
2069    capabilities: Option<RawCapabilities>,
2070    #[serde(default)]
2071    inference: Option<RawInferenceConfig>,
2072    #[serde(default)]
2073    context: Option<RawContextConfig>,
2074    #[serde(default)]
2075    observability: Option<RawObservabilityConfig>,
2076    #[serde(default)]
2077    trace: Option<RawTraceConfig>,
2078    #[serde(default)]
2079    network: Option<RawNetworkConfig>,
2080    #[serde(default)]
2081    lifecycle: Option<RawLifecycleConfig>,
2082    #[serde(default)]
2083    exports: Option<RawExports>,
2084    #[serde(default)]
2085    control: Option<RawControl>,
2086    #[serde(default)]
2087    deploy: Option<RawDeploy>,
2088    /// Captured only to refuse it, as [`RuntimeManifestError::TopLevelMurVersion`]: the pin is
2089    /// `deploy.mur_version`. Present-detection rather than a typed value, so a number, a blank
2090    /// string and a bare `mur_version:` are all refused.
2091    #[serde(default, deserialize_with = "deserialize_present")]
2092    mur_version: Option<serde_norway::Value>,
2093    /// Captured only to refuse it. `config:` is delivered to one artifact through that artifact's
2094    /// own grant, so a capsule-wide block reaches nothing; without this field it would be one of
2095    /// the unrecognized top-level keys this manifest silently ignores.
2096    #[serde(default, deserialize_with = "deserialize_present")]
2097    config: Option<serde_norway::Value>,
2098    /// Read by `mur build` and `mur publish` off this same file to select the packaging type.
2099    /// Declared here so a manifest that is both runnable and publishable does not report its own
2100    /// build-side keys as unrecognized; the run path takes no meaning from it and never inspects
2101    /// the value, so any shape parses.
2102    #[serde(default)]
2103    #[allow(dead_code)]
2104    runtime: Option<serde_norway::Value>,
2105    /// Read by `mur build` and `mur publish`: it separates a native tool from a wasm one, both in
2106    /// the derived packaging type and in `mur publish`'s platform tagging. Ignored on the run
2107    /// path, and untyped for the same reason as `runtime:` above.
2108    #[serde(default)]
2109    #[allow(dead_code)]
2110    implementation: Option<serde_norway::Value>,
2111    /// Read by `mur build` and `mur publish` as the declared packaging type, which overrides the
2112    /// one derived from `runtime:` and `implementation:`. Ignored on the run path, and untyped for
2113    /// the same reason as `runtime:` above.
2114    #[serde(default)]
2115    #[allow(dead_code)]
2116    execution: Option<serde_norway::Value>,
2117    /// Read by `mur build` as the list of companion files to pack beside the manifest. Ignored on
2118    /// the run path, and untyped for the same reason as `runtime:` above.
2119    #[serde(default)]
2120    #[allow(dead_code)]
2121    requires_files: Option<serde_norway::Value>,
2122    #[serde(flatten)]
2123    unknown: UnknownKeys,
2124}
2125
2126#[derive(Debug, Deserialize)]
2127struct RawDeploy {
2128    #[serde(default)]
2129    mur_version: Option<String>,
2130    #[serde(flatten)]
2131    unknown: UnknownKeys,
2132}
2133
2134#[derive(Debug, Deserialize)]
2135struct RawControl {
2136    /// Untyped, so a value that is not a list of strings is refused as an
2137    /// [`RuntimeManifestError::InvalidControl`] naming the field rather than as a serde type error
2138    /// naming a line number.
2139    #[serde(default)]
2140    settings: Option<serde_norway::Value>,
2141    #[serde(default)]
2142    secrets: Option<serde_norway::Value>,
2143    #[serde(default)]
2144    agent_settings: Option<serde_norway::Value>,
2145    #[serde(flatten)]
2146    unknown: UnknownKeys,
2147}
2148
2149#[derive(Debug, Deserialize)]
2150struct RawExports {
2151    #[serde(default)]
2152    files: Option<RawFileExport>,
2153    #[serde(default)]
2154    peer_files: Option<RawPeerFilesExport>,
2155    #[serde(default)]
2156    peer_tasks: Option<RawPeerTasksExport>,
2157    #[serde(flatten)]
2158    unknown: UnknownKeys,
2159}
2160
2161#[derive(Debug, Deserialize)]
2162struct RawPeerTasksExport {
2163    // `Option` so an omitted `accept` is distinguishable from an explicit `false`: the omission
2164    // is refused rather than defaulted, because consent is never inferred.
2165    #[serde(default)]
2166    accept: Option<bool>,
2167    #[serde(flatten)]
2168    unknown: UnknownKeys,
2169}
2170
2171#[derive(Debug, Deserialize)]
2172struct RawPeerFilesExport {
2173    #[serde(default)]
2174    root: Option<String>,
2175    /// Untyped for the same reason as [`RawFileExport::max_bytes`]: `30m` is a YAML string and a
2176    /// bare `1800` is a YAML integer, and a wrong-shaped value has to reach the parser to be
2177    /// reported as an [`RuntimeManifestError::InvalidExports`] naming the field.
2178    #[serde(default)]
2179    max_ttl: Option<serde_norway::Value>,
2180    #[serde(default)]
2181    max_bytes: Option<serde_norway::Value>,
2182    #[serde(flatten)]
2183    unknown: UnknownKeys,
2184}
2185
2186#[derive(Debug, Deserialize)]
2187struct RawFileExport {
2188    #[serde(default)]
2189    root: Option<String>,
2190    #[serde(default)]
2191    mode: Option<String>,
2192    /// Deliberately untyped: `max_bytes` accepts both a bare integer and a suffixed string, and
2193    /// a wrong-shaped value has to reach [`parse_exports`] to be reported as an
2194    /// [`RuntimeManifestError::InvalidExports`] naming the field rather than as a serde type
2195    /// error naming a line number.
2196    #[serde(default)]
2197    max_bytes: Option<serde_norway::Value>,
2198    #[serde(flatten)]
2199    unknown: UnknownKeys,
2200}
2201
2202#[derive(Debug, Deserialize)]
2203struct RawLifecycleConfig {
2204    #[serde(default)]
2205    task_acceptance: Option<TaskAcceptance>,
2206    #[serde(default)]
2207    after_task: Option<AfterTask>,
2208    #[serde(default)]
2209    queue_depth: Option<usize>,
2210    #[serde(default)]
2211    input_timeout_secs: Option<u64>,
2212    #[serde(default)]
2213    conversation: Option<ConversationMode>,
2214    #[serde(default)]
2215    max_task_reopens: Option<u32>,
2216    #[serde(default)]
2217    shell_grace_secs: Option<u64>,
2218    #[serde(default)]
2219    delegation_deadline_secs: Option<u64>,
2220    #[serde(default)]
2221    deadline_notice_secs: Option<u64>,
2222    #[serde(default)]
2223    keep_working: Option<RawKeepWorking>,
2224    #[serde(default)]
2225    done_check: Option<RawDoneCheck>,
2226    #[serde(flatten)]
2227    unknown: UnknownKeys,
2228}
2229
2230#[derive(Debug, Deserialize)]
2231struct RawDoneCheck {
2232    #[serde(default)]
2233    command: Option<String>,
2234    #[serde(default)]
2235    limit: Option<u32>,
2236    #[serde(default)]
2237    timeout_secs: Option<u64>,
2238    #[serde(flatten)]
2239    unknown: UnknownKeys,
2240}
2241
2242#[derive(Debug, Deserialize)]
2243struct RawKeepWorking {
2244    #[serde(default)]
2245    message: Option<String>,
2246    #[serde(default)]
2247    limit: Option<u32>,
2248    #[serde(flatten)]
2249    unknown: UnknownKeys,
2250}
2251
2252#[derive(Debug, Deserialize)]
2253struct RawNetworkConfig {
2254    internal_port: Option<u16>,
2255    #[serde(default)]
2256    authentication: Option<RawNetworkAuthentication>,
2257    #[serde(flatten)]
2258    unknown: UnknownKeys,
2259}
2260
2261#[derive(Debug, Deserialize)]
2262struct RawNetworkAuthentication {
2263    #[serde(default)]
2264    scheme: Option<String>,
2265    /// Keyed by credential name. A `BTreeMap`, so the lowered credentials come out sorted by name
2266    /// whatever order the manifest wrote them in.
2267    #[serde(default)]
2268    credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
2269    #[serde(flatten)]
2270    unknown: UnknownKeys,
2271}
2272
2273#[derive(Debug, Deserialize)]
2274struct RawDoorCredential {
2275    /// Untyped, so a value that is not a list of strings is refused as an
2276    /// [`RuntimeManifestError::InvalidNetworkConfig`] naming the field rather than as a serde type
2277    /// error naming a line number.
2278    #[serde(default)]
2279    scopes: Option<serde_norway::Value>,
2280    #[serde(flatten)]
2281    unknown: UnknownKeys,
2282}
2283
2284#[derive(Debug, Deserialize)]
2285struct RawContextConfig {
2286    max_tokens: Option<u32>,
2287    /// `on` | `off`. Kept as a raw `String` so an unrecognized value reports as an invalid
2288    /// value for this key rather than as a serde variant error against the whole block.
2289    #[serde(default)]
2290    record: Option<String>,
2291    #[serde(default)]
2292    record_store: Option<String>,
2293    #[serde(default)]
2294    seed_budget: Option<f32>,
2295    #[serde(default)]
2296    seed_overflow_margin: Option<f32>,
2297    /// Untyped so an empty block, a null block and an unknown key inside it are each reported
2298    /// as an invalid `context.retain` rather than as a serde message about a struct field.
2299    #[serde(default, deserialize_with = "present_yaml_value")]
2300    retain: Option<serde_norway::Value>,
2301    #[serde(flatten)]
2302    unknown: UnknownKeys,
2303}
2304
2305#[derive(Debug, Deserialize)]
2306struct RawObservabilityConfig {
2307    #[serde(default)]
2308    otel_endpoint: Option<String>,
2309    #[serde(default)]
2310    eval: Option<RawEvalConfig>,
2311    #[serde(flatten)]
2312    unknown: UnknownKeys,
2313}
2314
2315#[derive(Debug, Deserialize)]
2316struct RawTraceConfig {
2317    /// Untyped so an unparseable mode reaches [`resolve_trace_capture`] and is reported as a
2318    /// [`RuntimeManifestError::InvalidTraceConfig`] naming the field and the accepted values,
2319    /// rather than as a serde message about an unknown variant.
2320    #[serde(default)]
2321    capture: Option<String>,
2322    /// Untyped for the same reason as `context.retain` — see [`RawContextConfig::retain`].
2323    #[serde(default, deserialize_with = "present_yaml_value")]
2324    retain: Option<serde_norway::Value>,
2325    #[serde(flatten)]
2326    unknown: UnknownKeys,
2327}
2328
2329#[derive(Debug, Deserialize)]
2330struct RawEvalConfig {
2331    #[serde(default)]
2332    dataset_id: Option<String>,
2333    #[serde(default)]
2334    scorers: Vec<RawScorerConfig>,
2335    #[serde(flatten)]
2336    unknown: UnknownKeys,
2337}
2338
2339#[derive(Debug, Deserialize)]
2340struct RawScorerConfig {
2341    #[serde(rename = "type")]
2342    scorer_type: String,
2343    name: Option<String>,
2344    max: Option<u64>,
2345    expected: Option<Vec<String>>,
2346    #[serde(flatten)]
2347    unknown: UnknownKeys,
2348}
2349
2350#[derive(Debug, Deserialize)]
2351struct RawArtifact {
2352    name: Option<String>,
2353    version: Option<String>,
2354    runtime: Option<String>,
2355    #[serde(default)]
2356    source: Option<String>,
2357    #[serde(default)]
2358    local_source: Option<bool>,
2359    #[serde(default)]
2360    prompt_payload: Option<bool>,
2361    #[serde(default)]
2362    capabilities: Option<RawCapabilities>,
2363    #[serde(default)]
2364    on_overflow: Option<String>,
2365    /// Untyped, and deserialized through [`deserialize_present`] rather than plain `Option`, so
2366    /// `config:` written with nothing under it arrives as `Some(Value::Null)` instead of
2367    /// collapsing into the absent case. The two mean different things: absent grants no variable,
2368    /// while an empty declaration is a written statement that carries nothing and is refused.
2369    #[serde(default, deserialize_with = "deserialize_present")]
2370    config: Option<serde_norway::Value>,
2371    /// `Some(None)` is `gateway:` written with nothing under it, refused as a block with no
2372    /// endpoint rather than collapsed into the absent case.
2373    #[serde(default, deserialize_with = "deserialize_present")]
2374    gateway: Option<Option<RawArtifactGateway>>,
2375    #[serde(flatten)]
2376    unknown: UnknownKeys,
2377}
2378
2379#[derive(Debug, Deserialize)]
2380struct RawArtifactGateway {
2381    #[serde(default)]
2382    endpoint: Option<String>,
2383    /// `Some(None)` is `api_key:` written as YAML null: present for the contradiction with
2384    /// `keyless: true`, and no credential for the missing-credential refusal.
2385    #[serde(default, deserialize_with = "deserialize_present")]
2386    api_key: Option<Option<String>>,
2387    /// Untyped, so a non-boolean is refused as an invalid declaration naming the field.
2388    #[serde(default)]
2389    keyless: Option<serde_norway::Value>,
2390    #[serde(flatten)]
2391    unknown: UnknownKeys,
2392}
2393
2394/// Deserialize a present key into `Some(value)` even when its value is YAML null, leaving `None`
2395/// to mean the key was absent and `#[serde(default)]` supplied it.
2396///
2397/// serde's own `Option` impl maps a null value to `None`, which erases exactly the distinction
2398/// `config:` rests on.
2399fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
2400where
2401    T: Deserialize<'de>,
2402    D: serde::Deserializer<'de>,
2403{
2404    T::deserialize(deserializer).map(Some)
2405}
2406
2407#[derive(Debug, Deserialize)]
2408struct RawCapabilities {
2409    #[serde(default)]
2410    network: Option<RawNetworkCapabilities>,
2411    #[serde(default)]
2412    peer_fetch: Option<RawPeerFetchCapabilities>,
2413    #[serde(default)]
2414    filesystem: Option<RawFilesystemCapabilities>,
2415    #[serde(default)]
2416    shell: Option<RawShellCapabilities>,
2417    #[serde(default)]
2418    spawn: Option<RawSpawnCapabilities>,
2419    #[serde(default)]
2420    env: Option<RawEnvCapabilities>,
2421    #[serde(default)]
2422    limits: Option<RawResourceLimits>,
2423    #[serde(default)]
2424    resources: Option<RawResourceCapabilities>,
2425    #[serde(default)]
2426    state: Option<RawStateCapabilities>,
2427    #[serde(default)]
2428    task_io: Option<RawTaskIoCapabilities>,
2429    #[serde(default)]
2430    conversation: Option<RawConversationCapabilities>,
2431    #[serde(default)]
2432    plan: Option<RawPlanCapabilities>,
2433    #[serde(default)]
2434    install: Option<RawInstallCapabilities>,
2435    /// Kept as a raw `String` rather than a `ContainmentClass` so a typo reports through
2436    /// `InvalidCapabilities` like every other bad capability value, instead of a bare serde
2437    /// "unknown variant" error attributed to the whole `capabilities:` block.
2438    #[serde(default)]
2439    containment: Option<String>,
2440    #[serde(flatten)]
2441    unknown: UnknownKeys,
2442}
2443
2444#[derive(Debug, Deserialize)]
2445struct RawTaskIoCapabilities {
2446    // `Option` so an omitted `read` is distinguishable from an explicit `false`: the omission
2447    // is rejected outright rather than defaulted, because a capability is never inferred.
2448    #[serde(default)]
2449    read: Option<bool>,
2450    #[serde(flatten)]
2451    unknown: UnknownKeys,
2452}
2453
2454#[derive(Debug, Deserialize)]
2455struct RawConversationCapabilities {
2456    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2457    // than defaulted.
2458    #[serde(default)]
2459    read: Option<bool>,
2460    #[serde(flatten)]
2461    unknown: UnknownKeys,
2462}
2463
2464#[derive(Debug, Deserialize)]
2465struct RawPlanCapabilities {
2466    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2467    // than defaulted.
2468    #[serde(default)]
2469    submit: Option<bool>,
2470    #[serde(flatten)]
2471    unknown: UnknownKeys,
2472}
2473
2474#[derive(Debug, Deserialize)]
2475struct RawInstallCapabilities {
2476    #[serde(default)]
2477    skill: Vec<String>,
2478    #[serde(default)]
2479    tool: Vec<String>,
2480    #[serde(default)]
2481    from: Option<Vec<String>>,
2482    #[serde(flatten)]
2483    unknown: UnknownKeys,
2484}
2485
2486#[derive(Debug, Deserialize)]
2487struct RawSpawnCapabilities {
2488    #[serde(default)]
2489    allow: Vec<String>,
2490    #[serde(flatten)]
2491    unknown: UnknownKeys,
2492}
2493
2494#[derive(Debug, Deserialize)]
2495struct RawEnvCapabilities {
2496    #[serde(default)]
2497    allow: Vec<String>,
2498    #[serde(flatten)]
2499    unknown: UnknownKeys,
2500}
2501
2502#[derive(Debug, Deserialize)]
2503struct RawResourceLimits {
2504    #[serde(default)]
2505    memory_bytes: Option<usize>,
2506    #[serde(default)]
2507    table_elements: Option<usize>,
2508    #[serde(default)]
2509    instances: Option<usize>,
2510    #[serde(default)]
2511    deadline_seconds: Option<u64>,
2512    #[serde(flatten)]
2513    unknown: UnknownKeys,
2514}
2515
2516#[derive(Debug, Deserialize)]
2517struct RawStateCapabilities {
2518    #[serde(default)]
2519    store: Option<String>,
2520    #[serde(flatten)]
2521    unknown: UnknownKeys,
2522}
2523
2524#[derive(Debug, Deserialize)]
2525struct RawResourceCapabilities {
2526    #[serde(default)]
2527    max_processes: Option<u64>,
2528    #[serde(default)]
2529    max_open_files: Option<u64>,
2530    #[serde(default)]
2531    max_file_size_bytes: Option<u64>,
2532    #[serde(default)]
2533    cpu_seconds: Option<u64>,
2534    #[serde(default)]
2535    memory_bytes: Option<u64>,
2536    #[serde(default)]
2537    cgroup_memory_bytes: Option<u64>,
2538    #[serde(default)]
2539    cgroup_pids_max: Option<u64>,
2540    #[serde(default)]
2541    cgroup_cpu_percent: Option<u32>,
2542    #[serde(default)]
2543    cgroup_io_bytes_per_sec: Option<u64>,
2544    #[serde(default)]
2545    workdir_max_bytes: Option<u64>,
2546    #[serde(flatten)]
2547    unknown: UnknownKeys,
2548}
2549
2550#[derive(Debug, Deserialize)]
2551struct RawNetworkCapabilities {
2552    #[serde(default)]
2553    allow: Vec<String>,
2554    #[serde(default)]
2555    unix_sockets: bool,
2556    #[serde(flatten)]
2557    unknown: UnknownKeys,
2558}
2559
2560#[derive(Debug, Deserialize)]
2561struct RawPeerFetchCapabilities {
2562    #[serde(default)]
2563    allow: Vec<String>,
2564    #[serde(flatten)]
2565    unknown: UnknownKeys,
2566}
2567
2568#[derive(Debug, Deserialize)]
2569struct RawFilesystemCapabilities {
2570    #[serde(default)]
2571    scope: Option<String>,
2572    /// Same shape and defaulting convention as `RawNetworkCapabilities::unix_sockets`: a plain
2573    /// `bool` that is always present after parsing and defaults to `false`, never an `Option`.
2574    /// "The key is absent" and "the key says `false`" are the same declaration.
2575    #[serde(default)]
2576    workdir_exec: bool,
2577    /// Always a list after parsing, never an `Option`: an absent key and an empty list are the
2578    /// same declaration — nothing is protected.
2579    #[serde(default)]
2580    read_only: Vec<String>,
2581    /// Same defaulting convention as `workdir_exec`.
2582    #[serde(default)]
2583    records: bool,
2584    #[serde(flatten)]
2585    unknown: UnknownKeys,
2586}
2587
2588#[derive(Debug, Deserialize)]
2589struct RawShellCapabilities {
2590    #[serde(default)]
2591    allow: Vec<String>,
2592    #[serde(default)]
2593    strip_env: Option<Vec<String>>,
2594    #[serde(default)]
2595    baseline_env: Option<Vec<String>>,
2596    #[serde(default)]
2597    interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
2598    #[serde(default)]
2599    staged_runtime: Vec<RawStagedRuntimeGrant>,
2600    #[serde(flatten)]
2601    unknown: UnknownKeys,
2602}
2603
2604#[derive(Debug, Deserialize)]
2605struct RawInterpreterRuntimeGrant {
2606    #[serde(default)]
2607    binary: Option<String>,
2608    #[serde(default)]
2609    dirs: Vec<RawInterpreterRuntimeDir>,
2610    #[serde(flatten)]
2611    unknown: UnknownKeys,
2612}
2613
2614#[derive(Debug, Deserialize)]
2615struct RawInterpreterRuntimeDir {
2616    #[serde(default)]
2617    path: Option<String>,
2618    // `Option` so an omitted `list_dir` is distinguishable from an explicit `false`: the
2619    // omission is rejected outright rather than defaulted, because enumerability is never
2620    // inferred.
2621    #[serde(default)]
2622    list_dir: Option<bool>,
2623    #[serde(flatten)]
2624    unknown: UnknownKeys,
2625}
2626
2627#[derive(Debug, Deserialize)]
2628struct RawStagedRuntimeGrant {
2629    #[serde(default)]
2630    binary: Option<String>,
2631    #[serde(default)]
2632    source_path: Option<String>,
2633    // `Option` so an omitted `pin` reaches `parse_staged_runtime` and is rejected there with a
2634    // field-naming message, rather than defaulting to an empty string that would silently mean
2635    // "unpinned" — the one thing this field exists to make impossible.
2636    #[serde(default)]
2637    pin: Option<String>,
2638    #[serde(flatten)]
2639    unknown: UnknownKeys,
2640}
2641
2642#[derive(Debug, Deserialize)]
2643struct RawInferenceConfig {
2644    transport: Option<String>,
2645    /// Removed: the driver's upstream is `gateway.endpoint` on its artifact entry. Deserialized
2646    /// only so `parse_inference` can refuse it by name, naming where it moved.
2647    #[serde(default, deserialize_with = "deserialize_present")]
2648    endpoint: Option<serde_norway::Value>,
2649    model: Option<String>,
2650    /// Removed: the driver's key is `gateway.api_key` on its artifact entry. Deserialized only so
2651    /// `parse_inference` can refuse it by name, naming where it moved.
2652    #[serde(default, deserialize_with = "deserialize_present")]
2653    api_key: Option<serde_norway::Value>,
2654    #[serde(default)]
2655    command: Option<String>,
2656    #[serde(default)]
2657    system_prompt: Option<String>,
2658    #[serde(default)]
2659    system_prompt_file: Option<String>,
2660    #[serde(default)]
2661    system_prompt_artifact: Option<String>,
2662    #[serde(default)]
2663    driver: Option<RawInferenceDriver>,
2664    #[serde(default)]
2665    compaction: Option<RawCompactionConfig>,
2666    #[serde(default)]
2667    max_turns: Option<u32>,
2668    /// The `inference` spelling of the reopen budget is not a live field. It is deserialized
2669    /// only so `parse_inference` can refuse it; without it serde would ignore the key.
2670    #[serde(default)]
2671    max_task_reopens: Option<u32>,
2672    #[serde(default)]
2673    max_tokens: Option<u32>,
2674    #[serde(default)]
2675    max_session_tokens: Option<u64>,
2676    #[serde(default)]
2677    tool_refresh: Option<String>,
2678    #[serde(default)]
2679    alternates: Option<Vec<RawInferenceAlternate>>,
2680    #[serde(default)]
2681    retry: Option<RawInferenceRetry>,
2682    #[serde(flatten)]
2683    unknown: UnknownKeys,
2684}
2685
2686#[derive(Debug, Deserialize)]
2687struct RawInferenceRetry {
2688    #[serde(default)]
2689    max_retries: Option<u32>,
2690    #[serde(default)]
2691    initial_delay_ms: Option<u32>,
2692    #[serde(default)]
2693    max_delay_ms: Option<u32>,
2694    #[serde(flatten)]
2695    unknown: UnknownKeys,
2696}
2697
2698#[derive(Debug, Deserialize)]
2699struct RawInferenceAlternate {
2700    #[serde(default)]
2701    name: Option<String>,
2702    #[serde(default)]
2703    model: Option<String>,
2704    #[serde(default)]
2705    driver: Option<RawInferenceDriver>,
2706    #[serde(flatten)]
2707    unknown: UnknownKeys,
2708}
2709
2710#[derive(Debug, Deserialize)]
2711struct RawCompactionConfig {
2712    threshold: Option<f32>,
2713    model: Option<String>,
2714    system_prompt: Option<String>,
2715    system_prompt_file: Option<String>,
2716    dump_summaries: Option<bool>,
2717    max_tokens: Option<u32>,
2718    #[serde(default)]
2719    driver_config: Option<serde_norway::Value>,
2720    #[serde(flatten)]
2721    unknown: UnknownKeys,
2722}
2723
2724#[derive(Debug, Deserialize)]
2725struct RawInferenceDriver {
2726    artifact: Option<String>,
2727    #[serde(default)]
2728    config: Option<serde_norway::Value>,
2729    #[serde(flatten)]
2730    unknown: UnknownKeys,
2731}
2732
2733/// The keys one `Raw*` block carried that no field of it claims.
2734///
2735/// A `BTreeMap` so the capture order is the manifest's key order sorted, which keeps the
2736/// `W-SEC-019` lines for one manifest identical on every run. The values are discarded: a key this
2737/// build does not recognize is reported by name and by containing block, and printing whatever an
2738/// operator wrote under it would put manifest contents into a diagnostic that has no use for them.
2739type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2740
2741/// A `Raw*` deserialization block that captures the keys this build does not recognize.
2742///
2743/// Implemented for every `Raw*` struct in this file, which is asserted structurally by
2744/// `every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names` rather than left to
2745/// review: a manifest key added without its block learning about it is exactly the defect
2746/// `W-SEC-019` exists to report.
2747trait RawBlock {
2748    /// Every key serde accepts on this block, in declaration order and under the name serde
2749    /// matches (so a `#[serde(rename)]` field appears under its renamed spelling). This is the
2750    /// candidate set [`crate::unknown_manifest_keys::nearest_known_key`] suggests from.
2751    const KNOWN_KEYS: &'static [&'static str];
2752
2753    fn unknown_keys(&self) -> &UnknownKeys;
2754
2755    /// Descends into the blocks this one owns, in declaration order, so the reported paths read
2756    /// down the manifest the way the document does.
2757    ///
2758    /// Defaulted to a no-op, which is correct for a block that owns no other block. A block that
2759    /// does own one must override this: `every_raw_struct_descends_into_the_blocks_it_owns` reads
2760    /// this file's own text and fails, naming the struct and the field, if it does not — an
2761    /// unwalked block reports none of its keys, silently.
2762    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2763        let _ = (path, out);
2764    }
2765}
2766
2767impl RawBlock for RawRuntimeManifest {
2768    const KNOWN_KEYS: &'static [&'static str] = &[
2769        "name",
2770        "version",
2771        "artifacts",
2772        "capabilities",
2773        "inference",
2774        "context",
2775        "observability",
2776        "trace",
2777        "network",
2778        "lifecycle",
2779        "exports",
2780        "control",
2781        "deploy",
2782        "mur_version",
2783        "config",
2784        "runtime",
2785        "implementation",
2786        "execution",
2787        "requires_files",
2788    ];
2789    fn unknown_keys(&self) -> &UnknownKeys {
2790        &self.unknown
2791    }
2792
2793    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2794        let artifacts = child_path(path, "artifacts");
2795        for (index, artifact) in self.artifacts.iter().enumerate() {
2796            collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2797        }
2798        if let Some(capabilities) = &self.capabilities {
2799            collect_block(capabilities, &child_path(path, "capabilities"), out);
2800        }
2801        if let Some(inference) = &self.inference {
2802            collect_block(inference, &child_path(path, "inference"), out);
2803        }
2804        if let Some(context) = &self.context {
2805            collect_block(context, &child_path(path, "context"), out);
2806        }
2807        if let Some(observability) = &self.observability {
2808            collect_block(observability, &child_path(path, "observability"), out);
2809        }
2810        if let Some(trace) = &self.trace {
2811            collect_block(trace, &child_path(path, "trace"), out);
2812        }
2813        if let Some(network) = &self.network {
2814            collect_block(network, &child_path(path, "network"), out);
2815        }
2816        if let Some(lifecycle) = &self.lifecycle {
2817            collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2818        }
2819        if let Some(exports) = &self.exports {
2820            collect_block(exports, &child_path(path, "exports"), out);
2821        }
2822        if let Some(control) = &self.control {
2823            collect_block(control, &child_path(path, "control"), out);
2824        }
2825        if let Some(deploy) = &self.deploy {
2826            collect_block(deploy, &child_path(path, "deploy"), out);
2827        }
2828    }
2829}
2830
2831impl RawBlock for RawDeploy {
2832    const KNOWN_KEYS: &'static [&'static str] = &["mur_version"];
2833    fn unknown_keys(&self) -> &UnknownKeys {
2834        &self.unknown
2835    }
2836}
2837
2838impl RawBlock for RawControl {
2839    const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
2840    fn unknown_keys(&self) -> &UnknownKeys {
2841        &self.unknown
2842    }
2843}
2844
2845impl RawBlock for RawExports {
2846    const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files", "peer_tasks"];
2847    fn unknown_keys(&self) -> &UnknownKeys {
2848        &self.unknown
2849    }
2850
2851    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2852        if let Some(files) = &self.files {
2853            collect_block(files, &child_path(path, "files"), out);
2854        }
2855        if let Some(peer_files) = &self.peer_files {
2856            collect_block(peer_files, &child_path(path, "peer_files"), out);
2857        }
2858        if let Some(peer_tasks) = &self.peer_tasks {
2859            collect_block(peer_tasks, &child_path(path, "peer_tasks"), out);
2860        }
2861    }
2862}
2863
2864impl RawBlock for RawPeerTasksExport {
2865    const KNOWN_KEYS: &'static [&'static str] = &["accept"];
2866    fn unknown_keys(&self) -> &UnknownKeys {
2867        &self.unknown
2868    }
2869}
2870
2871impl RawBlock for RawPeerFilesExport {
2872    const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2873    fn unknown_keys(&self) -> &UnknownKeys {
2874        &self.unknown
2875    }
2876}
2877
2878impl RawBlock for RawFileExport {
2879    const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2880    fn unknown_keys(&self) -> &UnknownKeys {
2881        &self.unknown
2882    }
2883}
2884
2885/// Resolves `lifecycle.keep_working`, refusing a missing or blank `message` and a missing or zero
2886/// `limit`. The message keeps the operator's bytes as written: only the blank check trims.
2887fn parse_keep_working(raw: RawKeepWorking) -> Result<KeepWorking, RuntimeManifestError> {
2888    let invalid = |field: &str, message: &str| RuntimeManifestError::InvalidLifecycleConfig {
2889        field: format!("lifecycle.keep_working.{field}"),
2890        message: message.to_string(),
2891    };
2892    let message = match raw.message {
2893        None => return Err(invalid("message", "is required")),
2894        Some(message) if message.trim().is_empty() => {
2895            return Err(invalid("message", "must not be blank"))
2896        }
2897        Some(message) => message,
2898    };
2899    let limit = match raw.limit {
2900        None => return Err(invalid("limit", "is required")),
2901        Some(0) => {
2902            return Err(invalid(
2903                "limit",
2904                "must be at least 1; omit lifecycle.keep_working to turn the option off",
2905            ))
2906        }
2907        Some(limit) => limit,
2908    };
2909    Ok(KeepWorking { message, limit })
2910}
2911
2912/// Resolves `lifecycle.done_check`, refusing a missing, blank or wordless `command`, a command
2913/// whose program `shell_allow` (`capabilities.shell.allow`) does not list, a missing `limit` and a
2914/// zero `timeout_secs`. The command keeps the operator's bytes as written.
2915fn parse_done_check(
2916    raw: RawDoneCheck,
2917    shell_allow: &[String],
2918) -> Result<DoneCheck, RuntimeManifestError> {
2919    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidLifecycleConfig {
2920        field: format!("lifecycle.done_check.{field}"),
2921        message,
2922    };
2923    let command = match raw.command {
2924        None => return Err(invalid("command", "is required".into())),
2925        Some(command) if command.trim().is_empty() => {
2926            return Err(invalid("command", "must not be blank".into()))
2927        }
2928        Some(command) => command,
2929    };
2930    let Some(program) = split_shell_words(&command).into_iter().next() else {
2931        return Err(invalid("command", "must name a program".into()));
2932    };
2933    if !shell_allow.contains(&program) {
2934        return Err(invalid(
2935            "command",
2936            format!("runs '{program}', which is not in capabilities.shell.allow"),
2937        ));
2938    }
2939    let Some(limit) = raw.limit else {
2940        return Err(invalid("limit", "is required".into()));
2941    };
2942    let timeout_secs = match raw.timeout_secs {
2943        None => DoneCheck::DEFAULT_TIMEOUT_SECS,
2944        Some(0) => return Err(invalid("timeout_secs", "must be at least 1".into())),
2945        Some(secs) => secs,
2946    };
2947    Ok(DoneCheck {
2948        command,
2949        limit,
2950        timeout_secs,
2951    })
2952}
2953
2954impl RawBlock for RawLifecycleConfig {
2955    const KNOWN_KEYS: &'static [&'static str] = &[
2956        "task_acceptance",
2957        "after_task",
2958        "queue_depth",
2959        "input_timeout_secs",
2960        "conversation",
2961        "max_task_reopens",
2962        "shell_grace_secs",
2963        "delegation_deadline_secs",
2964        "deadline_notice_secs",
2965        "keep_working",
2966        "done_check",
2967    ];
2968    fn unknown_keys(&self) -> &UnknownKeys {
2969        &self.unknown
2970    }
2971
2972    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2973        if let Some(keep_working) = &self.keep_working {
2974            collect_block(keep_working, &child_path(path, "keep_working"), out);
2975        }
2976        if let Some(done_check) = &self.done_check {
2977            collect_block(done_check, &child_path(path, "done_check"), out);
2978        }
2979    }
2980}
2981
2982impl RawBlock for RawDoneCheck {
2983    const KNOWN_KEYS: &'static [&'static str] = &["command", "limit", "timeout_secs"];
2984    fn unknown_keys(&self) -> &UnknownKeys {
2985        &self.unknown
2986    }
2987}
2988
2989impl RawBlock for RawKeepWorking {
2990    const KNOWN_KEYS: &'static [&'static str] = &["message", "limit"];
2991    fn unknown_keys(&self) -> &UnknownKeys {
2992        &self.unknown
2993    }
2994}
2995
2996impl RawBlock for RawNetworkConfig {
2997    const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
2998    fn unknown_keys(&self) -> &UnknownKeys {
2999        &self.unknown
3000    }
3001
3002    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3003        if let Some(authentication) = &self.authentication {
3004            collect_block(authentication, &child_path(path, "authentication"), out);
3005        }
3006    }
3007}
3008
3009impl RawBlock for RawNetworkAuthentication {
3010    const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
3011    fn unknown_keys(&self) -> &UnknownKeys {
3012        &self.unknown
3013    }
3014
3015    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3016        let credentials_path = child_path(path, "credentials");
3017        for (name, credential) in self.credentials.iter().flatten() {
3018            collect_block(credential, &child_path(&credentials_path, name), out);
3019        }
3020    }
3021}
3022
3023impl RawBlock for RawDoorCredential {
3024    const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
3025    fn unknown_keys(&self) -> &UnknownKeys {
3026        &self.unknown
3027    }
3028}
3029
3030impl RawBlock for RawContextConfig {
3031    const KNOWN_KEYS: &'static [&'static str] = &[
3032        "max_tokens",
3033        "record",
3034        "record_store",
3035        "seed_budget",
3036        "seed_overflow_margin",
3037        "retain",
3038    ];
3039    fn unknown_keys(&self) -> &UnknownKeys {
3040        &self.unknown
3041    }
3042}
3043
3044impl RawBlock for RawObservabilityConfig {
3045    const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
3046    fn unknown_keys(&self) -> &UnknownKeys {
3047        &self.unknown
3048    }
3049
3050    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3051        if let Some(eval) = &self.eval {
3052            collect_block(eval, &child_path(path, "eval"), out);
3053        }
3054    }
3055}
3056
3057impl RawBlock for RawTraceConfig {
3058    const KNOWN_KEYS: &'static [&'static str] = &["capture", "retain"];
3059    fn unknown_keys(&self) -> &UnknownKeys {
3060        &self.unknown
3061    }
3062}
3063
3064impl RawBlock for RawEvalConfig {
3065    const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
3066    fn unknown_keys(&self) -> &UnknownKeys {
3067        &self.unknown
3068    }
3069
3070    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3071        let scorers = child_path(path, "scorers");
3072        for (index, scorer) in self.scorers.iter().enumerate() {
3073            collect_block(scorer, &format!("{scorers}[{index}]"), out);
3074        }
3075    }
3076}
3077
3078impl RawBlock for RawScorerConfig {
3079    const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
3080    fn unknown_keys(&self) -> &UnknownKeys {
3081        &self.unknown
3082    }
3083}
3084
3085impl RawBlock for RawArtifact {
3086    const KNOWN_KEYS: &'static [&'static str] = &[
3087        "name",
3088        "version",
3089        "runtime",
3090        "source",
3091        "local_source",
3092        "prompt_payload",
3093        "capabilities",
3094        "on_overflow",
3095        "config",
3096        "gateway",
3097    ];
3098    fn unknown_keys(&self) -> &UnknownKeys {
3099        &self.unknown
3100    }
3101
3102    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3103        if let Some(capabilities) = &self.capabilities {
3104            collect_block(capabilities, &child_path(path, "capabilities"), out);
3105        }
3106        if let Some(Some(gateway)) = &self.gateway {
3107            collect_block(gateway, &child_path(path, "gateway"), out);
3108        }
3109    }
3110}
3111
3112impl RawBlock for RawArtifactGateway {
3113    const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
3114    fn unknown_keys(&self) -> &UnknownKeys {
3115        &self.unknown
3116    }
3117}
3118
3119impl RawBlock for RawCapabilities {
3120    const KNOWN_KEYS: &'static [&'static str] = &[
3121        "network",
3122        "peer_fetch",
3123        "filesystem",
3124        "shell",
3125        "spawn",
3126        "env",
3127        "limits",
3128        "resources",
3129        "state",
3130        "task_io",
3131        "conversation",
3132        "plan",
3133        "install",
3134        "containment",
3135    ];
3136    fn unknown_keys(&self) -> &UnknownKeys {
3137        &self.unknown
3138    }
3139
3140    /// Reached from both [`RawRuntimeManifest`] and [`RawArtifact`], so the capsule-wide block and
3141    /// a per-artifact one report through identical code and differ only in the `path` they are
3142    /// handed — `capabilities.shell` against `artifacts[0].capabilities.shell`.
3143    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3144        if let Some(network) = &self.network {
3145            collect_block(network, &child_path(path, "network"), out);
3146        }
3147        if let Some(peer_fetch) = &self.peer_fetch {
3148            collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
3149        }
3150        if let Some(filesystem) = &self.filesystem {
3151            collect_block(filesystem, &child_path(path, "filesystem"), out);
3152        }
3153        if let Some(shell) = &self.shell {
3154            collect_block(shell, &child_path(path, "shell"), out);
3155        }
3156        if let Some(spawn) = &self.spawn {
3157            collect_block(spawn, &child_path(path, "spawn"), out);
3158        }
3159        if let Some(env) = &self.env {
3160            collect_block(env, &child_path(path, "env"), out);
3161        }
3162        if let Some(limits) = &self.limits {
3163            collect_block(limits, &child_path(path, "limits"), out);
3164        }
3165        if let Some(resources) = &self.resources {
3166            collect_block(resources, &child_path(path, "resources"), out);
3167        }
3168        if let Some(state) = &self.state {
3169            collect_block(state, &child_path(path, "state"), out);
3170        }
3171        if let Some(task_io) = &self.task_io {
3172            collect_block(task_io, &child_path(path, "task_io"), out);
3173        }
3174        if let Some(conversation) = &self.conversation {
3175            collect_block(conversation, &child_path(path, "conversation"), out);
3176        }
3177        if let Some(plan) = &self.plan {
3178            collect_block(plan, &child_path(path, "plan"), out);
3179        }
3180        if let Some(install) = &self.install {
3181            collect_block(install, &child_path(path, "install"), out);
3182        }
3183    }
3184}
3185
3186impl RawBlock for RawTaskIoCapabilities {
3187    const KNOWN_KEYS: &'static [&'static str] = &["read"];
3188    fn unknown_keys(&self) -> &UnknownKeys {
3189        &self.unknown
3190    }
3191}
3192
3193impl RawBlock for RawConversationCapabilities {
3194    const KNOWN_KEYS: &'static [&'static str] = &["read"];
3195    fn unknown_keys(&self) -> &UnknownKeys {
3196        &self.unknown
3197    }
3198}
3199
3200impl RawBlock for RawPlanCapabilities {
3201    const KNOWN_KEYS: &'static [&'static str] = &["submit"];
3202    fn unknown_keys(&self) -> &UnknownKeys {
3203        &self.unknown
3204    }
3205}
3206
3207impl RawBlock for RawInstallCapabilities {
3208    const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool", "from"];
3209    fn unknown_keys(&self) -> &UnknownKeys {
3210        &self.unknown
3211    }
3212}
3213
3214impl RawBlock for RawSpawnCapabilities {
3215    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3216    fn unknown_keys(&self) -> &UnknownKeys {
3217        &self.unknown
3218    }
3219}
3220
3221impl RawBlock for RawEnvCapabilities {
3222    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3223    fn unknown_keys(&self) -> &UnknownKeys {
3224        &self.unknown
3225    }
3226}
3227
3228impl RawBlock for RawResourceLimits {
3229    const KNOWN_KEYS: &'static [&'static str] = &[
3230        "memory_bytes",
3231        "table_elements",
3232        "instances",
3233        "deadline_seconds",
3234    ];
3235    fn unknown_keys(&self) -> &UnknownKeys {
3236        &self.unknown
3237    }
3238}
3239
3240impl RawBlock for RawStateCapabilities {
3241    const KNOWN_KEYS: &'static [&'static str] = &["store"];
3242    fn unknown_keys(&self) -> &UnknownKeys {
3243        &self.unknown
3244    }
3245}
3246
3247impl RawBlock for RawResourceCapabilities {
3248    const KNOWN_KEYS: &'static [&'static str] = &[
3249        "max_processes",
3250        "max_open_files",
3251        "max_file_size_bytes",
3252        "cpu_seconds",
3253        "memory_bytes",
3254        "cgroup_memory_bytes",
3255        "cgroup_pids_max",
3256        "cgroup_cpu_percent",
3257        "cgroup_io_bytes_per_sec",
3258        "workdir_max_bytes",
3259    ];
3260    fn unknown_keys(&self) -> &UnknownKeys {
3261        &self.unknown
3262    }
3263}
3264
3265impl RawBlock for RawNetworkCapabilities {
3266    const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
3267    fn unknown_keys(&self) -> &UnknownKeys {
3268        &self.unknown
3269    }
3270}
3271
3272impl RawBlock for RawPeerFetchCapabilities {
3273    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3274    fn unknown_keys(&self) -> &UnknownKeys {
3275        &self.unknown
3276    }
3277}
3278
3279impl RawBlock for RawFilesystemCapabilities {
3280    const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only", "records"];
3281    fn unknown_keys(&self) -> &UnknownKeys {
3282        &self.unknown
3283    }
3284}
3285
3286impl RawBlock for RawShellCapabilities {
3287    const KNOWN_KEYS: &'static [&'static str] = &[
3288        "allow",
3289        "strip_env",
3290        "baseline_env",
3291        "interpreter_runtime",
3292        "staged_runtime",
3293    ];
3294    fn unknown_keys(&self) -> &UnknownKeys {
3295        &self.unknown
3296    }
3297
3298    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3299        let interpreter_runtime = child_path(path, "interpreter_runtime");
3300        for (index, grant) in self.interpreter_runtime.iter().enumerate() {
3301            collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
3302        }
3303        let staged_runtime = child_path(path, "staged_runtime");
3304        for (index, grant) in self.staged_runtime.iter().enumerate() {
3305            collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
3306        }
3307    }
3308}
3309
3310impl RawBlock for RawInterpreterRuntimeGrant {
3311    const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
3312    fn unknown_keys(&self) -> &UnknownKeys {
3313        &self.unknown
3314    }
3315
3316    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3317        let dirs = child_path(path, "dirs");
3318        for (index, dir) in self.dirs.iter().enumerate() {
3319            collect_block(dir, &format!("{dirs}[{index}]"), out);
3320        }
3321    }
3322}
3323
3324impl RawBlock for RawInterpreterRuntimeDir {
3325    const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
3326    fn unknown_keys(&self) -> &UnknownKeys {
3327        &self.unknown
3328    }
3329}
3330
3331impl RawBlock for RawStagedRuntimeGrant {
3332    const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
3333    fn unknown_keys(&self) -> &UnknownKeys {
3334        &self.unknown
3335    }
3336}
3337
3338impl RawBlock for RawInferenceConfig {
3339    const KNOWN_KEYS: &'static [&'static str] = &[
3340        "transport",
3341        "endpoint",
3342        "model",
3343        "api_key",
3344        "command",
3345        "system_prompt",
3346        "system_prompt_file",
3347        "system_prompt_artifact",
3348        "driver",
3349        "compaction",
3350        "max_turns",
3351        "max_task_reopens",
3352        "max_tokens",
3353        "max_session_tokens",
3354        "tool_refresh",
3355        "alternates",
3356        "retry",
3357    ];
3358    fn unknown_keys(&self) -> &UnknownKeys {
3359        &self.unknown
3360    }
3361
3362    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3363        if let Some(driver) = &self.driver {
3364            collect_block(driver, &child_path(path, "driver"), out);
3365        }
3366        if let Some(compaction) = &self.compaction {
3367            collect_block(compaction, &child_path(path, "compaction"), out);
3368        }
3369        if let Some(retry) = &self.retry {
3370            collect_block(retry, &child_path(path, "retry"), out);
3371        }
3372        if let Some(alternates) = &self.alternates {
3373            let alternates_path = child_path(path, "alternates");
3374            for (index, alternate) in alternates.iter().enumerate() {
3375                collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
3376            }
3377        }
3378    }
3379}
3380
3381impl RawBlock for RawInferenceRetry {
3382    const KNOWN_KEYS: &'static [&'static str] =
3383        &["max_retries", "initial_delay_ms", "max_delay_ms"];
3384    fn unknown_keys(&self) -> &UnknownKeys {
3385        &self.unknown
3386    }
3387}
3388
3389impl RawBlock for RawInferenceAlternate {
3390    const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
3391    fn unknown_keys(&self) -> &UnknownKeys {
3392        &self.unknown
3393    }
3394
3395    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3396        if let Some(driver) = &self.driver {
3397            collect_block(driver, &child_path(path, "driver"), out);
3398        }
3399    }
3400}
3401
3402impl RawBlock for RawCompactionConfig {
3403    const KNOWN_KEYS: &'static [&'static str] = &[
3404        "threshold",
3405        "model",
3406        "system_prompt",
3407        "system_prompt_file",
3408        "dump_summaries",
3409        "max_tokens",
3410        "driver_config",
3411    ];
3412    fn unknown_keys(&self) -> &UnknownKeys {
3413        &self.unknown
3414    }
3415}
3416
3417impl RawBlock for RawInferenceDriver {
3418    const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
3419    fn unknown_keys(&self) -> &UnknownKeys {
3420        &self.unknown
3421    }
3422}
3423
3424/// Walks a parsed raw manifest and reports every key no block of it claimed, each block's own
3425/// keys before the blocks it owns, in the order the blocks appear in the manifest's own type.
3426///
3427/// Called once, immediately after `serde_norway` returns and before any field is moved out, so the
3428/// result covers the whole document rather than the parts validation happens to reach. Reporting
3429/// only; nothing here can refuse a manifest.
3430fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
3431    let mut out = Vec::new();
3432    collect_block(raw, "", &mut out);
3433    out
3434}
3435
3436/// One block's captured keys, each paired with the nearest key that block does recognize, then
3437/// the same for every block it owns.
3438///
3439/// `path` is empty for the manifest root, which is what
3440/// [`crate::unknown_manifest_keys::UnknownManifestKey`] renders as "at the top level".
3441fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
3442    for key in block.unknown_keys().keys() {
3443        out.push(UnknownManifestKey {
3444            key: key.clone(),
3445            block_path: path.to_string(),
3446            nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
3447        });
3448    }
3449    block.walk_children(path, out);
3450}
3451
3452/// The dotted path of `field` inside the block at `parent`, built in one place so no
3453/// [`RawBlock::walk_children`] has to reproduce the rule that the manifest root has no prefix.
3454/// An indexed child appends its own `[index]` to the result.
3455fn child_path(parent: &str, field: &str) -> String {
3456    if parent.is_empty() {
3457        return field.to_string();
3458    }
3459    format!("{parent}.{field}")
3460}
3461
3462/// Read a manifest file's text, mapping a missing file to
3463/// [`RuntimeManifestError::NotFound`] and any other read failure to
3464/// [`RuntimeManifestError::Io`].
3465///
3466/// For a caller that needs the manifest text as well as the parsed manifest — `mur doctor` reads
3467/// the references a manifest makes out of the same bytes it parses, so the file is opened once.
3468pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
3469    fs::read_to_string(path).map_err(|source| {
3470        if source.kind() == std::io::ErrorKind::NotFound {
3471            return RuntimeManifestError::NotFound(path.display().to_string());
3472        }
3473
3474        RuntimeManifestError::Io {
3475            path: path.display().to_string(),
3476            source,
3477        }
3478    })
3479}
3480
3481#[must_use = "validated runtime manifest is required before run"]
3482pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
3483    let content = read_runtime_manifest_text(path)?;
3484
3485    RuntimeManifest::from_yaml_str(&content)
3486}
3487
3488/// One `${VAR}` reference a manifest makes, and the field that makes it.
3489#[derive(Debug, Clone, PartialEq, Eq)]
3490pub struct ReferencedEnvVariable {
3491    /// The manifest field holding the reference, e.g. `artifacts.murmur-driver-anthropic.gateway.api_key`.
3492    pub field: String,
3493    /// The variable the reference names.
3494    pub variable: String,
3495}
3496
3497impl ReferencedEnvVariable {
3498    /// Whether the reference is a gateway's `api_key` — a credential the runtime also answers
3499    /// from the global config's `credentials:` map, not only from the environment.
3500    pub fn is_gateway_credential(&self) -> bool {
3501        self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
3502    }
3503}
3504
3505/// The narrow view of a manifest this scan needs: the fields whose value may be an `${ENV_REF}`,
3506/// and nothing else. Every other key is ignored, so a manifest this build would refuse for an
3507/// unrelated reason still yields its references.
3508///
3509/// Deliberately not one of the `Raw*` structs: those carry the unknown-key overflow map that
3510/// makes `warn_on_unknown_manifest_keys` complete, and this one is a second, partial reader of a
3511/// document that reader has already covered.
3512#[derive(Deserialize)]
3513struct ReferencedManifestFields {
3514    #[serde(default)]
3515    artifacts: Vec<ReferencedArtifactFields>,
3516}
3517
3518#[derive(Deserialize)]
3519struct ReferencedArtifactFields {
3520    #[serde(default)]
3521    name: Option<String>,
3522    #[serde(default)]
3523    gateway: Option<ReferencedGatewayFields>,
3524}
3525
3526#[derive(Deserialize)]
3527struct ReferencedGatewayFields {
3528    #[serde(default)]
3529    api_key: Option<String>,
3530}
3531
3532/// Every environment variable `manifest_yaml` references through an `${ENV_REF}`, sorted by
3533/// variable name and deduplicated.
3534///
3535/// Never fails and never reads the environment: unparseable YAML, an absent field and a literal
3536/// (non-reference) value all yield an empty list. What counts as a reference is decided by the
3537/// same `parse_env_reference` the resolving parse applies, so a name reported here is exactly a
3538/// name [`RuntimeManifest::from_yaml_str`] would have looked up.
3539pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
3540    let Ok(raw) = serde_norway::from_str::<ReferencedManifestFields>(manifest_yaml) else {
3541        return Vec::new();
3542    };
3543
3544    let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
3545    for artifact in raw.artifacts {
3546        let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
3547            continue;
3548        };
3549        if let Some(variable) = parse_env_reference(api_key.trim()) {
3550            let name = artifact.name.unwrap_or_default();
3551            referenced.push(ReferencedEnvVariable {
3552                field: format!("artifacts.{}.gateway.api_key", name.trim()),
3553                variable: variable.to_string(),
3554            });
3555        }
3556    }
3557
3558    referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
3559    referenced.dedup();
3560    referenced
3561}
3562
3563/// Whether a parse keeps what the manifest says about a secret it references.
3564///
3565/// The two [`RuntimeManifest`] constructors differ by this value and nothing else, so validation
3566/// cannot drift between a manifest `mur run` accepts and one a referee accepts. Neither reads the
3567/// process environment.
3568#[derive(Clone, Copy, PartialEq, Eq)]
3569enum SecretResolution {
3570    /// Every `gateway.api_key` is kept as an [`ApiKeyReference`], unresolved.
3571    Resolve,
3572    /// Every `gateway.api_key` is left unread. Each parsed [`ArtifactGateway::api_key`] is `None`
3573    /// whatever the manifest wrote.
3574    Skip,
3575}
3576
3577impl RuntimeManifest {
3578    pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
3579        Self::parse(input, SecretResolution::Resolve)
3580    }
3581
3582    /// Parse a manifest without resolving any secret it references.
3583    ///
3584    /// Validation is identical to [`RuntimeManifest::from_yaml_str`] on every block, `artifacts`
3585    /// included, with one difference: every resulting [`ArtifactGateway::api_key`] is `None`,
3586    /// whether the manifest wrote an `${ENV_REF}` or a literal key.
3587    ///
3588    /// For a caller that reads a foreign capsule's manifest for its capability policy alone —
3589    /// `mur-roost` refereeing a delegation — and is entitled to none of the credentials that
3590    /// manifest names.
3591    pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
3592        Self::parse(input, SecretResolution::Skip)
3593    }
3594
3595    fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
3596        let raw: RawRuntimeManifest = serde_norway::from_str(input).map_err(|err| {
3597            if let Some(location) = err.location() {
3598                RuntimeManifestError::YamlSyntax(format!(
3599                    "{}: YAML syntax error at line {}, column {}: {}",
3600                    MANIFEST_FILENAME,
3601                    location.line(),
3602                    location.column(),
3603                    err
3604                ))
3605            } else {
3606                RuntimeManifestError::YamlSyntax(format!(
3607                    "{MANIFEST_FILENAME}: YAML syntax error: {err}"
3608                ))
3609            }
3610        })?;
3611
3612        // The key is not a field of `RawRuntimeManifest`, so it lands in the unknown-key map.
3613        if raw.unknown.contains_key(REGISTRY_BLOCK_KEY) {
3614            return Err(RuntimeManifestError::RegistryBlock);
3615        }
3616
3617        // Collected before any field is moved out of `raw`, so the walk sees the whole document
3618        // rather than the part validation happens to reach before its first refusal.
3619        let unknown_keys = collect_unknown_keys(&raw);
3620
3621        if raw.mur_version.is_some() {
3622            return Err(RuntimeManifestError::TopLevelMurVersion);
3623        }
3624
3625        let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
3626            RuntimeManifestError::MissingField {
3627                field: "name".to_string(),
3628            }
3629        })?;
3630
3631        let version = raw
3632            .version
3633            .filter(|s| !s.trim().is_empty())
3634            .ok_or_else(|| RuntimeManifestError::MissingField {
3635                field: "version".to_string(),
3636            })?;
3637
3638        // Read off the raw entries because a parse that skips secrets keeps no `ApiKeyReference`,
3639        // and `control.secrets` is checked against what the manifest wrote either way.
3640        let gateway_references = raw_gateway_references(&raw.artifacts);
3641
3642        let artifacts = raw
3643            .artifacts
3644            .into_iter()
3645            .enumerate()
3646            .map(|(index, artifact)| {
3647                let name = artifact
3648                    .name
3649                    .filter(|s| !s.trim().is_empty())
3650                    .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3651                        index,
3652                        message: "missing required field 'name'".to_string(),
3653                    })?;
3654
3655                let source = artifact.source.filter(|s| !s.trim().is_empty());
3656                check_entry_name(index, &name, source.is_some())?;
3657
3658                let runtime = match artifact.runtime.as_deref() {
3659                    None | Some("tool") => ArtifactRuntime::Tool,
3660                    Some("wasm") | Some("native") => {
3661                        return Err(RuntimeManifestError::InvalidArtifact {
3662                            index,
3663                            message: format!(
3664                                "use 'runtime: tool'; implementation is declared in the \
3665                                 artifact's own manifest (got '{}')",
3666                                artifact.runtime.as_deref().unwrap_or("")
3667                            ),
3668                        });
3669                    }
3670                    Some("driver") => ArtifactRuntime::Driver,
3671                    Some("hook") => ArtifactRuntime::Hook,
3672                    Some("skill") => ArtifactRuntime::Skill,
3673                    Some(other) => {
3674                        return Err(RuntimeManifestError::InvalidArtifact {
3675                            index,
3676                            message: format!(
3677                                "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
3678                            ),
3679                        });
3680                    }
3681                };
3682
3683                // Capabilities are keyed on declared properties, not on the role. When the
3684                // property is absent we derive it from the role, which reproduces the previous
3685                // skill-only gate exactly and leaves every existing manifest unchanged.
3686                let local_source = artifact
3687                    .local_source
3688                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3689                let prompt_payload = artifact
3690                    .prompt_payload
3691                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3692
3693                // `source:` requires the artifact to declare `local_source: true`.
3694                if source.is_some() && !local_source {
3695                    return Err(RuntimeManifestError::InvalidArtifact {
3696                        index,
3697                        message: format!(
3698                            "artifact '{name}' declares 'source:' but does not declare \
3699                             'local_source: true' (runtime: {})",
3700                            runtime.as_str()
3701                        ),
3702                    });
3703                }
3704
3705                // When `source:` is present, `version:` is optional — substitute "local".
3706                // If both are set, `version:` is ignored and a warning is printed to stderr.
3707                let version = if source.is_some() {
3708                    if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
3709                        crate::warn_line(format_args!(
3710                            "warning: artifact '{name}' declares both 'source:' and \
3711                             'version: {explicit}'; version is ignored for local-source skills \
3712                             (using 'local')"
3713                        ));
3714                    }
3715                    "local".to_string()
3716                } else {
3717                    artifact
3718                        .version
3719                        .filter(|s| !s.trim().is_empty())
3720                        .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3721                            index,
3722                            message: "missing required field 'version'".to_string(),
3723                        })?
3724                };
3725
3726                // Per-artifact `capabilities:` is enforced on every role that actually
3727                // executes — `hook` (default-deny grant) and `tool`/`driver` (narrowing
3728                // below the capsule ceiling). A `skill` has no execution surface, so the
3729                // block would be silently ignored there; that reads like a scoped artifact
3730                // when it is not, hence a parse error rather than a no-op.
3731                let capabilities = match artifact.capabilities {
3732                    None => None,
3733                    Some(raw_caps) => {
3734                        // The install grant is held by the session, not by one artifact: a
3735                        // per-artifact block would reach no pull at all.
3736                        if raw_caps.install.is_some() {
3737                            return Err(RuntimeManifestError::InvalidArtifact {
3738                                index,
3739                                message: format!(
3740                                    "artifact '{name}' declares 'capabilities.install:'; the \
3741                                     grant is capsule-wide and is only recognized in the \
3742                                     top-level capabilities block"
3743                                ),
3744                            });
3745                        }
3746                        if runtime == ArtifactRuntime::Skill {
3747                            return Err(RuntimeManifestError::InvalidArtifact {
3748                                index,
3749                                message: format!(
3750                                    "artifact '{name}' declares per-artifact 'capabilities:' but \
3751                                     has 'runtime: {}'; per-artifact capabilities are only \
3752                                     recognized on 'runtime: hook', 'runtime: tool', and \
3753                                     'runtime: driver' entries — use the capsule-wide top-level \
3754                                     'capabilities:' block instead",
3755                                    runtime.as_str()
3756                                ),
3757                            });
3758                        }
3759                        // `task_io` grants the `murmur:task-io/read` host import, which only
3760                        // hook components can be handed. On a tool or driver entry nothing
3761                        // would enforce it, and a silently-inert grant reads like a scoped
3762                        // artifact — the same reason `on_overflow:` below is a parse error off
3763                        // a hook.
3764                        if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
3765                            return Err(RuntimeManifestError::InvalidArtifact {
3766                                index,
3767                                message: format!(
3768                                    "artifact '{name}' declares 'capabilities.task_io:' but has \
3769                                     'runtime: {}'; the key grants the murmur:task-io/read host \
3770                                     import and is only recognized on 'runtime: hook' entries",
3771                                    runtime.as_str()
3772                                ),
3773                            });
3774                        }
3775                        // `filesystem.records` replaces a hook's workdir scope with the records
3776                        // directory's `hooks/`. Only a hook is preopened that way, and a hook has
3777                        // one `"."`, so the two declarations cannot both hold.
3778                        if let Some(filesystem) = raw_caps
3779                            .filesystem
3780                            .as_ref()
3781                            .filter(|filesystem| filesystem.records)
3782                        {
3783                            if runtime != ArtifactRuntime::Hook {
3784                                return Err(RuntimeManifestError::InvalidArtifact {
3785                                    index,
3786                                    message: format!(
3787                                        "artifact '{name}' declares \
3788                                         'capabilities.filesystem.records: true' but has \
3789                                         'runtime: {}'; the key gives a hook the records \
3790                                         directory's hooks/ and is only recognized on \
3791                                         'runtime: hook' entries",
3792                                        runtime.as_str()
3793                                    ),
3794                                });
3795                            }
3796                            if filesystem
3797                                .scope
3798                                .as_deref()
3799                                .is_some_and(|scope| !scope.trim().is_empty())
3800                            {
3801                                return Err(RuntimeManifestError::InvalidArtifact {
3802                                    index,
3803                                    message: format!(
3804                                        "artifact '{name}' declares both \
3805                                         'capabilities.filesystem.records: true' and \
3806                                         'capabilities.filesystem.scope'; a hook's \".\" is \
3807                                         either the records directory's hooks/ or a workdir \
3808                                         scope — declare one"
3809                                    ),
3810                                });
3811                            }
3812                        }
3813                        // `conversation` grants the `murmur:conversation/read` host import, on the
3814                        // same terms as `task_io` above: only a hook component's world can import
3815                        // it, so on any other role the grant would be silently inert.
3816                        if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
3817                            return Err(RuntimeManifestError::InvalidArtifact {
3818                                index,
3819                                message: format!(
3820                                    "artifact '{name}' declares 'capabilities.conversation:' but \
3821                                     has 'runtime: {}'; the key grants the \
3822                                     murmur:conversation/read host import and is only recognized \
3823                                     on 'runtime: hook' entries",
3824                                    runtime.as_str()
3825                                ),
3826                            });
3827                        }
3828                        parse_capabilities(Some(raw_caps))?
3829                    }
3830                };
3831
3832                // `on_overflow:` governs an async hook's job queue, which only exists for
3833                // `runtime: hook`. On any other role it would be silently ignored, so it is
3834                // a parse error for the same reason per-artifact `capabilities:` is on a
3835                // skill. It stays legal on a *blocking* hook entry (simply inert): the
3836                // execution mode lives in the artifact's own manifest and is unknown here.
3837                let on_overflow = match artifact.on_overflow.as_deref() {
3838                    None => HookOverflowPolicy::default(),
3839                    Some(raw) => {
3840                        if runtime != ArtifactRuntime::Hook {
3841                            return Err(RuntimeManifestError::InvalidArtifact {
3842                                index,
3843                                message: format!(
3844                                    "artifact '{name}' declares 'on_overflow:' but has \
3845                                     'runtime: {}'; the key governs an async hook's job queue \
3846                                     and is only recognized on 'runtime: hook' entries",
3847                                    runtime.as_str()
3848                                ),
3849                            });
3850                        }
3851                        match raw {
3852                            "drop" => HookOverflowPolicy::Drop,
3853                            "block" => HookOverflowPolicy::Block,
3854                            other => {
3855                                return Err(RuntimeManifestError::InvalidArtifact {
3856                                    index,
3857                                    message: format!(
3858                                        "artifact '{name}' declares unknown on_overflow \
3859                                         '{other}'; expected: drop, block"
3860                                    ),
3861                                });
3862                            }
3863                        }
3864                    }
3865                };
3866
3867                // `config:` reaches an artifact through that artifact's own grant, and a skill
3868                // holds no grant and runs no component — nothing would ever deliver it. Refused
3869                // rather than ignored, for the reason the two gates above are: a key that reads
3870                // like configuration and is silently dropped is worse than one that is refused.
3871                // The block's *shape* is the runtime's business, not this parser's; only the
3872                // role is decided here.
3873                if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
3874                    return Err(RuntimeManifestError::InvalidArtifact {
3875                        index,
3876                        message: format!(
3877                            "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
3878                             is recognized only on 'runtime: hook', 'runtime: tool', and \
3879                             'runtime: driver' entries",
3880                            runtime.as_str()
3881                        ),
3882                    });
3883                }
3884
3885                let gateway = match artifact.gateway {
3886                    None => None,
3887                    Some(raw_gateway) => {
3888                        if runtime == ArtifactRuntime::Skill {
3889                            return Err(RuntimeManifestError::InvalidArtifact {
3890                                index,
3891                                message: format!(
3892                                    "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
3893                                     the key is recognized only on 'runtime: hook', \
3894                                     'runtime: tool', and 'runtime: driver' entries",
3895                                    runtime.as_str()
3896                                ),
3897                            });
3898                        }
3899                        Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
3900                    }
3901                };
3902
3903                Ok(RuntimeArtifact {
3904                    name,
3905                    version,
3906                    runtime,
3907                    source,
3908                    local_source,
3909                    prompt_payload,
3910                    capabilities,
3911                    on_overflow,
3912                    config: artifact.config,
3913                    gateway,
3914                })
3915            })
3916            .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
3917
3918        // The capsule-wide block reaches capsule, tool and driver components, none of which
3919        // can be handed the murmur:task-io/read import. Rejected rather than ignored, on the
3920        // same terms as declaring it on a non-hook artifact entry.
3921        if raw
3922            .capabilities
3923            .as_ref()
3924            .is_some_and(|caps| caps.task_io.is_some())
3925        {
3926            return Err(RuntimeManifestError::InvalidCapabilities {
3927                field: "capabilities.task_io".to_string(),
3928                message: "is recognized only on 'runtime: hook' artifact entries, not in the \
3929                          capsule-wide capabilities block — the grant is per-hook"
3930                    .to_string(),
3931            });
3932        }
3933        // `filesystem.records` names a hook's preopen, and the capsule-wide block preopens nothing
3934        // for any hook. Refused rather than ignored, on the `task_io` block's terms.
3935        if raw
3936            .capabilities
3937            .as_ref()
3938            .and_then(|caps| caps.filesystem.as_ref())
3939            .is_some_and(|filesystem| filesystem.records)
3940        {
3941            return Err(RuntimeManifestError::InvalidCapabilities {
3942                field: "capabilities.filesystem.records".to_string(),
3943                message: "is recognized only on 'runtime: hook' artifact entries, not in the \
3944                          capsule-wide capabilities block — the grant is per-hook"
3945                    .to_string(),
3946            });
3947        }
3948        // Config is delivered on one artifact's own grant, so there is no capsule-wide form of
3949        // it: the capsule's guest holds no artifact grant, and a top-level block would reach no
3950        // component at all. Refused rather than ignored, on the same terms as the capsule-wide
3951        // `task_io` block above.
3952        if raw.config.is_some() {
3953            return Err(RuntimeManifestError::InvalidCapabilities {
3954                field: "config".to_string(),
3955                message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
3956                          'runtime: driver' entry that reads it — there is no capsule-wide \
3957                          config block"
3958                    .to_string(),
3959            });
3960        }
3961        let capabilities = parse_capabilities(raw.capabilities)?;
3962        let inference = parse_inference(raw.inference)?;
3963        refuse_install_under_process_transport(capabilities.as_ref(), inference.as_ref())?;
3964        validate_driver_gateways(&artifacts, inference.as_ref())?;
3965        validate_driver_choices(&artifacts, inference.as_ref())?;
3966        validate_process_driver_entry(&artifacts, inference.as_ref())?;
3967
3968        // Validate system_prompt_artifact: must name a declared artifact whose payload may be
3969        // bound as the system prompt (`prompt_payload`, defaulted from the role when absent).
3970        if let Some(ref sp_art) = inference
3971            .as_ref()
3972            .and_then(|i| i.system_prompt_artifact.clone())
3973        {
3974            let matching = artifacts.iter().find(|a| &a.name == sp_art);
3975            match matching {
3976                None => {
3977                    return Err(RuntimeManifestError::InvalidInferenceConfig {
3978                        field: "inference.system_prompt_artifact".to_string(),
3979                        message: not_declared_message(&artifacts, sp_art),
3980                    });
3981                }
3982                Some(a) if !a.prompt_payload => {
3983                    return Err(RuntimeManifestError::InvalidInferenceConfig {
3984                        field: "inference.system_prompt_artifact".to_string(),
3985                        message: format!(
3986                            "artifact '{sp_art}' (runtime: {}) does not declare \
3987                             'prompt_payload: true', which system_prompt_artifact requires",
3988                            a.runtime.as_str()
3989                        ),
3990                    });
3991                }
3992                Some(_) => {} // valid
3993            }
3994        }
3995        let context = parse_context(raw.context)?;
3996        let observability = parse_observability(raw.observability);
3997        let trace = raw
3998            .trace
3999            .map(|raw_trace| {
4000                let capture = resolve_trace_capture(raw_trace.capture.as_deref())?;
4001                let retain = parse_trace_retain(raw_trace.retain)?;
4002                Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
4003            })
4004            .transpose()?;
4005        let network = raw
4006            .network
4007            .map(|n| {
4008                Ok::<_, RuntimeManifestError>(NetworkConfig {
4009                    internal_port: n.internal_port,
4010                    authentication: parse_network_authentication(n.authentication)?,
4011                })
4012            })
4013            .transpose()?;
4014        refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
4015        let lifecycle = raw
4016            .lifecycle
4017            .map(|raw_lc| {
4018                let defaults = LifecycleConfig::default();
4019                let lifecycle = LifecycleConfig {
4020                    task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
4021                    after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
4022                    queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
4023                    input_timeout_secs: raw_lc.input_timeout_secs,
4024                    conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
4025                    max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
4026                    shell_grace_secs: raw_lc.shell_grace_secs.unwrap_or(defaults.shell_grace_secs),
4027                    delegation_deadline_secs: raw_lc
4028                        .delegation_deadline_secs
4029                        .unwrap_or(defaults.delegation_deadline_secs),
4030                    deadline_notice_secs: raw_lc.deadline_notice_secs,
4031                    keep_working: raw_lc.keep_working.map(parse_keep_working).transpose()?,
4032                    done_check: raw_lc
4033                        .done_check
4034                        .map(|raw| {
4035                            let shell_allow = capabilities
4036                                .as_ref()
4037                                .and_then(|capabilities| capabilities.shell.as_ref())
4038                                .map_or(&[][..], |shell| shell.allow.as_slice());
4039                            parse_done_check(raw, shell_allow)
4040                        })
4041                        .transpose()?,
4042                };
4043                if lifecycle.queue_has_room() {
4044                    Ok(lifecycle)
4045                } else {
4046                    Err(RuntimeManifestError::QueueWithoutRoom)
4047                }
4048            })
4049            .transpose()?;
4050
4051        let exports = parse_exports(raw.exports)?;
4052        let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
4053        validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
4054
4055        Ok(Self {
4056            name,
4057            version,
4058            artifacts,
4059            capabilities,
4060            inference,
4061            context,
4062            observability,
4063            trace,
4064            network,
4065            lifecycle,
4066            exports,
4067            control,
4068            deploy: raw.deploy.map(|deploy| DeployConfig {
4069                mur_version: deploy.mur_version.filter(|s| !s.trim().is_empty()),
4070            }),
4071            unknown_keys,
4072        })
4073    }
4074}
4075
4076/// Every artifact entry whose `gateway.api_key` is written as `${NAME}`, as `(artifact, NAME)`.
4077fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
4078    artifacts
4079        .iter()
4080        .filter_map(|artifact| {
4081            let name = artifact.name.as_deref()?.trim();
4082            let written = artifact
4083                .gateway
4084                .as_ref()?
4085                .as_ref()?
4086                .api_key
4087                .as_ref()?
4088                .as_ref()?;
4089            let variable = parse_env_reference(written.trim())?;
4090            Some((name.to_string(), variable.to_string()))
4091        })
4092        .collect()
4093}
4094
4095/// Lowers the top-level `control:` block. A block that declares nothing is no block.
4096///
4097/// `gateway_references` is every `(artifact, NAME)` pair an entry's `gateway.api_key: ${NAME}`
4098/// wrote. A secret must back one of them, and must not back the configured `transport: http`
4099/// driver's: that credential is resolved at staging and keys every turn, so a session launched
4100/// without it could make no inference call at all.
4101fn parse_control(
4102    raw: Option<RawControl>,
4103    gateway_references: &[(String, String)],
4104    inference: Option<&InferenceConfig>,
4105) -> Result<Option<ControlConfig>, RuntimeManifestError> {
4106    let Some(raw) = raw else {
4107        return Ok(None);
4108    };
4109    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
4110        field: field.to_string(),
4111        message,
4112    };
4113    let settings_written = control_string_list("control.settings", raw.settings)?;
4114    let secrets_written = control_string_list("control.secrets", raw.secrets)?;
4115    let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
4116    if settings_written.is_empty()
4117        && secrets_written.is_empty()
4118        && agent_settings_written.is_empty()
4119    {
4120        return Ok(None);
4121    }
4122    let Some(inference) = inference else {
4123        return Err(refuse(
4124            "control",
4125            "is declared on a capsule with no inference: block; the control surface is served on \
4126             an agent capsule's A2A listener, and a script capsule opens none"
4127                .to_string(),
4128        ));
4129    };
4130
4131    let mut settings = Vec::new();
4132    for entry in settings_written {
4133        let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
4134            let controllable: Vec<&str> = ControllableSetting::ALL
4135                .iter()
4136                .map(|setting| setting.wire_name())
4137                .collect();
4138            return Err(refuse(
4139                "control.settings",
4140                format!(
4141                    "'{entry}' is not a controllable setting; the controllable settings are: {}",
4142                    controllable.join(", ")
4143                ),
4144            ));
4145        };
4146        if settings.contains(&setting) {
4147            return Err(refuse(
4148                "control.settings",
4149                format!("'{entry}' is listed more than once"),
4150            ));
4151        }
4152        match setting {
4153            ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
4154                return Err(refuse(
4155                    "control.settings",
4156                    format!(
4157                        "'{entry}' is controllable only under inference.transport: http; a \
4158                         transport: {} harness sets its own output cap",
4159                        inference.transport
4160                    ),
4161                ));
4162            }
4163            ControllableSetting::InferenceMaxTokens => {}
4164            ControllableSetting::InferenceDriver => {
4165                check_driver_setting(inference).map_err(|message| {
4166                    refuse("control.settings", format!("'{entry}' {message}"))
4167                })?;
4168            }
4169        }
4170        settings.push(setting);
4171    }
4172
4173    let mut agent_settings = Vec::new();
4174    for entry in agent_settings_written {
4175        let Some(setting) = ControllableSetting::from_wire_name(&entry)
4176            .filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
4177        else {
4178            let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
4179                .iter()
4180                .map(|setting| setting.wire_name())
4181                .collect();
4182            return Err(refuse(
4183                "control.agent_settings",
4184                format!(
4185                    "'{entry}' is not a setting the agent may change; control.agent_settings \
4186                     accepts only: {}",
4187                    settable.join(", ")
4188                ),
4189            ));
4190        };
4191        if agent_settings.contains(&setting) {
4192            return Err(refuse(
4193                "control.agent_settings",
4194                format!("'{entry}' is listed more than once"),
4195            ));
4196        }
4197        check_driver_setting(inference)
4198            .map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
4199        agent_settings.push(setting);
4200    }
4201
4202    let inference_driver = (inference.transport == "http")
4203        .then(|| {
4204            inference
4205                .driver
4206                .as_ref()
4207                .map(|driver| driver.artifact.as_str())
4208        })
4209        .flatten();
4210    let mut secrets: Vec<String> = Vec::new();
4211    for entry in secrets_written {
4212        if !is_valid_env_variable(&entry) {
4213            return Err(refuse(
4214                "control.secrets",
4215                format!(
4216                    "'{entry}' is not a credential name; a name is uppercase letters, digits and \
4217                     underscores, and does not start with a digit"
4218                ),
4219            ));
4220        }
4221        if secrets.contains(&entry) {
4222            return Err(refuse(
4223                "control.secrets",
4224                format!("'{entry}' is listed more than once"),
4225            ));
4226        }
4227        let backing: Vec<&str> = gateway_references
4228            .iter()
4229            .filter(|(_, variable)| *variable == entry)
4230            .map(|(artifact, _)| artifact.as_str())
4231            .collect();
4232        if backing.is_empty() {
4233            return Err(refuse(
4234                "control.secrets",
4235                format!(
4236                    "'{entry}' is not referenced by any artifact's gateway.api_key as \
4237                     ${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
4238                ),
4239            ));
4240        }
4241        if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
4242            return Err(refuse(
4243                "control.secrets",
4244                format!(
4245                    "'{entry}' keys the configured inference driver's gateway \
4246                     (artifacts.{driver}.gateway.api_key); the inference credential is resolved \
4247                     at launch and cannot be supplied by a controller"
4248                ),
4249            ));
4250        }
4251        secrets.push(entry);
4252    }
4253
4254    Ok(Some(ControlConfig {
4255        settings,
4256        secrets,
4257        agent_settings,
4258    }))
4259}
4260
4261/// Why `inference.driver` cannot be listed for `inference`, or `Ok` when it can: only under
4262/// `transport: http`, and only with at least one alternate to select.
4263fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
4264    if inference.transport != "http" {
4265        return Err(format!(
4266            "is controllable only under inference.transport: http; a transport: {} harness owns \
4267             the model it runs",
4268            inference.transport
4269        ));
4270    }
4271    if inference.alternates.is_empty() {
4272        return Err(
4273            "selects among inference.alternates, and this capsule declares none; declare an \
4274             alternate to switch to"
4275                .to_string(),
4276        );
4277    }
4278    Ok(())
4279}
4280
4281/// `value` as a list of strings, trimmed, for `field`. Absent and YAML null are the empty list.
4282fn control_string_list(
4283    field: &str,
4284    value: Option<serde_norway::Value>,
4285) -> Result<Vec<String>, RuntimeManifestError> {
4286    let refuse = || RuntimeManifestError::InvalidControl {
4287        field: field.to_string(),
4288        message: "must be a list of names".to_string(),
4289    };
4290    let items = match value {
4291        None | Some(serde_norway::Value::Null) => return Ok(Vec::new()),
4292        Some(serde_norway::Value::Sequence(items)) => items,
4293        Some(_) => return Err(refuse()),
4294    };
4295    items
4296        .into_iter()
4297        .map(|item| match item {
4298            serde_norway::Value::String(name) => Ok(name.trim().to_string()),
4299            _ => Err(refuse()),
4300        })
4301        .collect()
4302}
4303
4304/// Lowers `network.authentication`. An absent or null block is a public door.
4305fn parse_network_authentication(
4306    raw: Option<RawNetworkAuthentication>,
4307) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
4308    let Some(raw) = raw else {
4309        return Ok(None);
4310    };
4311    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
4312        field: field.to_string(),
4313        message,
4314    };
4315    let schemes: Vec<&str> = AuthenticationScheme::ALL
4316        .iter()
4317        .map(|scheme| scheme.wire_name())
4318        .collect();
4319    let scheme = match raw.scheme.as_deref().map(str::trim) {
4320        None | Some("") => {
4321            return Err(refuse(
4322                "network.authentication.scheme",
4323                format!("is required; the schemes are: {}", schemes.join(", ")),
4324            ))
4325        }
4326        Some(written) => AuthenticationScheme::ALL
4327            .iter()
4328            .copied()
4329            .find(|scheme| scheme.wire_name() == written)
4330            .ok_or_else(|| {
4331                refuse(
4332                    "network.authentication.scheme",
4333                    format!(
4334                        "'{written}' is not a door authentication scheme; the schemes are: {}",
4335                        schemes.join(", ")
4336                    ),
4337                )
4338            })?,
4339    };
4340
4341    let mut credentials = Vec::new();
4342    for (name, credential) in raw.credentials.unwrap_or_default() {
4343        if name == OPERATOR_CREDENTIAL {
4344            return Err(refuse(
4345                "network.authentication.credentials",
4346                format!(
4347                    "'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
4348                     token, which holds every scope"
4349                ),
4350            ));
4351        }
4352        if !is_valid_credential_name(&name) {
4353            return Err(refuse(
4354                "network.authentication.credentials",
4355                format!(
4356                    "'{name}' is not a credential name; a name is a lowercase letter followed by \
4357                     at most {} lowercase letters, digits, '_' or '-'",
4358                    MAX_CREDENTIAL_NAME_LEN - 1
4359                ),
4360            ));
4361        }
4362        let field = format!("network.authentication.credentials.{name}.scopes");
4363        let scopes = door_scope_list(&field, credential.scopes)?;
4364        credentials.push(DoorCredential { name, scopes });
4365    }
4366
4367    Ok(Some(NetworkAuthentication {
4368        scheme,
4369        credentials,
4370    }))
4371}
4372
4373/// `^[a-z][a-z0-9_-]{0,31}$`.
4374fn is_valid_credential_name(name: &str) -> bool {
4375    let mut chars = name.chars();
4376    chars.next().is_some_and(|first| first.is_ascii_lowercase())
4377        && name.len() <= MAX_CREDENTIAL_NAME_LEN
4378        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
4379}
4380
4381/// One credential's `scopes`: required, non-empty, each in [`DOOR_SCOPES`] and listed once.
4382fn door_scope_list(
4383    field: &str,
4384    value: Option<serde_norway::Value>,
4385) -> Result<Vec<String>, RuntimeManifestError> {
4386    let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
4387        field: field.to_string(),
4388        message,
4389    };
4390    let items = match value {
4391        None | Some(serde_norway::Value::Null) => {
4392            return Err(refuse(
4393                "is required: a credential reaches only the scopes it lists".to_string(),
4394            ))
4395        }
4396        Some(serde_norway::Value::Sequence(items)) => items,
4397        Some(_) => return Err(refuse("must be a list of scopes".to_string())),
4398    };
4399    if items.is_empty() {
4400        return Err(refuse(
4401            "lists no scope; a credential must reach at least one".to_string(),
4402        ));
4403    }
4404    let mut scopes: Vec<String> = Vec::new();
4405    for item in items {
4406        let serde_norway::Value::String(scope) = item else {
4407            return Err(refuse("must be a list of scopes".to_string()));
4408        };
4409        let scope = scope.trim().to_string();
4410        if let Some(&(_, replacement)) = RETIRED_DOOR_SCOPES
4411            .iter()
4412            .find(|(retired, _)| *retired == scope)
4413        {
4414            return Err(RuntimeManifestError::RetiredDoorScope {
4415                field: field.to_string(),
4416                scope,
4417                replacement,
4418            });
4419        }
4420        if !DOOR_SCOPES.contains(&scope.as_str()) {
4421            return Err(refuse(format!(
4422                "'{scope}' is not a door scope; the scopes are: {}",
4423                DOOR_SCOPES.join(", ")
4424            )));
4425        }
4426        if scopes.contains(&scope) {
4427            return Err(refuse(format!("'{scope}' is listed more than once")));
4428        }
4429        scopes.push(scope);
4430    }
4431    Ok(scopes)
4432}
4433
4434/// Refuses `network.authentication` on a capsule that declares `capabilities.spawn.allow`.
4435///
4436/// A delegated child posts its completion to its parent's door, and nothing issues a child a token
4437/// for that door, so an authenticated parent would refuse every completion it asked for.
4438fn refuse_authenticated_delegating_parent(
4439    network: Option<&NetworkConfig>,
4440    capabilities: Option<&Capabilities>,
4441) -> Result<(), RuntimeManifestError> {
4442    let authenticated = network.is_some_and(|network| network.authentication.is_some());
4443    let delegates = capabilities
4444        .and_then(|capabilities| capabilities.spawn.as_ref())
4445        .is_some_and(|spawn| !spawn.allow.is_empty());
4446    if authenticated && delegates {
4447        return Err(RuntimeManifestError::InvalidNetworkConfig {
4448            field: "network.authentication".to_string(),
4449            message: "cannot be declared together with capabilities.spawn.allow: a delegated \
4450                      child posts its outcome to this door, and a delegated child cannot yet be \
4451                      issued a token for this door"
4452                .to_string(),
4453        });
4454    }
4455    Ok(())
4456}
4457
4458/// Lowers the top-level `exports:` block, rejecting anything the runtime would otherwise have to
4459/// decide about later.
4460///
4461/// The `root` rules are checked here and never again by a caller: a gateway that "cleans up" a
4462/// path is a gateway that has taken over the containment boundary, so the only legal answer to a
4463/// root naming somewhere outside the workdir is a refusal.
4464fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
4465    let Some(raw_exports) = raw else {
4466        return Ok(None);
4467    };
4468    let files = raw_exports.files.map(parse_file_export).transpose()?;
4469    let peer_files = raw_exports
4470        .peer_files
4471        .map(parse_peer_files_export)
4472        .transpose()?;
4473    let peer_tasks = raw_exports
4474        .peer_tasks
4475        .map(|raw| {
4476            raw.accept
4477                .map(|accept| PeerTasksExport { accept })
4478                .ok_or_else(|| RuntimeManifestError::InvalidExports {
4479                    field: "exports.peer_tasks.accept".to_string(),
4480                    message: "must be set explicitly to true or false — consent to peer tasks is \
4481                              never inferred"
4482                        .to_string(),
4483                })
4484        })
4485        .transpose()?;
4486    Ok(Some(Exports {
4487        files,
4488        peer_files,
4489        peer_tasks,
4490    }))
4491}
4492
4493/// Lowers `exports.peer_files`, on exactly the same terms as [`parse_file_export`]: a root that
4494/// could name somewhere outside the workdir is refused here and never repaired later.
4495///
4496/// `max_ttl` is left as the operator declared it, including absent. Whether absent is legal
4497/// depends on `lifecycle.after_task`, which is a different block of the same manifest and a
4498/// different kind of question — one about what survives teardown rather than about whether a
4499/// value is well-formed. The runtime asks it, at launch, and refuses with `E-CAP-008`.
4500fn parse_peer_files_export(
4501    raw: RawPeerFilesExport,
4502) -> Result<PeerFilesExport, RuntimeManifestError> {
4503    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4504        field: field.to_string(),
4505        message,
4506    };
4507
4508    let root = raw
4509        .root
4510        .map(|root| root.trim().to_string())
4511        .filter(|root| !root.is_empty())
4512        .ok_or_else(|| {
4513            invalid(
4514                "exports.peer_files.root",
4515                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4516            )
4517        })?;
4518    check_export_root_shape("exports.peer_files.root", &root)?;
4519
4520    let max_ttl_secs = match raw.max_ttl {
4521        None => None,
4522        Some(value) => {
4523            let text = scalar_text(&value).ok_or_else(|| {
4524                invalid(
4525                    "exports.peer_files.max_ttl",
4526                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
4527                )
4528            })?;
4529            let parsed = parse_duration_secs(&text)
4530                .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
4531            if parsed == 0 {
4532                return Err(invalid(
4533                    "exports.peer_files.max_ttl",
4534                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
4535                ));
4536            }
4537            Some(parsed)
4538        }
4539    };
4540
4541    let max_bytes = match raw.max_bytes {
4542        None => DEFAULT_PEER_FILES_MAX_BYTES,
4543        Some(value) => {
4544            let text = scalar_text(&value).ok_or_else(|| {
4545                invalid(
4546                    "exports.peer_files.max_bytes",
4547                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4548                )
4549            })?;
4550            let parsed = parse_byte_size(&text)
4551                .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
4552            if parsed == 0 {
4553                return Err(invalid(
4554                    "exports.peer_files.max_bytes",
4555                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4556                ));
4557            }
4558            parsed
4559        }
4560    };
4561
4562    Ok(PeerFilesExport {
4563        root,
4564        max_ttl_secs,
4565        max_bytes,
4566    })
4567}
4568
4569/// A YAML scalar as the text the byte-size and duration parsers expect, or `None` for a mapping
4570/// or a sequence — a shape neither parser could report on without quoting the whole node.
4571/// `Some` for a key that is present, whatever its value — including an explicit null.
4572///
4573/// The plain `Option<serde_norway::Value>` a `#[serde(default)]` field gets collapses `retain:`
4574/// with nothing under it into the same `None` an absent key produces, and those two say opposite
4575/// things: absent means "keep everything", and empty is refused.
4576fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_norway::Value>, D::Error>
4577where
4578    D: serde::Deserializer<'de>,
4579{
4580    serde_norway::Value::deserialize(deserializer).map(Some)
4581}
4582
4583fn scalar_text(value: &serde_norway::Value) -> Option<String> {
4584    match value {
4585        serde_norway::Value::String(text) => Some(text.clone()),
4586        serde_norway::Value::Number(number) => Some(number.to_string()),
4587        _ => None,
4588    }
4589}
4590
4591/// The shared `root` shape rule: relative, no `..`, no absolute prefix. Both export blocks
4592/// resolve their root against the same accessible workdir, so both refuse the same shapes with
4593/// the same sentence.
4594fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
4595    for component in Path::new(root).components() {
4596        match component {
4597            std::path::Component::Normal(_) | std::path::Component::CurDir => {}
4598            std::path::Component::ParentDir => {
4599                return Err(RuntimeManifestError::InvalidExports {
4600                    field: field.to_string(),
4601                    message: format!(
4602                        "'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}"
4603                    ),
4604                });
4605            }
4606            std::path::Component::RootDir | std::path::Component::Prefix(_) => {
4607                return Err(RuntimeManifestError::InvalidExports {
4608                    field: field.to_string(),
4609                    message: format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}"),
4610                });
4611            }
4612        }
4613    }
4614    Ok(())
4615}
4616
4617fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
4618    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4619        field: field.to_string(),
4620        message,
4621    };
4622
4623    let root = raw
4624        .root
4625        .map(|root| root.trim().to_string())
4626        .filter(|root| !root.is_empty())
4627        .ok_or_else(|| {
4628            invalid(
4629                "exports.files.root",
4630                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4631            )
4632        })?;
4633    check_export_root_shape("exports.files.root", &root)?;
4634
4635    let mode = match raw.mode.as_deref().map(str::trim) {
4636        None | Some("") => {
4637            return Err(invalid(
4638                "exports.files.mode",
4639                "is required and must be 'read-only'".to_string(),
4640            ));
4641        }
4642        Some("read-only") => ExportMode::ReadOnly,
4643        Some(other) => {
4644            return Err(invalid(
4645                "exports.files.mode",
4646                format!("'{other}' must be 'read-only'"),
4647            ));
4648        }
4649    };
4650
4651    let max_bytes = match raw.max_bytes {
4652        None => DEFAULT_EXPORT_MAX_BYTES,
4653        Some(value) => {
4654            let Some(text) = scalar_text(&value) else {
4655                return Err(invalid(
4656                    "exports.files.max_bytes",
4657                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4658                ));
4659            };
4660            let parsed = parse_byte_size(&text)
4661                .map_err(|message| invalid("exports.files.max_bytes", message))?;
4662            if parsed == 0 {
4663                return Err(invalid(
4664                    "exports.files.max_bytes",
4665                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4666                ));
4667            }
4668            parsed
4669        }
4670    };
4671
4672    Ok(FileExport {
4673        root,
4674        mode,
4675        max_bytes,
4676    })
4677}
4678
4679/// The accepted spelling of `exports.files.root`, stated once so every rejection says the same
4680/// thing about what a legal root looks like.
4681const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
4682
4683/// The accepted spelling of `capabilities.peer_fetch.allow`, stated once.
4684pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
4685
4686/// Structural check on one network destination: an `http`/`https` URL with no path, query or
4687/// fragment, or a bare `host[:port]`.
4688///
4689/// Deliberately shape-only. The authoritative matcher is the runtime's network-rule parser, which
4690/// this crate cannot see; what this catches is the class of entry that could never become a rule
4691/// at all, at the line that wrote it rather than at launch.
4692fn check_network_destination_shape(entry: &str) -> Result<(), String> {
4693    let trimmed = entry.trim();
4694    if trimmed.is_empty() {
4695        return Err(format!(
4696            "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4697        ));
4698    }
4699
4700    if trimmed.contains("://") {
4701        let url = Url::parse(trimmed).map_err(|error| {
4702            format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
4703        })?;
4704        let scheme = url.scheme().to_ascii_lowercase();
4705        if scheme != "http" && scheme != "https" {
4706            return Err(format!(
4707                "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
4708                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4709            ));
4710        }
4711        if url.host_str().is_none_or(str::is_empty) {
4712            return Err(format!(
4713                "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4714            ));
4715        }
4716        if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
4717            return Err(format!(
4718                "'{entry}' must not include a path, query or fragment; \
4719                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4720            ));
4721        }
4722        return Ok(());
4723    }
4724
4725    if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
4726        return Err(format!(
4727            "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4728        ));
4729    }
4730    // Split from the right so an IPv6 literal's own colons are not mistaken for a port separator.
4731    if let Some((host, port)) = trimmed.rsplit_once(':') {
4732        if !host.ends_with(']') {
4733            if host.is_empty() {
4734                return Err(format!(
4735                    "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4736                ));
4737            }
4738            if port.parse::<u16>().is_err() {
4739                return Err(format!(
4740                    "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4741                ));
4742            }
4743        }
4744    }
4745    Ok(())
4746}
4747
4748fn parse_capabilities(
4749    raw: Option<RawCapabilities>,
4750) -> Result<Option<Capabilities>, RuntimeManifestError> {
4751    let Some(raw_caps) = raw else {
4752        return Ok(None);
4753    };
4754
4755    let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
4756        allow: raw_network.allow,
4757        unix_sockets: raw_network.unix_sockets,
4758    });
4759
4760    let peer_fetch = raw_caps
4761        .peer_fetch
4762        .map(|raw_peer_fetch| {
4763            if raw_peer_fetch.allow.is_empty() {
4764                return Err(RuntimeManifestError::InvalidCapabilities {
4765                    field: "capabilities.peer_fetch.allow".to_string(),
4766                    message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
4767                });
4768            }
4769            for entry in &raw_peer_fetch.allow {
4770                check_network_destination_shape(entry).map_err(|message| {
4771                    RuntimeManifestError::InvalidCapabilities {
4772                        field: "capabilities.peer_fetch.allow".to_string(),
4773                        message,
4774                    }
4775                })?;
4776            }
4777            Ok(PeerFetchCapabilities {
4778                allow: raw_peer_fetch.allow,
4779            })
4780        })
4781        .transpose()?;
4782
4783    let filesystem = raw_caps
4784        .filesystem
4785        .map(|raw_filesystem| FilesystemCapabilities {
4786            scope: raw_filesystem.scope.and_then(|scope| {
4787                let trimmed = scope.trim();
4788                (!trimmed.is_empty()).then(|| trimmed.to_string())
4789            }),
4790            // Copied straight across: a bool has no empty/whitespace case to normalise, and there
4791            // is nothing to validate here — the *consequence* of declaring it (no `scoped`, and a
4792            // refusal when `capabilities.containment: scoped` is also declared) belongs to the
4793            // runtime's containment layer, not to manifest parsing.
4794            workdir_exec: raw_filesystem.workdir_exec,
4795            // Normalised on exactly the terms `scope` above is: trimmed, and an entry left empty
4796            // by that trim is dropped rather than carried as a rule covering nothing. Shape is
4797            // not judged here either — an absolute or escaping entry is the runtime's refusal.
4798            read_only: raw_filesystem
4799                .read_only
4800                .into_iter()
4801                .filter_map(|entry| {
4802                    let trimmed = entry.trim();
4803                    (!trimmed.is_empty()).then(|| trimmed.to_string())
4804                })
4805                .collect(),
4806            // Where it is recognized is decided by the caller, which knows the entry's role.
4807            records: raw_filesystem.records,
4808        });
4809
4810    let shell = raw_caps
4811        .shell
4812        .map(|raw_shell| {
4813            if raw_shell.allow.is_empty() {
4814                return Err(RuntimeManifestError::InvalidCapabilities {
4815                    field: "capabilities.shell.allow".to_string(),
4816                    message: "must contain at least one binary".to_string(),
4817                });
4818            }
4819            for (index, entry) in raw_shell.allow.iter().enumerate() {
4820                if let Some(reason) = shell_allow_entry_error(entry) {
4821                    return Err(RuntimeManifestError::ShellAllowEntry {
4822                        index,
4823                        entry: entry.clone(),
4824                        reason,
4825                    });
4826                }
4827            }
4828
4829            let interpreter_runtime =
4830                parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
4831            // Parsed after `interpreter_runtime` because the mutual-exclusion rule needs the
4832            // already-validated grant list to test against.
4833            let staged_runtime = parse_staged_runtime(
4834                &raw_shell.allow,
4835                &interpreter_runtime,
4836                raw_shell.staged_runtime,
4837            )?;
4838
4839            Ok(ShellCapabilities {
4840                allow: raw_shell.allow,
4841                strip_env: raw_shell.strip_env,
4842                baseline_env: raw_shell.baseline_env,
4843                interpreter_runtime,
4844                staged_runtime,
4845            })
4846        })
4847        .transpose()?;
4848
4849    let spawn = raw_caps
4850        .spawn
4851        .map(|raw_spawn| parse_spawn_allow(raw_spawn.allow))
4852        .transpose()?;
4853
4854    let env = raw_caps.env.map(|raw_env| EnvCapabilities {
4855        allow: raw_env.allow,
4856    });
4857
4858    let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
4859
4860    let resources = raw_caps
4861        .resources
4862        .map(parse_resource_capabilities)
4863        .transpose()?;
4864
4865    let state = raw_caps.state.map(parse_state_capabilities);
4866
4867    let task_io = raw_caps
4868        .task_io
4869        .map(|raw_task_io| {
4870            raw_task_io
4871                .read
4872                .map(|read| TaskIoCapabilities { read })
4873                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4874                    field: "capabilities.task_io.read".to_string(),
4875                    message: "must be set explicitly to true or false — a capability is never \
4876                              inferred"
4877                        .to_string(),
4878                })
4879        })
4880        .transpose()?;
4881
4882    let conversation = raw_caps
4883        .conversation
4884        .map(|raw_conversation| {
4885            raw_conversation
4886                .read
4887                .map(|read| ConversationCapabilities { read })
4888                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4889                    field: "capabilities.conversation.read".to_string(),
4890                    message: "must be set explicitly to true or false — a capability is never \
4891                              inferred"
4892                        .to_string(),
4893                })
4894        })
4895        .transpose()?;
4896
4897    let plan = raw_caps
4898        .plan
4899        .map(|raw_plan| {
4900            raw_plan
4901                .submit
4902                .map(|submit| PlanCapabilities { submit })
4903                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4904                    field: "capabilities.plan.submit".to_string(),
4905                    message: "must be set explicitly to true or false — a capability is never \
4906                              inferred"
4907                        .to_string(),
4908                })
4909        })
4910        .transpose()?;
4911
4912    let install = raw_caps.install.map(parse_install).transpose()?;
4913
4914    let containment = raw_caps
4915        .containment
4916        .as_deref()
4917        .map(str::trim)
4918        .filter(|value| !value.is_empty())
4919        .map(|value| {
4920            value.parse::<ContainmentClass>().map_err(|error| {
4921                RuntimeManifestError::InvalidCapabilities {
4922                    field: "capabilities.containment".to_string(),
4923                    message: error.to_string(),
4924                }
4925            })
4926        })
4927        .transpose()?;
4928
4929    Ok(Some(Capabilities {
4930        network,
4931        peer_fetch,
4932        filesystem,
4933        shell,
4934        spawn,
4935        env,
4936        limits,
4937        resources,
4938        state,
4939        task_io,
4940        conversation,
4941        plan,
4942        install,
4943        containment,
4944    }))
4945}
4946
4947/// Lower `capabilities.spawn.allow`: every entry a full name, no two sharing a bare part —
4948/// `delegate-task` offers the model the bare parts, so each must pick out one capsule.
4949fn parse_spawn_allow(allow: Vec<String>) -> Result<SpawnCapabilities, RuntimeManifestError> {
4950    let mut parsed: Vec<ArtifactName> = Vec::with_capacity(allow.len());
4951    for (index, entry) in allow.iter().enumerate() {
4952        let name = ArtifactName::parse(entry).map_err(|error| {
4953            RuntimeManifestError::InvalidArtifactName {
4954                field: format!("capabilities.spawn.allow[{index}]"),
4955                message: error.to_string(),
4956                hint: error.hint(),
4957            }
4958        })?;
4959        if let Some(earlier) = parsed
4960            .iter()
4961            .find(|earlier| earlier.name() == name.name() && **earlier != name)
4962        {
4963            return Err(RuntimeManifestError::InvalidArtifactName {
4964                field: format!("capabilities.spawn.allow[{index}]"),
4965                message: format!(
4966                    "'{earlier}' and '{name}' share the name '{}', which is how delegate-task \
4967                     offers a capsule to the model",
4968                    name.name()
4969                ),
4970                hint: Some(format!(
4971                    "keep one of '{earlier}' and '{name}' in capabilities.spawn.allow"
4972                )),
4973            });
4974        }
4975        parsed.push(name);
4976    }
4977    Ok(SpawnCapabilities { allow })
4978}
4979
4980/// Refuse an `artifacts[index].name` of the wrong form: a full name for an entry that resolves,
4981/// a bare name for a `source:` entry, which is never resolved or locked.
4982fn check_entry_name(
4983    index: usize,
4984    name: &str,
4985    has_source: bool,
4986) -> Result<(), RuntimeManifestError> {
4987    let field = format!("artifacts[{index}].name");
4988    if !has_source {
4989        return ArtifactName::parse(name).map(|_| ()).map_err(|error| {
4990            RuntimeManifestError::InvalidArtifactName {
4991                field,
4992                message: error.to_string(),
4993                hint: error.hint(),
4994            }
4995        });
4996    }
4997    if let Some((_, bare)) = name.split_once('/') {
4998        return Err(RuntimeManifestError::InvalidArtifactName {
4999            field,
5000            message: format!(
5001                "'{name}' declares 'source:', so it is named by its bare name; a scope says \
5002                 where a resolved artifact comes from, and a source: entry is never resolved"
5003            ),
5004            hint: Some(format!("drop the scope: name it '{bare}'")),
5005        });
5006    }
5007    match artifact_name_format_error(name) {
5008        Some(reason) => Err(RuntimeManifestError::InvalidArtifactName {
5009            field,
5010            message: format!("'{name}' is not a valid artifact name: it {reason}"),
5011            hint: None,
5012        }),
5013        None => Ok(()),
5014    }
5015}
5016
5017/// The refusal of a reference to an artifact `artifacts:` does not declare. When exactly one
5018/// declared entry has `name` as its bare part, the message names that entry, the reference the
5019/// operator most likely meant.
5020fn not_declared_message(artifacts: &[RuntimeArtifact], name: &str) -> String {
5021    let mut same_bare = artifacts
5022        .iter()
5023        .filter(|artifact| artifact.name != name && artifact.tool_name() == tool_name_of(name));
5024    match (same_bare.next(), same_bare.next()) {
5025        (Some(only), None) => format!(
5026            "artifact '{name}' is not declared in artifacts: (the declared '{}' has that name; \
5027             refer to it as '{}')",
5028            only.name, only.name
5029        ),
5030        _ => format!("artifact '{name}' is not declared in artifacts:"),
5031    }
5032}
5033
5034/// Lower `capabilities.install`, refusing a block that names nothing and any entry outside
5035/// [`crate::install_pattern`]'s grammar.
5036fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
5037    if raw.skill.is_empty() && raw.tool.is_empty() {
5038        return Err(RuntimeManifestError::InvalidCapabilities {
5039            field: "capabilities.install".to_string(),
5040            message: "must name at least one 'skill' or 'tool' entry — a capability is never \
5041                      inferred; omit the block to grant no install at all"
5042                .to_string(),
5043        });
5044    }
5045    for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
5046        for (index, entry) in entries.iter().enumerate() {
5047            crate::install_pattern::validate(entry).map_err(|error| {
5048                RuntimeManifestError::InvalidArtifactName {
5049                    field: format!("capabilities.install.{key}[{index}]"),
5050                    message: format!(
5051                        "entry {entry:?} is invalid ({}); expected {}",
5052                        error.reason,
5053                        crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
5054                    ),
5055                    hint: error.hint,
5056                }
5057            })?;
5058        }
5059    }
5060    let from = match raw.from {
5061        None => InstallFrom::Stores,
5062        Some(list) => parse_install_from(&list)?,
5063    };
5064    Ok(InstallCapabilities {
5065        skill: raw.skill,
5066        tool: raw.tool,
5067        from,
5068    })
5069}
5070
5071/// Refuse `capabilities.install` on a `transport: process` capsule: the pull and remove tools are
5072/// offered through the http agent loop's tool array, which that transport never sends.
5073fn refuse_install_under_process_transport(
5074    capabilities: Option<&Capabilities>,
5075    inference: Option<&InferenceConfig>,
5076) -> Result<(), RuntimeManifestError> {
5077    let installs = capabilities.is_some_and(|capabilities| capabilities.install.is_some());
5078    let process = inference.is_some_and(|inference| inference.transport == "process");
5079    if installs && process {
5080        return Err(RuntimeManifestError::InvalidCapabilities {
5081            field: "capabilities.install".to_string(),
5082            message: "is not valid with transport: process; the harness owns its tool list, so a \
5083                      pulled artifact could never reach it"
5084                .to_string(),
5085        });
5086    }
5087    Ok(())
5088}
5089
5090/// The values `capabilities.install.from` accepts, as its refusals name them.
5091const INSTALL_FROM_ACCEPTED: &str = "[stores] or [stores, sources]";
5092
5093/// Lower `capabilities.install.from`: `stores` is required, `sources` optional, each at most
5094/// once and in either order.
5095fn parse_install_from(list: &[String]) -> Result<InstallFrom, RuntimeManifestError> {
5096    let refuse = |message: String| RuntimeManifestError::InvalidCapabilities {
5097        field: "capabilities.install.from".to_string(),
5098        message: format!("{message}; expected {INSTALL_FROM_ACCEPTED}"),
5099    };
5100    if list.is_empty() {
5101        return Err(refuse("is empty".to_string()));
5102    }
5103    let (mut stores, mut sources) = (false, false);
5104    for entry in list {
5105        let seen = match entry.as_str() {
5106            "stores" => &mut stores,
5107            "sources" => &mut sources,
5108            other => return Err(refuse(format!("names {other:?}, which is not a place"))),
5109        };
5110        if *seen {
5111            return Err(refuse(format!("names {entry:?} twice")));
5112        }
5113        *seen = true;
5114    }
5115    if !stores {
5116        return Err(refuse(
5117            "does not name stores, which every pull is answered from first".to_string(),
5118        ));
5119    }
5120    Ok(if sources {
5121        InstallFrom::StoresAndSources
5122    } else {
5123        InstallFrom::Stores
5124    })
5125}
5126
5127/// Lower `capabilities.state`. Infallible: the only field is a name, and a name's *shape* is a
5128/// runtime question (does it resolve to one directory under `~/.murmur/state/`?) rather than a
5129/// manifest-parse one — `capsule_runtime::state_store::validate_store_name` answers it and refuses
5130/// with `E-CAP-009`.
5131///
5132/// Surrounding whitespace is trimmed but an emptied value is *kept*, unlike
5133/// `capabilities.filesystem.scope`, which normalises empty to absent. `store: ""` is a written
5134/// declaration that names nothing, and silently defaulting it to the capsule name would hand a
5135/// store to someone who asked for a different one; it reaches the runtime and is refused there.
5136fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
5137    StateCapabilities {
5138        store: raw.store.map(|store| store.trim().to_string()),
5139    }
5140}
5141
5142/// Why `entry` is not a `capabilities.shell.allow` entry, which must be one path component: the
5143/// binary's name, looked up on `PATH`, and the name of the tool the model calls. `None` for a
5144/// bare binary name.
5145fn shell_allow_entry_error(entry: &str) -> Option<&'static str> {
5146    if entry.is_empty() {
5147        Some("is empty")
5148    } else if entry.contains('\0') {
5149        Some("contains a NUL byte")
5150    } else if entry.contains('/') {
5151        Some("is a path")
5152    } else if entry == "." || entry == ".." {
5153        Some("names a directory")
5154    } else if entry.chars().any(char::is_whitespace) {
5155        Some("contains whitespace")
5156    } else {
5157        None
5158    }
5159}
5160
5161/// Lower and validate `capabilities.shell.interpreter_runtime`. Every rejection is an
5162/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending value, matching the rest
5163/// of this file's "structurally present but semantically invalid" reporting. Nothing here can
5164/// expand a whole install prefix: each grant names specific directories, and each directory
5165/// carries an explicit `list_dir` the author had to write — enumerability is never inferred.
5166fn parse_interpreter_runtime(
5167    allow: &[String],
5168    raw_grants: Vec<RawInterpreterRuntimeGrant>,
5169) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
5170    let mut grants = Vec::with_capacity(raw_grants.len());
5171    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5172        let base = format!("capabilities.shell.interpreter_runtime[{index}]");
5173
5174        let binary = raw_grant
5175            .binary
5176            .filter(|b| !b.trim().is_empty())
5177            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5178                field: format!("{base}.binary"),
5179                message: "must name a binary".to_string(),
5180            })?;
5181
5182        // The whole point of this mechanism: it narrows filesystem access alongside an exec
5183        // grant that already exists. It can never itself grant exec, so the binary must already
5184        // be allowlisted in this same block's `allow`.
5185        if !allow.contains(&binary) {
5186            return Err(RuntimeManifestError::InvalidCapabilities {
5187                field: format!("{base}.binary"),
5188                message: format!(
5189                    "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
5190                     only narrow filesystem access for an already-allowlisted binary, never \
5191                     grant exec"
5192                ),
5193            });
5194        }
5195
5196        if raw_grant.dirs.is_empty() {
5197            return Err(RuntimeManifestError::InvalidCapabilities {
5198                field: format!("{base}.dirs"),
5199                message: "must name at least one directory".to_string(),
5200            });
5201        }
5202
5203        let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
5204        for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
5205            let dir_base = format!("{base}.dirs[{dir_index}]");
5206
5207            let path = raw_dir
5208                .path
5209                .filter(|p| !p.trim().is_empty())
5210                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5211                    field: format!("{dir_base}.path"),
5212                    message: "must name a host directory".to_string(),
5213                })?;
5214            if !path.starts_with('/') {
5215                return Err(RuntimeManifestError::InvalidCapabilities {
5216                    field: format!("{dir_base}.path"),
5217                    message: format!(
5218                        "'{path}' must be an absolute host path (start with '/') — these are \
5219                         filesystem paths outside the workdir"
5220                    ),
5221                });
5222            }
5223
5224            let list_dir =
5225                raw_dir
5226                    .list_dir
5227                    .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5228                        field: format!("{dir_base}.list_dir"),
5229                        message: format!(
5230                            "'{path}' must set list_dir explicitly to true or false — \
5231                         enumerability is never inferred"
5232                        ),
5233                    })?;
5234
5235            dirs.push(InterpreterRuntimeDir { path, list_dir });
5236        }
5237
5238        grants.push(InterpreterRuntimeGrant { binary, dirs });
5239    }
5240
5241    Ok(grants)
5242}
5243
5244/// Lower and validate `capabilities.shell.staged_runtime`, given this same block's `allow` list
5245/// and its already-validated `interpreter_runtime` grants. Every rejection is a
5246/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending field, matching
5247/// [`parse_interpreter_runtime`] and the rest of this file.
5248///
5249/// Four rules, each of them a thing that would otherwise be silently wrong at launch rather than
5250/// loudly wrong here:
5251///
5252///   1. `binary` must already be in `allow` — staging a runtime tree never itself grants exec.
5253///   2. `binary` must not also carry an `interpreter_runtime` grant — the two are alternatives,
5254///      and declaring both means the author expects a host-widening grant that a composed root
5255///      makes both unnecessary and (once Landlock re-installs inside the root) meaningless.
5256///   3. `source_path` must be absolute — it is a host path outside the workdir, resolved by the
5257///      launch host and not relative to anything the runtime knows.
5258///   4. `pin` must be present and non-empty — it is the value a human compares across two hosts,
5259///      so an absent one defeats the field's only purpose.
5260///
5261/// Nothing here touches the filesystem: whether `source_path` exists on *this* host is a launch
5262/// -time fact, not a manifest-validity one, and a manifest must stay parseable on a machine that
5263/// will never run it (`mur build` on a laptop for a Linux fleet).
5264fn parse_staged_runtime(
5265    allow: &[String],
5266    interpreter_runtime: &[InterpreterRuntimeGrant],
5267    raw_grants: Vec<RawStagedRuntimeGrant>,
5268) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
5269    let mut grants = Vec::with_capacity(raw_grants.len());
5270    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5271        let base = format!("capabilities.shell.staged_runtime[{index}]");
5272
5273        let binary = raw_grant
5274            .binary
5275            .filter(|b| !b.trim().is_empty())
5276            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5277                field: format!("{base}.binary"),
5278                message: "must name a binary".to_string(),
5279            })?;
5280
5281        if !allow.contains(&binary) {
5282            return Err(RuntimeManifestError::InvalidCapabilities {
5283                field: format!("{base}.binary"),
5284                message: format!(
5285                    "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
5286                     stage the runtime tree behind an already-allowlisted binary, never grant exec"
5287                ),
5288            });
5289        }
5290
5291        if interpreter_runtime
5292            .iter()
5293            .any(|grant| grant.binary == binary)
5294        {
5295            return Err(RuntimeManifestError::InvalidCapabilities {
5296                field: format!("{base}.binary"),
5297                message: format!(
5298                    "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
5299                     are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
5300                     tree into the capsule's own root, which is what makes widening the capsule's \
5301                     host filesystem scope unnecessary rather than something to add to it. Remove \
5302                     the interpreter_runtime grant for '{binary}'"
5303                ),
5304            });
5305        }
5306
5307        let source_path = raw_grant
5308            .source_path
5309            .filter(|p| !p.trim().is_empty())
5310            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5311                field: format!("{base}.source_path"),
5312                message: "must name the host directory holding the pinned runtime tree".to_string(),
5313            })?;
5314        if !source_path.starts_with('/') {
5315            return Err(RuntimeManifestError::InvalidCapabilities {
5316                field: format!("{base}.source_path"),
5317                message: format!(
5318                    "'{source_path}' must be an absolute host path (start with '/') — \
5319                     staged-runtime sources are host filesystem paths outside the workdir"
5320                ),
5321            });
5322        }
5323
5324        let pin = raw_grant
5325            .pin
5326            .map(|p| p.trim().to_string())
5327            .filter(|p| !p.is_empty())
5328            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5329                field: format!("{base}.pin"),
5330                message: format!(
5331                    "'{source_path}' must declare a non-empty pin identifying which build this \
5332                     tree is — it is never inferred, because it is the value a human compares \
5333                     across two hosts to confirm the same runtime shipped to both"
5334                ),
5335            })?;
5336
5337        grants.push(StagedRuntimeGrant {
5338            binary,
5339            source_path,
5340            pin,
5341        });
5342    }
5343
5344    Ok(grants)
5345}
5346
5347/// Lower `capabilities.limits`, rejecting values that can only ever produce a guest that
5348/// traps on its first instruction. A zero cap is always a manifest authoring mistake, and
5349/// catching it here means `mur run` fails at parse time with a field name rather than
5350/// surfacing much later as an opaque wasm trap.
5351fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
5352    let reject_zero_usize =
5353        |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
5354            match value {
5355                Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5356                    field: format!("capabilities.limits.{field}"),
5357                    message: "must be greater than zero".to_string(),
5358                }),
5359                _ => Ok(()),
5360            }
5361        };
5362
5363    reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
5364    reject_zero_usize(raw.table_elements, "table_elements")?;
5365    reject_zero_usize(raw.instances, "instances")?;
5366    if raw.deadline_seconds == Some(0) {
5367        return Err(RuntimeManifestError::InvalidCapabilities {
5368            field: "capabilities.limits.deadline_seconds".to_string(),
5369            message: "must be greater than zero".to_string(),
5370        });
5371    }
5372
5373    Ok(ResourceLimits {
5374        memory_bytes: raw.memory_bytes,
5375        table_elements: raw.table_elements,
5376        instances: raw.instances,
5377        deadline_seconds: raw.deadline_seconds,
5378    })
5379}
5380
5381/// Lower and validate `capabilities.resources`. Mirrors [`parse_resource_limits`]: nothing is
5382/// defaulted here (the runtime owns the defaults, so "omitted" must stay distinguishable from
5383/// "declared"), and a declared `0` is rejected outright on every field — a zero ceiling is
5384/// never what an author means, and letting it through would turn a typo into a subprocess that
5385/// cannot fork, open a file, or run for a single CPU-second.
5386fn parse_resource_capabilities(
5387    raw: RawResourceCapabilities,
5388) -> Result<ResourceCapabilities, RuntimeManifestError> {
5389    let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
5390        match value {
5391            Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5392                field: format!("capabilities.resources.{field}"),
5393                message: "must be greater than zero".to_string(),
5394            }),
5395            _ => Ok(()),
5396        }
5397    };
5398
5399    reject_zero(raw.max_processes, "max_processes")?;
5400    reject_zero(raw.max_open_files, "max_open_files")?;
5401    reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
5402    reject_zero(raw.cpu_seconds, "cpu_seconds")?;
5403    reject_zero(raw.memory_bytes, "memory_bytes")?;
5404    reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
5405    reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
5406    reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
5407    reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
5408    reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
5409
5410    Ok(ResourceCapabilities {
5411        max_processes: raw.max_processes,
5412        max_open_files: raw.max_open_files,
5413        max_file_size_bytes: raw.max_file_size_bytes,
5414        cpu_seconds: raw.cpu_seconds,
5415        memory_bytes: raw.memory_bytes,
5416        cgroup_memory_bytes: raw.cgroup_memory_bytes,
5417        cgroup_pids_max: raw.cgroup_pids_max,
5418        cgroup_cpu_percent: raw.cgroup_cpu_percent,
5419        cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
5420        workdir_max_bytes: raw.workdir_max_bytes,
5421    })
5422}
5423
5424/// Why a `transport: process` manifest with no `inference.driver` is refused. Names one driver
5425/// artifact by way of example: this is the one message a reader reaches with nothing to go on, and
5426/// an abstract instruction to "name a process driver" leaves them no way to find one.
5427const PROCESS_DRIVER_REQUIRED: &str =
5428    "missing required field; a transport: process capsule must name the process driver that \
5429     knows how to drive its harness. inference.command no longer selects a harness — it only \
5430     overrides the binary the driver names. Add inference.driver.artifact naming a process \
5431     driver, for example murmur/driver-claude-code";
5432
5433fn parse_inference(
5434    raw: Option<RawInferenceConfig>,
5435) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
5436    let Some(raw) = raw else {
5437        return Ok(None);
5438    };
5439
5440    let transport = raw
5441        .transport
5442        .map(|s| s.trim().to_string())
5443        .filter(|s| !s.is_empty())
5444        .unwrap_or_else(|| "http".to_string());
5445
5446    let compaction = parse_compaction(raw.compaction)?;
5447    let system_prompt = optional_trimmed_string(raw.system_prompt);
5448    let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
5449    let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
5450
5451    let prompt_sources_set = [
5452        system_prompt.is_some(),
5453        system_prompt_file.is_some(),
5454        system_prompt_artifact.is_some(),
5455    ]
5456    .iter()
5457    .filter(|&&b| b)
5458    .count();
5459    if prompt_sources_set > 1 {
5460        return Err(RuntimeManifestError::InvalidInferenceConfig {
5461            field: "inference.system_prompt".to_string(),
5462            message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
5463                      may be set"
5464                .to_string(),
5465        });
5466    }
5467
5468    if raw.max_turns == Some(0) {
5469        return Err(RuntimeManifestError::InvalidInferenceConfig {
5470            field: "inference.max_turns".to_string(),
5471            message: "must be greater than 0".to_string(),
5472        });
5473    }
5474    let max_turns = raw.max_turns;
5475
5476    // The reopen budget is a lifecycle knob, read from `lifecycle.max_task_reopens`. Nothing
5477    // here consumes the `inference` spelling, so it would be silently inert — reject it rather
5478    // than let it look effective.
5479    if raw.max_task_reopens.is_some() {
5480        return Err(RuntimeManifestError::InvalidInferenceConfig {
5481            field: "inference.max_task_reopens".to_string(),
5482            message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
5483                .to_string(),
5484        });
5485    }
5486
5487    // Left to the unknown-key pass, either field would only draw a warning and the capsule would
5488    // launch with no upstream or credential bound, so each is refused by name, pointing at the
5489    // driver entry's `gateway:` block. Nothing translates it.
5490    for (present, removed, moved_to) in [
5491        (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
5492        (raw.api_key.is_some(), "api_key", "gateway.api_key"),
5493    ] {
5494        if !present {
5495            continue;
5496        }
5497        let entry = raw
5498            .driver
5499            .as_ref()
5500            .and_then(|driver| driver.artifact.as_deref())
5501            .map(str::trim)
5502            .filter(|name| !name.is_empty())
5503            .map(|name| format!("on the artifacts: entry '{name}'"))
5504            .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
5505        return Err(RuntimeManifestError::InvalidInferenceConfig {
5506            field: format!("inference.{removed}"),
5507            message: format!("was removed; set {moved_to} {entry} instead"),
5508        });
5509    }
5510
5511    match transport.as_str() {
5512        "http" => {
5513            let model = required_inference_field(raw.model, "model")?;
5514
5515            let driver_raw =
5516                raw.driver
5517                    .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5518                        field: "inference.driver".to_string(),
5519                        message: "missing required field".to_string(),
5520                    })?;
5521            let driver = parse_inference_driver(driver_raw)?;
5522
5523            // Advisory only: catch an authoring typo at parse time rather than as a confusing
5524            // provider 400. Large values are deliberately NOT clamped — a ceiling here would
5525            // block a model whose real limit is higher than anything we could hard-code.
5526            if raw.max_tokens == Some(0) {
5527                return Err(RuntimeManifestError::InvalidInferenceConfig {
5528                    field: "inference.max_tokens".to_string(),
5529                    message: "must be greater than 0".to_string(),
5530                });
5531            }
5532            if raw.max_session_tokens == Some(0) {
5533                return Err(RuntimeManifestError::InvalidInferenceConfig {
5534                    field: "inference.max_session_tokens".to_string(),
5535                    message: "must be greater than 0".to_string(),
5536                });
5537            }
5538            let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
5539            let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
5540            let retry = parse_inference_retry(raw.retry)?;
5541
5542            Ok(Some(InferenceConfig {
5543                transport,
5544                model,
5545                driver: Some(driver),
5546                command: None,
5547                compaction,
5548                system_prompt,
5549                system_prompt_file,
5550                system_prompt_artifact,
5551                max_turns,
5552                max_tokens: raw.max_tokens,
5553                max_session_tokens: raw.max_session_tokens,
5554                tool_refresh,
5555                alternates,
5556                retry,
5557            }))
5558        }
5559        "process" => {
5560            // The CLI subprocess path never builds a driver payload, so this value would be
5561            // silently inert here — reject it rather than let it look effective.
5562            if raw.max_tokens.is_some() {
5563                return Err(RuntimeManifestError::InvalidInferenceConfig {
5564                    field: "inference.max_tokens".to_string(),
5565                    message: "is not valid with transport: process".to_string(),
5566                });
5567            }
5568            // The harness owns its tool list and runs its own compaction, so neither trigger has a
5569            // turn boundary here to act on.
5570            if raw.tool_refresh.is_some() {
5571                return Err(RuntimeManifestError::InvalidInferenceConfig {
5572                    field: "inference.tool_refresh".to_string(),
5573                    message: "is not valid with transport: process".to_string(),
5574                });
5575            }
5576            // The harness runs its own compaction, so no `on-compaction` call is made here for
5577            // either key to shape.
5578            for (present, key) in [
5579                (
5580                    compaction.as_ref().is_some_and(|c| c.max_tokens.is_some()),
5581                    "max_tokens",
5582                ),
5583                (
5584                    compaction
5585                        .as_ref()
5586                        .is_some_and(|c| c.driver_config.is_some()),
5587                    "driver_config",
5588                ),
5589            ] {
5590                if present {
5591                    return Err(RuntimeManifestError::InvalidInferenceConfig {
5592                        field: format!("inference.compaction.{key}"),
5593                        message: "is not valid with transport: process".to_string(),
5594                    });
5595                }
5596            }
5597            // The harness owns the conversation and the model it runs, so there is no agent-loop
5598            // call for a switch to reroute.
5599            if raw.alternates.is_some() {
5600                return Err(RuntimeManifestError::InvalidInferenceConfig {
5601                    field: "inference.alternates".to_string(),
5602                    message: "is not valid with transport: process; the harness owns the \
5603                              conversation and the model it runs"
5604                        .to_string(),
5605                });
5606            }
5607            if raw.retry.is_some() {
5608                return Err(RuntimeManifestError::InvalidInferenceConfig {
5609                    field: "inference.retry".to_string(),
5610                    message: "is not valid with transport: process; the harness retries its own \
5611                              provider calls"
5612                        .to_string(),
5613                });
5614            }
5615
5616            // Valid here: the harness's own reported token counts are what this transport's
5617            // turns are metered against, and a driver that reports none is refused at load
5618            // rather than run for ever beneath a ceiling nothing could reach.
5619            if raw.max_session_tokens == Some(0) {
5620                return Err(RuntimeManifestError::InvalidInferenceConfig {
5621                    field: "inference.max_session_tokens".to_string(),
5622                    message: "must be greater than 0".to_string(),
5623                });
5624            }
5625
5626            let driver = raw.driver.map(parse_inference_driver).transpose()?;
5627            let command = optional_trimmed_string(raw.command);
5628            // The driver is what knows how to drive a harness CLI: its flags, its output format,
5629            // its version probe. `command` only says which executable to run instead of the one
5630            // the driver names, so it selects nothing on its own.
5631            if driver.is_none() {
5632                return Err(RuntimeManifestError::InvalidInferenceConfig {
5633                    field: "inference.driver.artifact".to_string(),
5634                    message: PROCESS_DRIVER_REQUIRED.to_string(),
5635                });
5636            }
5637            // model is OPTIONAL for transport: process — an empty string leaves the choice of
5638            // model to the harness, which has an account default of its own. The driver is handed
5639            // `none` rather than an empty string, so it never passes a model flag with no value.
5640            let model = optional_trimmed_string(raw.model).unwrap_or_default();
5641
5642            Ok(Some(InferenceConfig {
5643                transport,
5644                model,
5645                driver,
5646                command,
5647                compaction,
5648                system_prompt,
5649                system_prompt_file,
5650                system_prompt_artifact,
5651                max_turns,
5652                max_tokens: None,
5653                max_session_tokens: raw.max_session_tokens,
5654                tool_refresh: ToolRefresh::default(),
5655                alternates: Vec::new(),
5656                retry: InferenceRetry::default(),
5657            }))
5658        }
5659        other => Err(RuntimeManifestError::InvalidInferenceConfig {
5660            field: "inference.transport".to_string(),
5661            message: format!("unknown value '{other}'"),
5662        }),
5663    }
5664}
5665
5666/// Lowers `inference.retry` under `transport: http`: each omitted key takes its default, and each
5667/// value is checked against its bounds. `max_delay_ms` below `initial_delay_ms` is refused on
5668/// `max_delay_ms`.
5669fn parse_inference_retry(
5670    raw: Option<RawInferenceRetry>,
5671) -> Result<InferenceRetry, RuntimeManifestError> {
5672    let defaults = InferenceRetry::default();
5673    let Some(raw) = raw else {
5674        return Ok(defaults);
5675    };
5676    let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5677        field: format!("inference.retry.{key}"),
5678        message,
5679    };
5680    let retry = InferenceRetry {
5681        max_retries: raw.max_retries.unwrap_or(defaults.max_retries),
5682        initial_delay_ms: raw.initial_delay_ms.unwrap_or(defaults.initial_delay_ms),
5683        max_delay_ms: raw.max_delay_ms.unwrap_or(defaults.max_delay_ms),
5684    };
5685    if retry.max_retries > InferenceRetry::MAX_RETRIES {
5686        return Err(refuse(
5687            "max_retries",
5688            format!("must be at most {}", InferenceRetry::MAX_RETRIES),
5689        ));
5690    }
5691    if !(1..=InferenceRetry::MAX_INITIAL_DELAY_MS).contains(&retry.initial_delay_ms) {
5692        return Err(refuse(
5693            "initial_delay_ms",
5694            format!(
5695                "must be between 1 and {}",
5696                InferenceRetry::MAX_INITIAL_DELAY_MS
5697            ),
5698        ));
5699    }
5700    if !(1..=InferenceRetry::MAX_DELAY_MS).contains(&retry.max_delay_ms) {
5701        return Err(refuse(
5702            "max_delay_ms",
5703            format!("must be between 1 and {}", InferenceRetry::MAX_DELAY_MS),
5704        ));
5705    }
5706    if retry.max_delay_ms < retry.initial_delay_ms {
5707        return Err(refuse(
5708            "max_delay_ms",
5709            format!(
5710                "must be at least inference.retry.initial_delay_ms ({})",
5711                retry.initial_delay_ms
5712            ),
5713        ));
5714    }
5715    Ok(retry)
5716}
5717
5718/// Whether `name` is an alternate's name: a lowercase letter, then up to 62 lowercase letters,
5719/// digits and hyphens.
5720fn is_valid_driver_choice_name(name: &str) -> bool {
5721    let mut chars = name.chars();
5722    chars.next().is_some_and(|first| first.is_ascii_lowercase())
5723        && name.len() <= 63
5724        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
5725}
5726
5727/// Lowers `inference.alternates` under `transport: http`, with the rules one entry and the list
5728/// can be judged on alone. What needs `artifacts:` is [`validate_driver_choices`]'s.
5729///
5730/// `primary_driver` and `primary_model` are the primary choice's pair, which no alternate may
5731/// repeat: two names for one pair would make a switch between them a change of nothing.
5732fn parse_alternates(
5733    raw: Option<Vec<RawInferenceAlternate>>,
5734    primary_driver: &str,
5735    primary_model: &str,
5736) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
5737    let mut alternates: Vec<InferenceAlternate> = Vec::new();
5738    for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
5739        let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5740            field: if key.is_empty() {
5741                format!("inference.alternates[{index}]")
5742            } else {
5743                format!("inference.alternates[{index}].{key}")
5744            },
5745            message,
5746        };
5747        let name = optional_trimmed_string(entry.name)
5748            .ok_or_else(|| refuse("name", "missing required field".to_string()))?;
5749        if name == PRIMARY_DRIVER_CHOICE {
5750            return Err(refuse(
5751                "name",
5752                format!(
5753                    "'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
5754                     inference.model; name the alternate something else"
5755                ),
5756            ));
5757        }
5758        if !is_valid_driver_choice_name(&name) {
5759            return Err(refuse(
5760                "name",
5761                format!(
5762                    "'{name}' is not a driver choice name; a name is a lowercase letter followed \
5763                     by up to 62 lowercase letters, digits and hyphens"
5764                ),
5765            ));
5766        }
5767        if alternates.iter().any(|alternate| alternate.name == name) {
5768            return Err(refuse(
5769                "name",
5770                format!("'{name}' names more than one alternate"),
5771            ));
5772        }
5773        let model = optional_trimmed_string(entry.model)
5774            .ok_or_else(|| refuse("model", "missing required field".to_string()))?;
5775        let Some(driver) = entry.driver else {
5776            return Err(refuse(
5777                "driver.artifact",
5778                "missing required field".to_string(),
5779            ));
5780        };
5781        if driver
5782            .config
5783            .as_ref()
5784            .is_some_and(|config| !matches!(config, serde_norway::Value::Null))
5785        {
5786            return Err(refuse(
5787                "driver.config",
5788                "is not valid on an alternate; inference.driver.config applies to every \
5789                 declared driver, and per-driver settings belong in that driver's \
5790                 artifacts[].config"
5791                    .to_string(),
5792            ));
5793        }
5794        let driver = optional_trimmed_string(driver.artifact)
5795            .ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
5796        if driver == primary_driver && model == primary_model {
5797            return Err(refuse(
5798                "",
5799                format!(
5800                    "'{name}' is driver '{driver}' with model '{model}', which is the primary \
5801                     choice; an alternate names a different pair"
5802                ),
5803            ));
5804        }
5805        if let Some(twin) = alternates
5806            .iter()
5807            .find(|alternate| alternate.driver == driver && alternate.model == model)
5808        {
5809            return Err(refuse(
5810                "",
5811                format!(
5812                    "'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
5813                     already is; an alternate names a different pair",
5814                    twin.name
5815                ),
5816            ));
5817        }
5818        alternates.push(InferenceAlternate {
5819            name,
5820            model,
5821            driver,
5822        });
5823    }
5824    Ok(alternates)
5825}
5826
5827/// `inference.tool_refresh` under `transport: http`: absent is [`ToolRefresh::Compaction`].
5828fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
5829    let Some(value) = raw else {
5830        return Ok(ToolRefresh::default());
5831    };
5832    ToolRefresh::ALL
5833        .into_iter()
5834        .find(|refresh| refresh.wire_name() == value)
5835        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5836            field: "inference.tool_refresh".to_string(),
5837            message: format!(
5838                "must be one of: {}; got '{value}'",
5839                ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
5840            ),
5841        })
5842}
5843
5844fn parse_context(
5845    raw: Option<RawContextConfig>,
5846) -> Result<Option<ContextConfig>, RuntimeManifestError> {
5847    let Some(raw) = raw else {
5848        return Ok(None);
5849    };
5850
5851    if let Some(max_tokens) = raw.max_tokens {
5852        if max_tokens == 0 {
5853            return Err(RuntimeManifestError::InvalidInferenceConfig {
5854                field: "context.max_tokens".to_string(),
5855                message: "must be greater than 0".to_string(),
5856            });
5857        }
5858    }
5859
5860    let fraction = |field: &str, value: Option<f32>, default: f32| match value {
5861        None => Ok(default),
5862        Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
5863        Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
5864            field: field.to_string(),
5865            message: "must be between 0.0 and 1.0".to_string(),
5866        }),
5867    };
5868
5869    let record = match raw.record.as_deref().map(str::trim) {
5870        None => true,
5871        Some("on") => true,
5872        Some("off") => false,
5873        Some(other) => {
5874            return Err(RuntimeManifestError::InvalidInferenceConfig {
5875                field: "context.record".to_string(),
5876                message: format!("unknown value '{other}'; expected: on, off"),
5877            })
5878        }
5879    };
5880
5881    Ok(Some(ContextConfig {
5882        max_tokens: raw.max_tokens,
5883        record,
5884        record_store: raw.record_store.map(|store| store.trim().to_string()),
5885        seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
5886        seed_overflow_margin: fraction(
5887            "context.seed_overflow_margin",
5888            raw.seed_overflow_margin,
5889            DEFAULT_SEED_OVERFLOW_MARGIN,
5890        )?,
5891        retain: parse_context_retain(raw.retain)?,
5892    }))
5893}
5894
5895/// The keys `trace.retain` accepts, in the order an error lists them.
5896const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
5897
5898/// The keys `context.retain` accepts, in the order an error lists them.
5899const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
5900
5901/// What both `retain:` blocks say when they carry nothing. Stated once so the two errors, which
5902/// travel through different `RuntimeManifestError` variants, cannot drift apart.
5903const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
5904                                  keep everything, which is what no policy means";
5905
5906/// One `retain:` block, reduced to its two recognized keys.
5907///
5908/// Shared by `trace.retain` and `context.retain`: they differ only in the name of the count key,
5909/// so the shape check, the unknown-key refusal and the empty-block refusal are written once.
5910/// `count_key` is `max_sessions` or `max_messages`; `block` is the dotted path an error names.
5911fn parse_retain_block(
5912    block: &str,
5913    count_key: &str,
5914    accepted: &[&str],
5915    raw: serde_norway::Value,
5916    invalid: &dyn Fn(String, String) -> RuntimeManifestError,
5917) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
5918    let mapping = match raw {
5919        serde_norway::Value::Mapping(mapping) => mapping,
5920        // `retain:` with nothing under it parses as null and means exactly `retain: {}`.
5921        serde_norway::Value::Null => {
5922            return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
5923        }
5924        other => {
5925            return Err(invalid(
5926                block.to_string(),
5927                format!(
5928                    "must be a block of keys ({}), got '{other:?}'",
5929                    accepted.join(", ")
5930                ),
5931            ))
5932        }
5933    };
5934    if mapping.is_empty() {
5935        return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
5936    }
5937
5938    let mut count: Option<u32> = None;
5939    let mut max_age_secs: Option<u64> = None;
5940    for (key, value) in &mapping {
5941        let key = key.as_str().unwrap_or_default();
5942        let field = format!("{block}.{key}");
5943        if key == count_key {
5944            let parsed = value
5945                .as_u64()
5946                .and_then(|n| u32::try_from(n).ok())
5947                .ok_or_else(|| {
5948                    invalid(
5949                        field.clone(),
5950                        "must be a whole number of at least 1".to_string(),
5951                    )
5952                })?;
5953            if parsed == 0 {
5954                return Err(invalid(
5955                    field,
5956                    "must be at least 1 — a limit of zero would delete what retention exists \
5957                     to keep; remove the key to leave it unbounded"
5958                        .to_string(),
5959                ));
5960            }
5961            count = Some(parsed);
5962        } else if key == "max_age" {
5963            let text = scalar_text(value).ok_or_else(|| {
5964                invalid(
5965                    field.clone(),
5966                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
5967                )
5968            })?;
5969            let parsed =
5970                parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
5971            if parsed == 0 {
5972                return Err(invalid(
5973                    field,
5974                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
5975                ));
5976            }
5977            max_age_secs = Some(parsed);
5978        } else {
5979            return Err(invalid(
5980                block.to_string(),
5981                format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
5982            ));
5983        }
5984    }
5985    Ok((count, max_age_secs))
5986}
5987
5988fn parse_trace_retain(
5989    raw: Option<serde_norway::Value>,
5990) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
5991    let Some(raw) = raw else {
5992        return Ok(None);
5993    };
5994    let (max_sessions, max_age_secs) = parse_retain_block(
5995        "trace.retain",
5996        "max_sessions",
5997        &TRACE_RETAIN_KEYS,
5998        raw,
5999        &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
6000    )?;
6001    Ok(Some(TraceRetainConfig {
6002        max_sessions,
6003        max_age_secs,
6004    }))
6005}
6006
6007fn parse_context_retain(
6008    raw: Option<serde_norway::Value>,
6009) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
6010    let Some(raw) = raw else {
6011        return Ok(None);
6012    };
6013    let (max_messages, max_age_secs) = parse_retain_block(
6014        "context.retain",
6015        "max_messages",
6016        &CONTEXT_RETAIN_KEYS,
6017        raw,
6018        &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
6019    )?;
6020    Ok(Some(ContextRetainConfig {
6021        max_messages,
6022        max_age_secs,
6023    }))
6024}
6025
6026fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
6027    raw.map(|raw| ObservabilityConfig {
6028        otel_endpoint: raw
6029            .otel_endpoint
6030            .map(|value| value.trim().to_string())
6031            .filter(|value| !value.is_empty()),
6032        eval: raw.eval.map(parse_eval_config),
6033    })
6034}
6035
6036fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
6037    let scorers = raw
6038        .scorers
6039        .into_iter()
6040        .filter_map(|raw| {
6041            let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
6042            match raw.scorer_type.as_str() {
6043                "exit_ok" => Some(ScorerConfig::ExitOk { name }),
6044                "max_turns" => Some(ScorerConfig::MaxTurns {
6045                    name,
6046                    max: raw.max.unwrap_or(10) as u32,
6047                }),
6048                "max_tokens" => Some(ScorerConfig::MaxTokens {
6049                    name,
6050                    max: raw.max.unwrap_or(100_000),
6051                }),
6052                "tool_sequence" => Some(ScorerConfig::ToolSequence {
6053                    name,
6054                    expected: raw.expected.unwrap_or_default(),
6055                }),
6056                "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
6057                other => {
6058                    crate::warn_line(format_args!(
6059                        "[murmur-artifact] unknown scorer type '{other}' — skipping"
6060                    ));
6061                    None
6062                }
6063            }
6064        })
6065        .collect();
6066
6067    EvalConfig {
6068        dataset_id: raw.dataset_id,
6069        scorers,
6070    }
6071}
6072
6073fn parse_compaction(
6074    raw: Option<RawCompactionConfig>,
6075) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
6076    let Some(raw) = raw else {
6077        return Ok(None);
6078    };
6079
6080    if let Some(threshold) = raw.threshold {
6081        if threshold <= 0.0 || threshold > 1.0 {
6082            return Err(RuntimeManifestError::InvalidInferenceConfig {
6083                field: "inference.compaction.threshold".to_string(),
6084                message: format!("must be in (0.0, 1.0] but got {threshold}"),
6085            });
6086        }
6087    }
6088
6089    if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
6090        return Err(RuntimeManifestError::InvalidInferenceConfig {
6091            field: "inference.compaction.system_prompt".to_string(),
6092            message: "at most one of inference.compaction.system_prompt, \
6093                      inference.compaction.system_prompt_file may be set"
6094                .to_string(),
6095        });
6096    }
6097
6098    if raw.max_tokens == Some(0) {
6099        return Err(RuntimeManifestError::InvalidInferenceConfig {
6100            field: "inference.compaction.max_tokens".to_string(),
6101            message: "must be greater than 0".to_string(),
6102        });
6103    }
6104    let driver_config =
6105        driver_config_json(raw.driver_config, "inference.compaction.driver_config")?;
6106
6107    Ok(Some(CompactionConfig {
6108        threshold: raw.threshold,
6109        model: raw.model,
6110        system_prompt: raw.system_prompt,
6111        system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
6112        // A plain optional boolean: nothing to validate, and deliberately independent of the
6113        // threshold-range and prompt-source checks above.
6114        dump_summaries: raw.dump_summaries,
6115        max_tokens: raw.max_tokens,
6116        driver_config,
6117    }))
6118}
6119
6120fn optional_trimmed_string(value: Option<String>) -> Option<String> {
6121    value
6122        .map(|s| s.trim().to_string())
6123        .filter(|s| !s.is_empty())
6124}
6125
6126fn required_inference_field(
6127    value: Option<String>,
6128    key: &str,
6129) -> Result<String, RuntimeManifestError> {
6130    value
6131        .map(|value| value.trim().to_string())
6132        .filter(|value| !value.is_empty())
6133        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6134            field: format!("inference.{key}"),
6135            message: "missing required field".to_string(),
6136        })
6137}
6138
6139/// One artifact entry's `gateway:` block, checked and with its key reference kept unresolved.
6140///
6141/// The credential rule reads what the manifest wrote, never a resolved value, so it decides the
6142/// same under [`SecretResolution::Resolve`] and [`SecretResolution::Skip`]: a non-blank `api_key`
6143/// is keyed, `keyless: true` with no `api_key` key is keyless, both together is
6144/// [`RuntimeManifestError::InvalidArtifact`], and anything else is
6145/// [`RuntimeManifestError::GatewayWithoutCredential`]. The endpoint's address plays no part.
6146fn parse_artifact_gateway(
6147    index: usize,
6148    name: &str,
6149    raw: Option<RawArtifactGateway>,
6150    secrets: SecretResolution,
6151) -> Result<ArtifactGateway, RuntimeManifestError> {
6152    let (endpoint, api_key, keyless) = match raw {
6153        Some(raw) => (
6154            optional_trimmed_string(raw.endpoint),
6155            raw.api_key,
6156            raw.keyless,
6157        ),
6158        None => (None, None, None),
6159    };
6160    let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
6161        index,
6162        message: format!(
6163            "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
6164             the upstream URL the runtime sends this artifact's keyed requests to"
6165        ),
6166    })?;
6167    validate_gateway_endpoint(name, &endpoint)
6168        .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
6169
6170    let keyless = match keyless {
6171        None | Some(serde_norway::Value::Null) | Some(serde_norway::Value::Bool(false)) => false,
6172        Some(serde_norway::Value::Bool(true)) => true,
6173        Some(_) => {
6174            return Err(RuntimeManifestError::InvalidArtifact {
6175                index,
6176                message: format!(
6177                    "artifact '{name}' declares gateway.keyless with a value that is not a \
6178                     boolean; write keyless: true for an upstream that takes no key"
6179                ),
6180            })
6181        }
6182    };
6183    if keyless && api_key.is_some() {
6184        return Err(RuntimeManifestError::InvalidArtifact {
6185            index,
6186            message: format!(
6187                "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
6188                 gateway either presents a key or is keyless"
6189            ),
6190        });
6191    }
6192    if keyless {
6193        return Ok(ArtifactGateway {
6194            endpoint,
6195            api_key: None,
6196            keyless: true,
6197        });
6198    }
6199
6200    let written = api_key
6201        .flatten()
6202        .map(|value| value.trim().to_string())
6203        .filter(|value| !value.is_empty());
6204    let Some(written) = written else {
6205        return Err(RuntimeManifestError::GatewayWithoutCredential {
6206            index,
6207            name: name.to_string(),
6208            endpoint,
6209        });
6210    };
6211    let api_key = match secrets {
6212        SecretResolution::Skip => None,
6213        SecretResolution::Resolve => Some(match parse_env_reference(&written) {
6214            Some(variable) => ApiKeyReference::Environment(variable.to_string()),
6215            None => ApiKeyReference::Literal(written),
6216        }),
6217    };
6218
6219    Ok(ArtifactGateway {
6220        endpoint,
6221        api_key,
6222        keyless: false,
6223    })
6224}
6225
6226/// Checks a `gateway.endpoint`, the only control on where an artifact's key is sent: a
6227/// gateway-addressed request is readdressed before any `network.allow` rule is consulted, so
6228/// nothing else confines the destination.
6229///
6230/// Accepted: an `https://` URL with a host, or an `http://` URL whose host is `localhost` or an
6231/// IP literal for which [`IpAddr::is_loopback`] returns true (`127.0.0.0/8`, `::1`). An optional
6232/// port and path are kept.
6233///
6234/// Refused:
6235/// - `${` anywhere, checked before parsing: the field is never interpolated, and the URL parser
6236///   accepts `$`, `{` and `}` in a host, so `https://${HOST}` would otherwise parse as a literal
6237///   host.
6238/// - A malformed URL, a scheme other than `http`/`https`, or no host.
6239/// - Userinfo (`user@`, `user:pass@`): `https://api.example.com@evil.example` addresses
6240///   `evil.example`, and the gateway never sends userinfo, so a password there is a second,
6241///   unmanaged credential. The message names the host the URL addresses.
6242/// - A query or a fragment: the gateway readdresses each request by its own path and query, so
6243///   either would be silently dropped.
6244/// - `http://` to any host that is not loopback, including `0.0.0.0` and `::ffff:127.0.0.1`.
6245///
6246/// The endpoint confines the origin only. The guest chooses the whole path and query of every
6247/// gateway request.
6248///
6249/// The error is the whole message, naming `gateway.endpoint` and the artifact entry `artifact`.
6250/// It quotes the endpoint with any userinfo masked, so a password written into the URL is never
6251/// echoed.
6252fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
6253    let shown = mask_endpoint_userinfo(endpoint);
6254    let refuse =
6255        |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
6256    if endpoint.contains("${") {
6257        return Err(refuse(
6258            "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
6259             literally"
6260                .to_string(),
6261        ));
6262    }
6263    let url = Url::parse(endpoint)
6264        .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
6265    let host = match url.scheme() {
6266        "http" | "https" => url
6267            .host_str()
6268            .filter(|host| !host.is_empty())
6269            .ok_or_else(|| refuse("which has no host".to_string()))?,
6270        other => {
6271            return Err(refuse(format!(
6272                "whose scheme '{other}' is unsupported — expected http or https"
6273            )))
6274        }
6275    };
6276    if !url.username().is_empty() || url.password().is_some() {
6277        return Err(refuse(format!(
6278            "which carries userinfo before '@' — the gateway never sends it, and this URL \
6279             addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
6280             the key through gateway.api_key"
6281        )));
6282    }
6283    if url.query().is_some() {
6284        return Err(refuse(
6285            "which carries a query — the gateway keeps each request's own query, so write the \
6286             upstream without one"
6287                .to_string(),
6288        ));
6289    }
6290    if url.fragment().is_some() {
6291        return Err(refuse(
6292            "which carries a fragment — write the upstream without one".to_string(),
6293        ));
6294    }
6295    if url.scheme() == "http" {
6296        let bare = host.trim_start_matches('[').trim_end_matches(']');
6297        let is_loopback = bare == "localhost"
6298            || bare
6299                .parse::<IpAddr>()
6300                .map(|ip| ip.is_loopback())
6301                .unwrap_or(false);
6302        if !is_loopback {
6303            return Err(refuse(format!(
6304                "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
6305                 remote upstream, or http://localhost or a loopback IP literal for a local one"
6306            )));
6307        }
6308    }
6309    Ok(())
6310}
6311
6312/// `endpoint` with everything before the last `@` of its authority replaced by `***`.
6313///
6314/// Textual rather than parsed, so it also masks an endpoint the URL parser rejects.
6315fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
6316    let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
6317    let rest = &endpoint[authority_start..];
6318    let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
6319    match rest[..authority_len].rfind('@') {
6320        Some(at) => format!(
6321            "{}***{}",
6322            &endpoint[..authority_start],
6323            &endpoint[authority_start + at..]
6324        )
6325        .into(),
6326        None => endpoint.into(),
6327    }
6328}
6329
6330/// The cross-entry rules for a `gateway:` on a driver, which need the parsed `inference:` block.
6331///
6332/// A driver's gateway is a metered inference gateway only when that driver is the configured
6333/// `transport: http` driver or an `inference.alternates` entry's driver. On any other driver entry
6334/// it could be spent only through tool dispatch, where nothing meters it, so it is refused. Under
6335/// `transport: http` the configured driver's entry must exist and must carry the upstream.
6336fn validate_driver_gateways(
6337    artifacts: &[RuntimeArtifact],
6338    inference: Option<&InferenceConfig>,
6339) -> Result<(), RuntimeManifestError> {
6340    let configured_driver = inference
6341        .filter(|inference| inference.transport == "http")
6342        .and_then(|inference| inference.driver.as_ref())
6343        .map(|driver| driver.artifact.as_str());
6344    let alternate_drivers: Vec<&str> = inference
6345        .map(|inference| {
6346            inference
6347                .alternates
6348                .iter()
6349                .map(|alternate| alternate.driver.as_str())
6350                .collect()
6351        })
6352        .unwrap_or_default();
6353    let under_process = inference.is_some_and(|inference| inference.transport == "process");
6354
6355    // A reference to nothing is reported as such before any entry is judged against it: a
6356    // misspelt driver name would otherwise read as a gateway on the wrong driver.
6357    if let Some(driver) = configured_driver {
6358        if !artifacts.iter().any(|artifact| artifact.name == driver) {
6359            return Err(RuntimeManifestError::InvalidInferenceConfig {
6360                field: "inference.driver.artifact".to_string(),
6361                message: not_declared_message(artifacts, driver),
6362            });
6363        }
6364    }
6365
6366    for (index, artifact) in artifacts.iter().enumerate() {
6367        if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
6368            continue;
6369        }
6370        if under_process {
6371            return Err(RuntimeManifestError::InvalidArtifact {
6372                index,
6373                message: format!(
6374                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
6375                     valid with transport: process — the CLI subprocess holds its own \
6376                     credentials and a process driver is granted nothing",
6377                    artifact.name
6378                ),
6379            });
6380        }
6381        if configured_driver != Some(artifact.name.as_str())
6382            && !alternate_drivers.contains(&artifact.name.as_str())
6383        {
6384            return Err(RuntimeManifestError::InvalidArtifact {
6385                index,
6386                message: format!(
6387                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
6388                     the configured inference driver; a driver's gateway is accepted only on the \
6389                     entry inference.driver.artifact names under transport: http, or on one an \
6390                     inference.alternates entry names",
6391                    artifact.name
6392                ),
6393            });
6394        }
6395    }
6396
6397    if let Some(driver) = configured_driver {
6398        let Some((index, entry)) = artifacts
6399            .iter()
6400            .enumerate()
6401            .find(|(_, artifact)| artifact.name == driver)
6402        else {
6403            return Err(RuntimeManifestError::InvalidInferenceConfig {
6404                field: "inference.driver.artifact".to_string(),
6405                message: not_declared_message(artifacts, driver),
6406            });
6407        };
6408        if entry.gateway.is_none() {
6409            return Err(RuntimeManifestError::InvalidArtifact {
6410                index,
6411                message: format!(
6412                    "artifact '{driver}' is the transport: http inference driver but declares no \
6413                     gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
6414                     on its entry"
6415                ),
6416            });
6417        }
6418    }
6419    Ok(())
6420}
6421
6422/// The cross-entry rules for `inference.alternates`, which need `artifacts:`.
6423///
6424/// Each alternate's `driver.artifact` must be an `artifacts:` entry with `runtime: driver`, and
6425/// that entry must carry its own `gateway:` — the credential a switch attaches — unless it is the
6426/// primary's own driver, whose gateway [`validate_driver_gateways`] already requires.
6427fn validate_driver_choices(
6428    artifacts: &[RuntimeArtifact],
6429    inference: Option<&InferenceConfig>,
6430) -> Result<(), RuntimeManifestError> {
6431    let Some(inference) = inference else {
6432        return Ok(());
6433    };
6434    let primary_driver = inference
6435        .driver
6436        .as_ref()
6437        .map(|driver| driver.artifact.as_str());
6438    for (index, alternate) in inference.alternates.iter().enumerate() {
6439        let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
6440            field: format!("inference.alternates[{index}].driver.artifact"),
6441            message,
6442        };
6443        let driver = alternate.driver.as_str();
6444        let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
6445            return Err(refuse(not_declared_message(artifacts, driver)));
6446        };
6447        if entry.runtime != ArtifactRuntime::Driver {
6448            return Err(refuse(format!(
6449                "artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
6450                 runtime: driver entry",
6451                entry.runtime.as_str()
6452            )));
6453        }
6454        if entry.gateway.is_none() && primary_driver != Some(driver) {
6455            return Err(refuse(format!(
6456                "artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
6457                 its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
6458                 URL>, api_key: ... }}' on its entry"
6459            )));
6460        }
6461    }
6462    Ok(())
6463}
6464
6465/// Refuses alternates nothing could ever select: `inference.driver` is in neither
6466/// `control.settings` nor `control.agent_settings`.
6467fn validate_alternates_selectable(
6468    inference: Option<&InferenceConfig>,
6469    control: Option<&ControlConfig>,
6470) -> Result<(), RuntimeManifestError> {
6471    if inference.is_none_or(|inference| inference.alternates.is_empty()) {
6472        return Ok(());
6473    }
6474    let selectable = control.is_some_and(|control| {
6475        control.controller_may_set(ControllableSetting::InferenceDriver)
6476            || control.agent_may_set(ControllableSetting::InferenceDriver)
6477    });
6478    if selectable {
6479        return Ok(());
6480    }
6481    Err(RuntimeManifestError::InvalidInferenceConfig {
6482        field: "inference.alternates".to_string(),
6483        message: "declares alternates that nothing could ever select; list inference.driver in \
6484                  control.settings for a controller to switch, or in control.agent_settings for \
6485                  the agent to"
6486            .to_string(),
6487    })
6488}
6489
6490/// The cross-entry rules for the `transport: process` driver, which need the parsed
6491/// `inference:` block.
6492///
6493/// The artifact `inference.driver.artifact` names must be declared in `artifacts:` with
6494/// `runtime: driver`. A process driver is granted nothing, so its entry may carry neither
6495/// `capabilities:` nor `config:`; its settings travel in `inference.driver.config`. A `gateway:`
6496/// on it is refused by [`validate_driver_gateways`].
6497fn validate_process_driver_entry(
6498    artifacts: &[RuntimeArtifact],
6499    inference: Option<&InferenceConfig>,
6500) -> Result<(), RuntimeManifestError> {
6501    let Some(driver) = inference
6502        .filter(|inference| inference.transport == "process")
6503        .and_then(|inference| inference.driver.as_ref())
6504        .map(|driver| driver.artifact.as_str())
6505    else {
6506        return Ok(());
6507    };
6508    let Some((index, entry)) = artifacts
6509        .iter()
6510        .enumerate()
6511        .find(|(_, artifact)| artifact.name == driver)
6512    else {
6513        return Err(RuntimeManifestError::InvalidInferenceConfig {
6514            field: "inference.driver.artifact".to_string(),
6515            message: not_declared_message(artifacts, driver),
6516        });
6517    };
6518    if entry.runtime != ArtifactRuntime::Driver {
6519        return Err(RuntimeManifestError::InvalidArtifact {
6520            index,
6521            message: format!(
6522                "artifact '{driver}' is the transport: process driver and must be declared with \
6523                 runtime: driver"
6524            ),
6525        });
6526    }
6527    if entry.capabilities.is_some() || entry.config.is_some() {
6528        return Err(RuntimeManifestError::InvalidArtifact {
6529            index,
6530            message: format!(
6531                "artifact '{driver}' is the transport: process driver, which is granted nothing; \
6532                 remove capabilities:/config: from its entry — driver settings go in \
6533                 inference.driver.config"
6534            ),
6535        });
6536    }
6537    Ok(())
6538}
6539
6540fn parse_inference_driver(
6541    raw: RawInferenceDriver,
6542) -> Result<InferenceDriver, RuntimeManifestError> {
6543    let artifact = raw
6544        .artifact
6545        .map(|value| value.trim().to_string())
6546        .filter(|value| !value.is_empty())
6547        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6548            field: "inference.driver.artifact".to_string(),
6549            message: "missing required field".to_string(),
6550        })?;
6551
6552    let config = driver_config_json(raw.config, "inference.driver.config")?;
6553
6554    Ok(InferenceDriver { artifact, config })
6555}
6556
6557/// Lowers a driver config mapping to compact JSON, or `None` when absent or null. `field` names
6558/// the key in the refusal of anything but a mapping.
6559fn driver_config_json(
6560    raw: Option<serde_norway::Value>,
6561    field: &str,
6562) -> Result<Option<String>, RuntimeManifestError> {
6563    let Some(raw) = raw else {
6564        return Ok(None);
6565    };
6566
6567    if matches!(raw, serde_norway::Value::Null) {
6568        return Ok(None);
6569    }
6570
6571    if !matches!(raw, serde_norway::Value::Mapping(_)) {
6572        return Err(RuntimeManifestError::InvalidInferenceConfig {
6573            field: field.to_string(),
6574            message: "expected mapping/object".to_string(),
6575        });
6576    }
6577
6578    let value =
6579        serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
6580            field: field.to_string(),
6581            message: format!("failed to convert to JSON: {err}"),
6582        })?;
6583
6584    serde_json::to_string(&value).map(Some).map_err(|err| {
6585        RuntimeManifestError::InvalidInferenceConfig {
6586            field: field.to_string(),
6587            message: format!("failed to encode JSON: {err}"),
6588        }
6589    })
6590}
6591
6592fn parse_env_reference(value: &str) -> Option<&str> {
6593    if !value.starts_with("${") || !value.ends_with('}') {
6594        return None;
6595    }
6596
6597    let variable = &value[2..value.len() - 1];
6598    is_valid_env_variable(variable).then_some(variable)
6599}
6600
6601fn is_valid_env_variable(value: &str) -> bool {
6602    let mut chars = value.chars();
6603    let Some(first) = chars.next() else {
6604        return false;
6605    };
6606
6607    if !(first == '_' || first.is_ascii_uppercase()) {
6608        return false;
6609    }
6610
6611    chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
6612}
6613
6614#[cfg(test)]
6615mod tests {
6616    use super::*;
6617    use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
6618
6619    fn manifest_with_trace(block: &str) -> String {
6620        format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
6621    }
6622
6623    fn lifecycle(task_acceptance: TaskAcceptance, queue_depth: usize) -> LifecycleConfig {
6624        LifecycleConfig {
6625            task_acceptance,
6626            queue_depth,
6627            ..Default::default()
6628        }
6629    }
6630
6631    #[test]
6632    fn tasks_held_at_once_under_queue_is_one_running_plus_queue_depth_waiting() {
6633        assert_eq!(lifecycle(TaskAcceptance::Queue, 1).tasks_held_at_once(), 2);
6634        assert_eq!(lifecycle(TaskAcceptance::Queue, 9).tasks_held_at_once(), 10);
6635    }
6636
6637    #[test]
6638    fn queue_has_room_is_false_only_for_a_queue_at_depth_zero() {
6639        assert!(!lifecycle(TaskAcceptance::Queue, 0).queue_has_room());
6640        assert!(lifecycle(TaskAcceptance::Queue, 1).queue_has_room());
6641        assert!(lifecycle(TaskAcceptance::Single, 0).queue_has_room());
6642        assert!(lifecycle(TaskAcceptance::None, 0).queue_has_room());
6643        assert!(lifecycle(TaskAcceptance::Single, 3).queue_has_room());
6644    }
6645
6646    fn parse_lifecycle(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
6647        RuntimeManifest::from_yaml_str(&format!(
6648            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n{block}"
6649        ))
6650    }
6651
6652    #[test]
6653    fn queue_depth_zero_under_queue_is_refused_at_load() {
6654        let error = parse_lifecycle("  task_acceptance: queue\n  queue_depth: 0\n").unwrap_err();
6655        assert!(
6656            matches!(error, RuntimeManifestError::QueueWithoutRoom),
6657            "{error:?}"
6658        );
6659        assert_eq!(
6660            error.to_string(),
6661            "murmur.yaml: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves \
6662             task_acceptance: queue no room for a waiting task"
6663        );
6664    }
6665
6666    /// `queue_depth` means nothing outside `queue`, so `0` there parses.
6667    #[test]
6668    fn queue_depth_zero_outside_queue_still_parses() {
6669        for block in [
6670            "  task_acceptance: single\n  queue_depth: 0\n",
6671            "  task_acceptance: none\n  queue_depth: 0\n",
6672            "  queue_depth: 0\n",
6673        ] {
6674            let lifecycle = parse_lifecycle(block)
6675                .unwrap_or_else(|error| panic!("{block:?}: {error}"))
6676                .effective_lifecycle();
6677            assert_eq!(lifecycle.queue_depth, 0, "{block:?}");
6678        }
6679        let queue = parse_lifecycle("  task_acceptance: queue\n  queue_depth: 1\n")
6680            .unwrap()
6681            .effective_lifecycle();
6682        assert_eq!(queue.task_acceptance, TaskAcceptance::Queue);
6683        assert_eq!(queue.queue_depth, 1);
6684    }
6685
6686    #[test]
6687    fn tasks_held_at_once_under_single_is_one_whatever_the_depth() {
6688        assert_eq!(lifecycle(TaskAcceptance::Single, 1).tasks_held_at_once(), 1);
6689        assert_eq!(lifecycle(TaskAcceptance::Single, 5).tasks_held_at_once(), 1);
6690    }
6691
6692    #[test]
6693    fn tasks_held_at_once_under_none_is_zero_whatever_the_depth() {
6694        assert_eq!(lifecycle(TaskAcceptance::None, 1).tasks_held_at_once(), 0);
6695        assert_eq!(lifecycle(TaskAcceptance::None, 5).tasks_held_at_once(), 0);
6696    }
6697
6698    /// The default is `single` at `queue_depth: 1`, which holds one task; switching only
6699    /// `task_acceptance` to `queue` keeps the default depth and holds two.
6700    #[test]
6701    fn tasks_held_at_once_of_the_default_lifecycle_and_default_depth() {
6702        assert_eq!(LifecycleConfig::default().tasks_held_at_once(), 1);
6703        let queue = RuntimeManifest::from_yaml_str(
6704            "name: cap\nversion: 0.0.1\nlifecycle:\n  task_acceptance: queue\n",
6705        )
6706        .unwrap()
6707        .effective_lifecycle();
6708        assert_eq!(queue.queue_depth, 1);
6709        assert_eq!(queue.tasks_held_at_once(), 2);
6710    }
6711
6712    /// No `trace:` block at all leaves the config absent; the runtime substitutes
6713    /// [`TraceCapture::default`] rather than the manifest carrying a synthesized one.
6714    #[test]
6715    fn absent_trace_block_stays_absent_and_defaults_to_meta() {
6716        let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
6717        assert_eq!(manifest.trace, None);
6718        assert_eq!(TraceCapture::default(), TraceCapture::Meta);
6719    }
6720
6721    #[test]
6722    fn each_capture_mode_parses_from_the_trace_block() {
6723        for (yaml, expected) in [
6724            ("none", TraceCapture::None),
6725            ("meta", TraceCapture::Meta),
6726            ("content", TraceCapture::Content),
6727        ] {
6728            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
6729                "  capture: {yaml}\n"
6730            )))
6731            .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
6732            assert_eq!(
6733                manifest.trace,
6734                Some(TraceConfig {
6735                    capture: expected,
6736                    retain: None
6737                })
6738            );
6739        }
6740    }
6741
6742    // ── retain ───────────────────────────────────────────────────────────────
6743
6744    /// A `trace:` block with only `capture` and a `context:` block with only `record` declare no
6745    /// retention at all: an absent `retain:` is the only way to say "keep everything", and it is
6746    /// what a capsule that never mentions retention gets.
6747    #[test]
6748    fn no_retain_block_anywhere_parses_as_no_policy() {
6749        let manifest = RuntimeManifest::from_yaml_str(
6750            "name: cap\nversion: 0.0.1\ntrace:\n  capture: content\ncontext:\n  record: on\n",
6751        )
6752        .unwrap();
6753        assert_eq!(manifest.trace.unwrap().retain, None);
6754        assert_eq!(manifest.context.unwrap().retain, None);
6755    }
6756
6757    #[test]
6758    fn trace_retain_parses_both_keys_and_the_day_suffix() {
6759        let manifest = RuntimeManifest::from_yaml_str(
6760            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 50\n    max_age: 14d\n",
6761        )
6762        .unwrap();
6763        assert_eq!(
6764            manifest.trace.unwrap().retain,
6765            Some(TraceRetainConfig {
6766                max_sessions: Some(50),
6767                max_age_secs: Some(14 * 86_400),
6768            })
6769        );
6770    }
6771
6772    #[test]
6773    fn context_retain_parses_both_keys_and_either_alone() {
6774        let both = RuntimeManifest::from_yaml_str(
6775            "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n    max_messages: 2000\n    max_age: 90d\n",
6776        )
6777        .unwrap();
6778        assert_eq!(
6779            both.context.unwrap().retain,
6780            Some(ContextRetainConfig {
6781                max_messages: Some(2000),
6782                max_age_secs: Some(90 * 86_400),
6783            })
6784        );
6785
6786        let count_only = RuntimeManifest::from_yaml_str(
6787            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 10\n",
6788        )
6789        .unwrap();
6790        assert_eq!(
6791            count_only.context.unwrap().retain,
6792            Some(ContextRetainConfig {
6793                max_messages: Some(10),
6794                max_age_secs: None,
6795            })
6796        );
6797    }
6798
6799    /// An empty block is not a way to declare no policy — omitting the block is. Both spellings
6800    /// of empty are refused, naming the block an operator has to go and change.
6801    #[test]
6802    fn an_empty_retain_block_is_refused_naming_the_block() {
6803        for (yaml, expect_trace) in [
6804            (
6805                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain: {}\n",
6806                true,
6807            ),
6808            (
6809                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n",
6810                true,
6811            ),
6812            (
6813                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain: {}\n",
6814                false,
6815            ),
6816            (
6817                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n",
6818                false,
6819            ),
6820        ] {
6821            let err = RuntimeManifest::from_yaml_str(yaml)
6822                .expect_err("an empty retain block must be refused");
6823            let (field, message) = match (&err, expect_trace) {
6824                (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
6825                    (field, message)
6826                }
6827                (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
6828                    (field, message)
6829                }
6830                _ => panic!("wrong variant for {yaml:?}: {err:?}"),
6831            };
6832            assert_eq!(
6833                field,
6834                if expect_trace {
6835                    "trace.retain"
6836                } else {
6837                    "context.retain"
6838                }
6839            );
6840            assert!(message.contains("at least one key"), "{message}");
6841        }
6842    }
6843
6844    /// A limit of zero would delete what retention exists to keep, so it is
6845    /// refused naming the key rather than accepted as "keep nothing".
6846    #[test]
6847    fn a_zero_retain_limit_is_refused_naming_the_key() {
6848        let trace = RuntimeManifest::from_yaml_str(
6849            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 0\n",
6850        )
6851        .expect_err("max_sessions: 0 must be refused");
6852        assert!(
6853            matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
6854                     if field == "trace.retain.max_sessions"),
6855            "{trace:?}"
6856        );
6857
6858        let context = RuntimeManifest::from_yaml_str(
6859            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 0\n",
6860        )
6861        .expect_err("max_messages: 0 must be refused");
6862        assert!(
6863            matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
6864                     if field == "context.retain.max_messages"),
6865            "{context:?}"
6866        );
6867
6868        let age = RuntimeManifest::from_yaml_str(
6869            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_age: 0\n",
6870        )
6871        .expect_err("max_age: 0 must be refused");
6872        assert!(
6873            matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
6874                     if field == "trace.retain.max_age"),
6875            "{age:?}"
6876        );
6877    }
6878
6879    #[test]
6880    fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
6881        let err = RuntimeManifest::from_yaml_str(
6882            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_traces: 3\n",
6883        )
6884        .expect_err("an unknown key must be refused");
6885        match err {
6886            RuntimeManifestError::InvalidTraceConfig { field, message } => {
6887                assert_eq!(field, "trace.retain");
6888                assert!(message.contains("max_traces"), "{message}");
6889                assert!(message.contains("max_sessions"), "{message}");
6890            }
6891            other => panic!("expected InvalidTraceConfig, got {other:?}"),
6892        }
6893    }
6894
6895    #[test]
6896    fn an_unparseable_retain_duration_reports_the_accepted_form() {
6897        let err = RuntimeManifest::from_yaml_str(
6898            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_age: 5 weeks\n",
6899        )
6900        .expect_err("'5 weeks' must be refused");
6901        match err {
6902            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
6903                assert_eq!(field, "context.retain.max_age");
6904                assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
6905            }
6906            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
6907        }
6908    }
6909
6910    #[test]
6911    fn unparseable_capture_value_names_the_field_and_accepted_values() {
6912        let err = RuntimeManifest::from_yaml_str(&manifest_with_trace("  capture: verbose\n"))
6913            .expect_err("'verbose' is not a capture mode");
6914        let rendered = err.to_string();
6915        assert!(rendered.contains("trace.capture"), "{rendered}");
6916        for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
6917            assert!(rendered.contains(accepted), "{rendered}");
6918        }
6919    }
6920
6921    #[test]
6922    fn resources_block_is_optional_and_absent_stays_absent() {
6923        let manifest = RuntimeManifest::from_yaml_str(
6924            r#"
6925name: cap
6926version: 0.0.1
6927capabilities:
6928  shell:
6929    allow: [bash]
6930"#,
6931        )
6932        .unwrap();
6933
6934        // `None` here is "the manifest said nothing", not "unlimited" — the runtime substitutes
6935        // its own defaults (see `capsule_runtime::resources::HostResourceLimits::resolve`), and
6936        // keeping the two states distinct is what lets it own them in one place.
6937        let caps = manifest.capabilities.expect("shell block must parse");
6938        assert_eq!(caps.resources, None);
6939    }
6940
6941    #[test]
6942    fn resources_fields_parse_and_each_stays_independently_optional() {
6943        let manifest = RuntimeManifest::from_yaml_str(
6944            r#"
6945name: cap
6946version: 0.0.1
6947capabilities:
6948  resources:
6949    max_processes: 32
6950    max_open_files: 16
6951    max_file_size_bytes: 1048576
6952    cpu_seconds: 30
6953    memory_bytes: 268435456
6954    cgroup_memory_bytes: 536870912
6955    cgroup_pids_max: 64
6956    cgroup_cpu_percent: 50
6957    cgroup_io_bytes_per_sec: 1048576
6958    workdir_max_bytes: 2097152
6959"#,
6960        )
6961        .unwrap();
6962
6963        let resources = manifest
6964            .capabilities
6965            .and_then(|caps| caps.resources)
6966            .expect("resources block must parse");
6967        assert_eq!(resources.max_processes, Some(32));
6968        assert_eq!(resources.max_open_files, Some(16));
6969        assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
6970        assert_eq!(resources.cpu_seconds, Some(30));
6971        assert_eq!(resources.memory_bytes, Some(268_435_456));
6972        assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
6973        assert_eq!(resources.cgroup_pids_max, Some(64));
6974        assert_eq!(resources.cgroup_cpu_percent, Some(50));
6975        assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
6976        assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
6977    }
6978
6979    #[test]
6980    fn state_block_is_optional_and_absent_stays_absent() {
6981        let manifest = RuntimeManifest::from_yaml_str(
6982            r#"
6983name: cap
6984version: 0.0.1
6985artifacts:
6986  - name: local/notes-tool
6987    version: 1.0.0
6988    runtime: tool
6989    capabilities:
6990      filesystem:
6991        scope: cache
6992"#,
6993        )
6994        .unwrap();
6995
6996        // Absent is deny: no store name to default, and nothing downstream creates a directory.
6997        let caps = manifest.artifacts[0]
6998            .capabilities
6999            .as_ref()
7000            .expect("filesystem block must parse");
7001        assert_eq!(caps.state, None);
7002    }
7003
7004    /// A declared-but-empty `state:` block is a real grant — it means "give me a store, named
7005    /// after the capsule" — so it must lower to `Some(..)` with no name, distinguishable from
7006    /// the absent block above.
7007    #[test]
7008    fn state_block_declared_empty_grants_the_capsule_named_store() {
7009        let manifest = RuntimeManifest::from_yaml_str(
7010            r#"
7011name: cap
7012version: 0.0.1
7013artifacts:
7014  - name: local/notes-tool
7015    version: 1.0.0
7016    runtime: tool
7017    capabilities:
7018      state: {}
7019"#,
7020        )
7021        .unwrap();
7022
7023        let state = manifest.artifacts[0]
7024            .capabilities
7025            .as_ref()
7026            .and_then(|caps| caps.state.as_ref())
7027            .expect("state block must parse");
7028        assert_eq!(state.store, None);
7029    }
7030
7031    #[test]
7032    fn state_store_name_parses_and_stays_independently_optional() {
7033        let manifest = RuntimeManifest::from_yaml_str(
7034            r#"
7035name: cap
7036version: 0.0.1
7037artifacts:
7038  - name: local/notes-tool
7039    version: 1.0.0
7040    runtime: tool
7041    capabilities:
7042      state:
7043        store: "  shey  "
7044      network:
7045        allow: [https://api.example.com]
7046"#,
7047        )
7048        .unwrap();
7049
7050        let caps = manifest.artifacts[0]
7051            .capabilities
7052            .as_ref()
7053            .expect("capabilities must parse");
7054        // Trimmed, but otherwise verbatim: whether the name is a usable directory segment is
7055        // decided by the runtime (`E-CAP-009`), not here.
7056        assert_eq!(
7057            caps.state.as_ref().and_then(|state| state.store.as_deref()),
7058            Some("shey")
7059        );
7060        // Declaring a store never touches any sibling sub-block.
7061        assert_eq!(
7062            caps.network
7063                .as_ref()
7064                .map(|network| network.allow.as_slice()),
7065            Some(["https://api.example.com".to_string()].as_slice())
7066        );
7067        assert_eq!(caps.filesystem, None);
7068    }
7069
7070    /// An explicitly empty name is kept rather than normalised to "default to the capsule name":
7071    /// the operator wrote a name, and handing them a different store than the one they wrote
7072    /// would be worse than the refusal the runtime raises for it.
7073    #[test]
7074    fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
7075        let manifest = RuntimeManifest::from_yaml_str(
7076            r#"
7077name: cap
7078version: 0.0.1
7079artifacts:
7080  - name: local/notes-tool
7081    version: 1.0.0
7082    runtime: tool
7083    capabilities:
7084      state:
7085        store: ""
7086"#,
7087        )
7088        .unwrap();
7089
7090        assert_eq!(
7091            manifest.artifacts[0]
7092                .capabilities
7093                .as_ref()
7094                .and_then(|caps| caps.state.as_ref())
7095                .and_then(|state| state.store.as_deref()),
7096            Some("")
7097        );
7098    }
7099
7100    #[test]
7101    fn resources_partial_block_leaves_undeclared_fields_none() {
7102        let manifest = RuntimeManifest::from_yaml_str(
7103            r#"
7104name: cap
7105version: 0.0.1
7106capabilities:
7107  resources:
7108    max_open_files: 16
7109"#,
7110        )
7111        .unwrap();
7112
7113        let resources = manifest
7114            .capabilities
7115            .and_then(|caps| caps.resources)
7116            .expect("resources block must parse");
7117        assert_eq!(resources.max_open_files, Some(16));
7118        assert_eq!(resources.max_processes, None);
7119        assert_eq!(resources.workdir_max_bytes, None);
7120    }
7121
7122    /// Every field rejects `0` at parse time, naming its own path. A zero ceiling is never what
7123    /// an author means, and accepting one would turn a typo into a subprocess that cannot fork,
7124    /// open a file, or run for a single CPU-second — discovered at runtime instead of at parse.
7125    #[test]
7126    fn resources_zero_is_rejected_on_every_field() {
7127        for field in [
7128            "max_processes",
7129            "max_open_files",
7130            "max_file_size_bytes",
7131            "cpu_seconds",
7132            "memory_bytes",
7133            "cgroup_memory_bytes",
7134            "cgroup_pids_max",
7135            "cgroup_cpu_percent",
7136            "cgroup_io_bytes_per_sec",
7137            "workdir_max_bytes",
7138        ] {
7139            let yaml =
7140                format!("name: cap\nversion: 0.0.1\ncapabilities:\n  resources:\n    {field}: 0\n");
7141            let error =
7142                RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
7143
7144            match error {
7145                RuntimeManifestError::InvalidCapabilities {
7146                    field: reported,
7147                    message,
7148                } => {
7149                    assert_eq!(reported, format!("capabilities.resources.{field}"));
7150                    assert_eq!(message, "must be greater than zero");
7151                }
7152                other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
7153            }
7154        }
7155    }
7156
7157    #[test]
7158    fn artifact_runtime_defaults_to_tool() {
7159        let manifest = RuntimeManifest::from_yaml_str(
7160            r#"
7161name: cap
7162version: 0.0.1
7163artifacts:
7164  - name: local/echo
7165    version: 1.2.3
7166"#,
7167        )
7168        .unwrap();
7169
7170        assert_eq!(manifest.artifacts.len(), 1);
7171        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7172    }
7173
7174    #[test]
7175    fn artifact_runtime_tool_explicit() {
7176        let manifest = RuntimeManifest::from_yaml_str(
7177            r#"
7178name: cap
7179version: 0.0.1
7180artifacts:
7181  - name: local/echo
7182    version: 1.2.3
7183    runtime: tool
7184"#,
7185        )
7186        .unwrap();
7187
7188        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7189    }
7190
7191    #[test]
7192    fn artifact_runtime_wasm_is_error() {
7193        let err = RuntimeManifest::from_yaml_str(
7194            r#"
7195name: cap
7196version: 0.0.1
7197artifacts:
7198  - name: local/echo
7199    version: 1.2.3
7200    runtime: wasm
7201"#,
7202        )
7203        .unwrap_err();
7204
7205        let msg = err.to_string();
7206        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7207        assert!(msg.contains("wasm"), "error was: {msg}");
7208    }
7209
7210    #[test]
7211    fn artifact_runtime_native_is_error() {
7212        let err = RuntimeManifest::from_yaml_str(
7213            r#"
7214name: cap
7215version: 0.0.1
7216artifacts:
7217  - name: local/echo
7218    version: 1.2.3
7219    runtime: native
7220"#,
7221        )
7222        .unwrap_err();
7223
7224        let msg = err.to_string();
7225        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7226        assert!(msg.contains("native"), "error was: {msg}");
7227    }
7228
7229    #[test]
7230    fn invalid_artifact_reports_index() {
7231        let err = RuntimeManifest::from_yaml_str(
7232            r#"
7233name: cap
7234version: 0.0.1
7235artifacts:
7236  - name: ""
7237    version: 1.2.3
7238"#,
7239        )
7240        .unwrap_err();
7241
7242        let msg = err.to_string();
7243        assert!(msg.contains("index 0"));
7244        assert!(msg.contains("missing required field 'name'"));
7245    }
7246
7247    #[test]
7248    fn capabilities_parse_when_all_fields_present() {
7249        let manifest = RuntimeManifest::from_yaml_str(
7250            r#"
7251name: cap
7252version: 0.0.1
7253artifacts:
7254  - name: local/echo
7255    version: 1.2.3
7256capabilities:
7257  network:
7258    allow:
7259      - https://api.anthropic.com
7260      - http://127.0.0.1:8080
7261  filesystem:
7262    scope: ./sandbox
7263"#,
7264        )
7265        .unwrap();
7266
7267        let capabilities = manifest.capabilities.expect("capabilities should exist");
7268        assert_eq!(
7269            capabilities.network.unwrap().allow,
7270            vec![
7271                "https://api.anthropic.com".to_string(),
7272                "http://127.0.0.1:8080".to_string()
7273            ]
7274        );
7275        assert_eq!(
7276            capabilities.filesystem.unwrap().scope,
7277            Some("./sandbox".to_string())
7278        );
7279    }
7280
7281    #[test]
7282    fn env_capabilities_parse_allowlist() {
7283        let manifest = RuntimeManifest::from_yaml_str(
7284            r#"
7285name: cap
7286version: 0.0.1
7287artifacts:
7288  - name: local/echo
7289    version: 1.2.3
7290capabilities:
7291  env:
7292    allow:
7293      - MY_APP_REGION
7294      - GITHUB_TOKEN
7295"#,
7296        )
7297        .unwrap();
7298
7299        let capabilities = manifest.capabilities.expect("capabilities should exist");
7300        assert_eq!(
7301            capabilities.env.unwrap().allow,
7302            vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
7303        );
7304    }
7305
7306    #[test]
7307    fn env_capabilities_absent_when_block_omitted() {
7308        let manifest = RuntimeManifest::from_yaml_str(
7309            r#"
7310name: cap
7311version: 0.0.1
7312artifacts:
7313  - name: local/echo
7314    version: 1.2.3
7315capabilities:
7316  network:
7317    allow:
7318      - https://api.anthropic.com
7319"#,
7320        )
7321        .unwrap();
7322
7323        assert!(manifest.capabilities.unwrap().env.is_none());
7324    }
7325
7326    /// Unlike `capabilities.shell.allow`, an empty `env.allow` is a legitimate no-op rather
7327    /// than a manifest error.
7328    #[test]
7329    fn env_capabilities_allow_empty_list_is_accepted() {
7330        let manifest = RuntimeManifest::from_yaml_str(
7331            r#"
7332name: cap
7333version: 0.0.1
7334artifacts:
7335  - name: local/echo
7336    version: 1.2.3
7337capabilities:
7338  env: {}
7339"#,
7340        )
7341        .unwrap();
7342
7343        assert_eq!(
7344            manifest.capabilities.unwrap().env,
7345            Some(EnvCapabilities { allow: vec![] })
7346        );
7347    }
7348
7349    #[test]
7350    fn missing_capabilities_block_is_backward_compatible() {
7351        let manifest = RuntimeManifest::from_yaml_str(
7352            r#"
7353name: cap
7354version: 0.0.1
7355artifacts:
7356  - name: local/echo
7357    version: 1.2.3
7358"#,
7359        )
7360        .unwrap();
7361
7362        assert!(manifest.capabilities.is_none());
7363    }
7364
7365    #[test]
7366    fn network_only_capabilities_parse() {
7367        let manifest = RuntimeManifest::from_yaml_str(
7368            r#"
7369name: cap
7370version: 0.0.1
7371artifacts:
7372  - name: local/echo
7373    version: 1.2.3
7374capabilities:
7375  network:
7376    allow:
7377      - https://allowed.example.com
7378"#,
7379        )
7380        .unwrap();
7381
7382        let capabilities = manifest.capabilities.unwrap();
7383        assert_eq!(
7384            capabilities.network.unwrap().allow,
7385            vec!["https://allowed.example.com".to_string()]
7386        );
7387        assert!(capabilities.filesystem.is_none());
7388    }
7389
7390    #[test]
7391    fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
7392        let manifest = RuntimeManifest::from_yaml_str(
7393            r#"
7394name: cap
7395version: 0.0.1
7396artifacts:
7397  - name: local/echo
7398    version: 1.2.3
7399capabilities:
7400  network: {}
7401"#,
7402        )
7403        .unwrap();
7404
7405        let capabilities = manifest.capabilities.unwrap();
7406        assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
7407    }
7408
7409    /// `unix_sockets` is optional and denies by default. A capsule that never heard of the key
7410    /// must not silently get `AF_UNIX` sockets — that is the whole `/var/run/docker.sock` escape.
7411    #[test]
7412    fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
7413        let manifest = RuntimeManifest::from_yaml_str(
7414            r#"
7415name: cap
7416version: 0.0.1
7417artifacts:
7418  - name: local/echo
7419    version: 1.2.3
7420capabilities:
7421  network:
7422    allow:
7423      - https://api.anthropic.com
7424"#,
7425        )
7426        .unwrap();
7427
7428        let network = manifest.capabilities.unwrap().network.unwrap();
7429        assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
7430        assert!(!network.unix_sockets);
7431    }
7432
7433    #[test]
7434    fn network_unix_sockets_parses_explicit_false() {
7435        let manifest = RuntimeManifest::from_yaml_str(
7436            r#"
7437name: cap
7438version: 0.0.1
7439artifacts:
7440  - name: local/echo
7441    version: 1.2.3
7442capabilities:
7443  network:
7444    unix_sockets: false
7445"#,
7446        )
7447        .unwrap();
7448
7449        assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
7450    }
7451
7452    #[test]
7453    fn network_unix_sockets_parses_explicit_true() {
7454        let manifest = RuntimeManifest::from_yaml_str(
7455            r#"
7456name: cap
7457version: 0.0.1
7458artifacts:
7459  - name: local/echo
7460    version: 1.2.3
7461capabilities:
7462  network:
7463    unix_sockets: true
7464"#,
7465        )
7466        .unwrap();
7467
7468        let network = manifest.capabilities.unwrap().network.unwrap();
7469        assert!(network.unix_sockets);
7470        // The opt-in is orthogonal to the IP allowlist — declaring it does not imply any host.
7471        assert_eq!(network.allow, Vec::<String>::new());
7472    }
7473
7474    /// `read_only` is normalised on exactly the terms `scope` is — trimmed, with an entry left
7475    /// empty by that trim dropped rather than carried as a rule covering the whole workdir — and
7476    /// path shape is deliberately not judged here.
7477    #[test]
7478    fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
7479        let manifest = RuntimeManifest::from_yaml_str(
7480            r#"
7481name: cap
7482version: 0.0.1
7483capabilities:
7484  filesystem:
7485    read_only:
7486      - tests
7487      - "  bench/fixtures  "
7488      - ""
7489      - "   "
7490"#,
7491        )
7492        .unwrap();
7493
7494        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7495        assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
7496    }
7497
7498    /// An absent key and an empty list are one declaration: nothing is protected.
7499    #[test]
7500    fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
7501        let manifest = RuntimeManifest::from_yaml_str(
7502            r#"
7503name: cap
7504version: 0.0.1
7505capabilities:
7506  filesystem:
7507    scope: workdir
7508"#,
7509        )
7510        .unwrap();
7511
7512        assert!(manifest
7513            .capabilities
7514            .unwrap()
7515            .filesystem
7516            .unwrap()
7517            .read_only
7518            .is_empty());
7519    }
7520
7521    /// Path shape is the runtime's refusal, not the parser's: an absolute or escaping entry parses
7522    /// here exactly as an absolute `scope` does, and fails at launch with `E-CAP-012`.
7523    #[test]
7524    fn filesystem_read_only_does_not_judge_path_shape() {
7525        let manifest = RuntimeManifest::from_yaml_str(
7526            r#"
7527name: cap
7528version: 0.0.1
7529capabilities:
7530  filesystem:
7531    read_only:
7532      - /etc
7533      - ../outside
7534"#,
7535        )
7536        .expect("shape is judged by the runtime, not the parser");
7537
7538        assert_eq!(
7539            manifest.capabilities.unwrap().filesystem.unwrap().read_only,
7540            vec!["/etc", "../outside"]
7541        );
7542    }
7543
7544    /// The default every pre-existing manifest gets: a `filesystem:` block that has never heard of
7545    /// `workdir_exec` must not silently keep the workdir's `Execute` right, because that right is
7546    /// exactly what makes `capabilities.shell.allow` bypassable from inside the workdir.
7547    #[test]
7548    fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
7549        let manifest = RuntimeManifest::from_yaml_str(
7550            r#"
7551name: cap
7552version: 0.0.1
7553artifacts:
7554  - name: local/echo
7555    version: 1.2.3
7556capabilities:
7557  filesystem:
7558    scope: workdir
7559"#,
7560        )
7561        .unwrap();
7562
7563        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7564        assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
7565        assert!(!filesystem.workdir_exec);
7566    }
7567
7568    /// The other absent case: no `filesystem:` block at all. There is nothing to default here —
7569    /// the whole sub-block stays `None` — but a capsule with no filesystem declaration must still
7570    /// read as "workdir exec denied" downstream, which `capability_policy_from_runtime_manifest`
7571    /// covers with its own test.
7572    #[test]
7573    fn filesystem_block_stays_absent_when_undeclared() {
7574        let manifest = RuntimeManifest::from_yaml_str(
7575            r#"
7576name: cap
7577version: 0.0.1
7578artifacts:
7579  - name: local/echo
7580    version: 1.2.3
7581capabilities:
7582  network:
7583    allow:
7584      - https://api.anthropic.com
7585"#,
7586        )
7587        .unwrap();
7588
7589        assert!(manifest.capabilities.unwrap().filesystem.is_none());
7590    }
7591
7592    #[test]
7593    fn filesystem_workdir_exec_parses_explicit_false() {
7594        let manifest = RuntimeManifest::from_yaml_str(
7595            r#"
7596name: cap
7597version: 0.0.1
7598artifacts:
7599  - name: local/echo
7600    version: 1.2.3
7601capabilities:
7602  filesystem:
7603    workdir_exec: false
7604"#,
7605        )
7606        .unwrap();
7607
7608        assert!(
7609            !manifest
7610                .capabilities
7611                .unwrap()
7612                .filesystem
7613                .unwrap()
7614                .workdir_exec
7615        );
7616    }
7617
7618    #[test]
7619    fn filesystem_workdir_exec_parses_explicit_true() {
7620        let manifest = RuntimeManifest::from_yaml_str(
7621            r#"
7622name: cap
7623version: 0.0.1
7624artifacts:
7625  - name: local/echo
7626    version: 1.2.3
7627capabilities:
7628  filesystem:
7629    workdir_exec: true
7630"#,
7631        )
7632        .unwrap();
7633
7634        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7635        assert!(filesystem.workdir_exec);
7636        // Orthogonal to `scope`, exactly as `unix_sockets` is orthogonal to `network.allow`:
7637        // declaring one says nothing about the other.
7638        assert_eq!(filesystem.scope, None);
7639    }
7640
7641    #[test]
7642    fn network_internal_port_parses() {
7643        let manifest = RuntimeManifest::from_yaml_str(
7644            r#"
7645name: worker
7646version: 0.1.0
7647artifacts: []
7648
7649network:
7650  internal_port: 58172
7651"#,
7652        )
7653        .unwrap();
7654
7655        assert_eq!(
7656            manifest.network.unwrap().internal_port,
7657            Some(58172),
7658            "network.internal_port should parse to Some(58172)"
7659        );
7660    }
7661
7662    #[test]
7663    fn network_internal_port_absent_when_omitted() {
7664        let manifest = RuntimeManifest::from_yaml_str(
7665            r#"
7666name: worker
7667version: 0.1.0
7668artifacts: []
7669"#,
7670        )
7671        .unwrap();
7672
7673        assert!(manifest.network.is_none());
7674    }
7675
7676    #[test]
7677    fn shell_capabilities_parse_with_allow_and_env_fields() {
7678        let manifest = RuntimeManifest::from_yaml_str(
7679            r#"
7680name: cap
7681version: 0.0.1
7682artifacts: []
7683capabilities:
7684  shell:
7685    allow:
7686      - bash
7687      - git
7688    strip_env:
7689      - "*_TOKEN"
7690      - SECRET_KEY
7691    baseline_env:
7692      - SSH_AUTH_SOCK
7693"#,
7694        )
7695        .unwrap();
7696
7697        let shell = manifest
7698            .capabilities
7699            .unwrap()
7700            .shell
7701            .expect("shell capabilities should be present");
7702        assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
7703        assert_eq!(
7704            shell.strip_env,
7705            Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
7706        );
7707        assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
7708    }
7709
7710    #[test]
7711    fn shell_capabilities_require_non_empty_allowlist() {
7712        let err = RuntimeManifest::from_yaml_str(
7713            r#"
7714name: cap
7715version: 0.0.1
7716artifacts: []
7717capabilities:
7718  shell:
7719    allow: []
7720"#,
7721        )
7722        .unwrap_err();
7723
7724        let msg = err.to_string();
7725        assert!(msg.contains("capabilities.shell.allow"));
7726        assert!(msg.contains("at least one"));
7727    }
7728
7729    fn shell_allow_error(entry: &str) -> String {
7730        let yaml = format!(
7731            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  shell:\n    allow: [bash, {}]\n",
7732            serde_json::to_string(entry).unwrap()
7733        );
7734        RuntimeManifest::from_yaml_str(&yaml)
7735            .expect_err(&format!("{entry:?} must be refused"))
7736            .to_string()
7737    }
7738
7739    #[test]
7740    fn shell_allow_refuses_an_entry_that_is_not_a_bare_name() {
7741        for (entry, reason) in [
7742            ("/tmp/uv-tool/bin/uv", "is a path"),
7743            ("bin/uv", "is a path"),
7744            ("..", "names a directory"),
7745            (".", "names a directory"),
7746            ("", "is empty"),
7747            ("a b", "contains whitespace"),
7748            ("uv\t", "contains whitespace"),
7749            ("uv\0", "contains a NUL byte"),
7750        ] {
7751            let message = shell_allow_error(entry);
7752            assert!(
7753                message.contains("'capabilities.shell.allow[1]'"),
7754                "{entry:?}: {message}"
7755            );
7756            assert!(
7757                message.contains(&format!(
7758                    "'{}' {reason}; an entry is a bare binary name",
7759                    entry.escape_debug()
7760                )),
7761                "{entry:?}: {message}"
7762            );
7763        }
7764    }
7765
7766    #[test]
7767    fn shell_allow_accepts_bare_names() {
7768        let manifest = RuntimeManifest::from_yaml_str(
7769            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  shell:\n    allow: [bash, python3.12, git-lfs, \"c++\"]\n",
7770        )
7771        .unwrap();
7772        assert_eq!(
7773            manifest.capabilities.unwrap().shell.unwrap().allow,
7774            ["bash", "python3.12", "git-lfs", "c++"]
7775        );
7776    }
7777
7778    #[test]
7779    fn interpreter_runtime_parses_accepted_shape() {
7780        let manifest = RuntimeManifest::from_yaml_str(
7781            r#"
7782name: cap
7783version: 0.0.1
7784artifacts: []
7785capabilities:
7786  shell:
7787    allow:
7788      - python3
7789    interpreter_runtime:
7790      - binary: python3
7791        dirs:
7792          - path: /usr/lib/python3.11
7793            list_dir: true
7794          - path: /usr/lib/python3.11/lib-dynload
7795            list_dir: false
7796"#,
7797        )
7798        .unwrap();
7799
7800        let shell = manifest.capabilities.unwrap().shell.unwrap();
7801        assert_eq!(
7802            shell.interpreter_runtime,
7803            vec![InterpreterRuntimeGrant {
7804                binary: "python3".to_string(),
7805                dirs: vec![
7806                    InterpreterRuntimeDir {
7807                        path: "/usr/lib/python3.11".to_string(),
7808                        list_dir: true,
7809                    },
7810                    InterpreterRuntimeDir {
7811                        path: "/usr/lib/python3.11/lib-dynload".to_string(),
7812                        list_dir: false,
7813                    },
7814                ],
7815            }]
7816        );
7817    }
7818
7819    #[test]
7820    fn interpreter_runtime_defaults_to_empty_when_absent() {
7821        let manifest = RuntimeManifest::from_yaml_str(
7822            r#"
7823name: cap
7824version: 0.0.1
7825artifacts: []
7826capabilities:
7827  shell:
7828    allow:
7829      - python3
7830"#,
7831        )
7832        .unwrap();
7833
7834        let shell = manifest.capabilities.unwrap().shell.unwrap();
7835        assert!(shell.interpreter_runtime.is_empty());
7836    }
7837
7838    /// Shared helper: parse a manifest whose sole `interpreter_runtime` grant is `grant_yaml`
7839    /// (indented to sit under `interpreter_runtime:`), returning the error message.
7840    fn interpreter_runtime_reject(grant_yaml: &str) -> String {
7841        let yaml = format!(
7842            r#"
7843name: cap
7844version: 0.0.1
7845artifacts: []
7846capabilities:
7847  shell:
7848    allow:
7849      - python3
7850    interpreter_runtime:
7851{grant_yaml}
7852"#,
7853        );
7854        RuntimeManifest::from_yaml_str(&yaml)
7855            .expect_err("grant should be rejected")
7856            .to_string()
7857    }
7858
7859    #[test]
7860    fn interpreter_runtime_rejects_binary_not_in_allow() {
7861        let msg = interpreter_runtime_reject(
7862            "      - binary: ruby\n        dirs:\n          - path: /usr/lib/ruby\n            list_dir: true\n",
7863        );
7864        assert!(
7865            msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
7866            "{msg}"
7867        );
7868        assert!(msg.contains("ruby"), "{msg}");
7869        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
7870    }
7871
7872    #[test]
7873    fn interpreter_runtime_rejects_relative_path() {
7874        let msg = interpreter_runtime_reject(
7875            "      - binary: python3\n        dirs:\n          - path: usr/lib/python3.11\n            list_dir: true\n",
7876        );
7877        assert!(
7878            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
7879            "{msg}"
7880        );
7881        assert!(msg.contains("absolute"), "{msg}");
7882    }
7883
7884    // ── capabilities.plan (capsule-wide) ─────────────────────────────────────
7885
7886    /// A capsule manifest whose capsule-wide capabilities carry `plan: <block>`.
7887    fn manifest_with_plan(block: &str) -> String {
7888        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n{block}")
7889    }
7890
7891    #[test]
7892    fn plan_submit_true_grants_the_capsule() {
7893        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n"))
7894            .expect("a plan block parses");
7895        assert_eq!(
7896            manifest.capabilities.unwrap().plan,
7897            Some(PlanCapabilities { submit: true })
7898        );
7899    }
7900
7901    /// `submit: false` and an absent `plan:` block both leave the capsule ungranted; the
7902    /// difference between them is only whether the operator wrote the denial down.
7903    #[test]
7904    fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
7905        let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: false\n"))
7906            .expect("an explicit denial parses");
7907        assert_eq!(
7908            explicit.capabilities.unwrap().plan,
7909            Some(PlanCapabilities { submit: false })
7910        );
7911
7912        let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
7913            .expect("no capabilities block parses");
7914        assert!(absent.capabilities.is_none());
7915    }
7916
7917    /// The capability is never inferred: a `plan:` block that omits `submit:` is a parse error
7918    /// naming the key, exactly as `task_io:` without `read:` is.
7919    #[test]
7920    fn plan_without_submit_is_rejected() {
7921        let err = RuntimeManifest::from_yaml_str(&manifest_with_plan("    {}\n"))
7922            .expect_err("an empty plan block is refused");
7923        let msg = err.to_string();
7924        assert!(msg.contains("capabilities.plan.submit"), "{msg}");
7925        assert!(msg.contains("never"), "{msg}");
7926    }
7927
7928    /// An unknown key inside the block is reported by path, like every other capability block.
7929    #[test]
7930    fn an_unknown_plan_key_is_reported_by_path() {
7931        let manifest =
7932            RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n    depth: 3\n"))
7933                .expect("an unknown key is a warning, not a parse error");
7934        assert!(
7935            manifest
7936                .unknown_keys
7937                .iter()
7938                .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
7939            "{:?}",
7940            manifest.unknown_keys
7941        );
7942    }
7943
7944    // ── capabilities.install (capsule-wide) ──────────────────────────────────
7945
7946    /// A capsule manifest whose capsule-wide capabilities carry `install: <block>`.
7947    fn manifest_with_install(block: &str) -> String {
7948        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  install:\n{block}")
7949    }
7950
7951    #[test]
7952    fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
7953        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
7954            "    skill: [ana/code-review, \"ana/style-*\"]\n    tool: [\"local/b-*\", local/a, \"murmur/*\", \"*\"]\n",
7955        ))
7956        .expect("an install block parses");
7957        assert_eq!(
7958            manifest.capabilities.unwrap().install,
7959            Some(InstallCapabilities {
7960                skill: vec!["ana/code-review".into(), "ana/style-*".into()],
7961                tool: vec![
7962                    "local/b-*".into(),
7963                    "local/a".into(),
7964                    "murmur/*".into(),
7965                    "*".into()
7966                ],
7967                from: InstallFrom::Stores,
7968            })
7969        );
7970    }
7971
7972    /// Either list alone is a grant; the other defaults to empty, which admits nothing of its
7973    /// kind.
7974    #[test]
7975    fn capabilities_install_accepts_either_list_alone() {
7976        let skill_only =
7977            RuntimeManifest::from_yaml_str(&manifest_with_install("    skill: [local/a]\n"))
7978                .expect("skill alone parses");
7979        assert_eq!(
7980            skill_only.capabilities.unwrap().install,
7981            Some(InstallCapabilities {
7982                skill: vec!["local/a".into()],
7983                tool: vec![],
7984                from: InstallFrom::Stores,
7985            })
7986        );
7987        let tool_only =
7988            RuntimeManifest::from_yaml_str(&manifest_with_install("    tool: [\"local/b-*\"]\n"))
7989                .expect("tool alone parses");
7990        assert_eq!(
7991            tool_only.capabilities.unwrap().install,
7992            Some(InstallCapabilities {
7993                skill: vec![],
7994                tool: vec!["local/b-*".into()],
7995                from: InstallFrom::Stores,
7996            })
7997        );
7998    }
7999
8000    #[test]
8001    fn capabilities_install_absent_is_none() {
8002        let manifest = RuntimeManifest::from_yaml_str(
8003            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n    submit: true\n",
8004        )
8005        .expect("a capabilities block without install parses");
8006        assert_eq!(manifest.capabilities.unwrap().install, None);
8007    }
8008
8009    fn install_from(list: &str) -> Result<InstallFrom, RuntimeManifestError> {
8010        RuntimeManifest::from_yaml_str(&manifest_with_install(&format!(
8011            "    tool: [\"murmur/tool-*\"]\n    from: {list}\n"
8012        )))
8013        .map(|manifest| manifest.capabilities.unwrap().install.unwrap().from)
8014    }
8015
8016    #[test]
8017    fn install_from_absent_is_stores() {
8018        let manifest =
8019            RuntimeManifest::from_yaml_str(&manifest_with_install("    tool: [local/a]\n"))
8020                .unwrap();
8021        assert_eq!(
8022            manifest.capabilities.unwrap().install.unwrap().from,
8023            InstallFrom::Stores
8024        );
8025    }
8026
8027    #[test]
8028    fn install_from_accepts_stores_alone_and_with_sources_in_either_order() {
8029        assert_eq!(install_from("[stores]").unwrap(), InstallFrom::Stores);
8030        assert_eq!(
8031            install_from("[stores, sources]").unwrap(),
8032            InstallFrom::StoresAndSources
8033        );
8034        assert_eq!(
8035            install_from("[sources, stores]").unwrap(),
8036            InstallFrom::StoresAndSources
8037        );
8038        assert_eq!(InstallFrom::StoresAndSources.as_list(), "stores, sources");
8039        assert!(InstallFrom::StoresAndSources.sources());
8040        assert!(!InstallFrom::Stores.sources());
8041    }
8042
8043    #[test]
8044    fn install_from_refuses_empty_sourceless_duplicate_and_unknown_lists() {
8045        for (list, why) in [
8046            ("[]", "is empty"),
8047            ("[sources]", "does not name stores"),
8048            ("[stores, stores]", "names \"stores\" twice"),
8049            ("[github]", "names \"github\", which is not a place"),
8050        ] {
8051            match install_from(list) {
8052                Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8053                    assert_eq!(field, "capabilities.install.from", "{list}");
8054                    assert!(message.contains(why), "{list}: {message}");
8055                    assert!(
8056                        message.contains("expected [stores] or [stores, sources]"),
8057                        "{list}: {message}"
8058                    );
8059                }
8060                other => panic!("{list}: expected InvalidCapabilities, got {other:?}"),
8061            }
8062        }
8063    }
8064
8065    #[test]
8066    fn install_from_is_no_unknown_key() {
8067        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8068            "    tool: [local/a]\n    from: [stores]\n",
8069        ))
8070        .unwrap();
8071        assert!(
8072            manifest
8073                .unknown_keys
8074                .iter()
8075                .all(|key| key.block_path != "capabilities.install"),
8076            "{:?}",
8077            manifest.unknown_keys
8078        );
8079    }
8080
8081    #[test]
8082    fn install_from_capabilities_install_is_refused_under_transport_process() {
8083        let yaml = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  install:\n    \
8084                    tool: [\"murmur/tool-*\"]\ninference:\n  transport: process\n  \
8085                    command: claude\n  model: claude-haiku-4-5-20251001\n  driver:\n    \
8086                    artifact: murmur/driver-claude-code\n";
8087        match RuntimeManifest::from_yaml_str(yaml) {
8088            Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8089                assert_eq!(field, "capabilities.install");
8090                assert_eq!(
8091                    message,
8092                    "is not valid with transport: process; the harness owns its tool list, so \
8093                     a pulled artifact could never reach it"
8094                );
8095            }
8096            other => panic!("expected InvalidCapabilities, got {other:?}"),
8097        }
8098    }
8099
8100    /// A block that names nothing is refused rather than read as "grant nothing": omitting the
8101    /// block is how a capsule grants nothing.
8102    #[test]
8103    fn capabilities_install_that_names_nothing_is_refused() {
8104        for block in [
8105            "    {}\n",
8106            "    skill: []\n",
8107            "    skill: []\n    tool: []\n",
8108            "    driver: [x]\n",
8109        ] {
8110            let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
8111                .expect_err("an install block naming nothing is refused");
8112            match &err {
8113                RuntimeManifestError::InvalidCapabilities { field, message } => {
8114                    assert_eq!(field, "capabilities.install", "{block:?}");
8115                    assert!(message.contains("at least one"), "{message}");
8116                    assert!(message.contains("omit the block"), "{message}");
8117                }
8118                other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
8119            }
8120        }
8121    }
8122
8123    #[test]
8124    fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
8125        for entry in [
8126            "a*b", "*x", "**", "Foo", "-a", "a/b/c", " ", "*/x", "a/*x", "a/x*y", "A/x",
8127        ] {
8128            for key in ["skill", "tool"] {
8129                let block = format!("    {key}: [{entry:?}]\n");
8130                let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8131                    .expect_err("a bad entry is refused");
8132                match &err {
8133                    RuntimeManifestError::InvalidArtifactName {
8134                        field,
8135                        message,
8136                        hint,
8137                    } => {
8138                        assert_eq!(field, &format!("capabilities.install.{key}[0]"));
8139                        assert!(message.contains(&format!("{entry:?}")), "{message}");
8140                        assert!(
8141                            message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
8142                            "{message}"
8143                        );
8144                        assert_eq!(hint, &None, "{entry:?}");
8145                    }
8146                    other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8147                }
8148            }
8149        }
8150    }
8151
8152    /// An entry with no scope is refused at its index with the hint naming its scoped forms.
8153    #[test]
8154    fn capabilities_install_refuses_an_unscoped_entry_with_the_scoped_hint() {
8155        for (entry, scoped) in [
8156            ("tool-*", "local/tool-*"),
8157            ("mur*", "local/mur*"),
8158            ("tool-x", "local/tool-x"),
8159            ("murmur-tool-editor", "murmur/tool-editor"),
8160        ] {
8161            let block = format!("    tool: [\"local/ok\", {entry:?}]\n");
8162            let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8163                .expect_err("an unscoped entry is refused");
8164            match &err {
8165                RuntimeManifestError::InvalidArtifactName {
8166                    field,
8167                    message,
8168                    hint,
8169                } => {
8170                    assert_eq!(field, "capabilities.install.tool[1]");
8171                    assert_eq!(
8172                        message,
8173                        &format!(
8174                            "entry {entry:?} is invalid ('{entry}' names no scope); expected {}",
8175                            crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
8176                        )
8177                    );
8178                    let hint = hint.as_deref().expect("an unscoped entry carries a hint");
8179                    assert!(hint.contains(scoped), "{entry:?}: {hint}");
8180                }
8181                other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8182            }
8183            assert!(
8184                err.to_string().contains("'capabilities.install.tool[1]'"),
8185                "{err}"
8186            );
8187        }
8188    }
8189
8190    /// The grant is held by the session, so a per-artifact block is refused on every role.
8191    #[test]
8192    fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
8193        for runtime in ["tool", "hook", "driver", "skill"] {
8194            let yaml = format!(
8195                "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/a\n    version: 0.1.0\n    \
8196                 runtime: {runtime}\n    capabilities:\n      install:\n        skill: [x]\n"
8197            );
8198            let err = RuntimeManifest::from_yaml_str(&yaml)
8199                .expect_err("a per-artifact install block is refused");
8200            match &err {
8201                RuntimeManifestError::InvalidArtifact { index, message } => {
8202                    assert_eq!(*index, 0);
8203                    assert!(message.contains("capabilities.install"), "{message}");
8204                    assert!(message.contains("capsule-wide"), "{message}");
8205                }
8206                other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
8207            }
8208        }
8209    }
8210
8211    /// An unknown key inside the block is reported by path and grants nothing.
8212    #[test]
8213    fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
8214        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8215            "    skill: [local/a]\n    driver: [x]\n",
8216        ))
8217        .expect("an unknown key beside a real entry is a warning, not a parse error");
8218        assert!(
8219            manifest
8220                .unknown_keys
8221                .iter()
8222                .any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
8223            "{:?}",
8224            manifest.unknown_keys
8225        );
8226        assert_eq!(
8227            manifest.capabilities.unwrap().install,
8228            Some(InstallCapabilities {
8229                skill: vec!["local/a".into()],
8230                tool: vec![],
8231                from: InstallFrom::Stores,
8232            })
8233        );
8234    }
8235
8236    // ── capabilities.task_io (per-hook, honored only on runtime: hook) ────────
8237
8238    /// A capsule manifest with one artifact entry carrying `capabilities: task_io: <block>`.
8239    fn manifest_with_task_io(runtime: &str, block: &str) -> String {
8240        format!(
8241            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/gate\n    version: 1.2.3\n    \
8242             runtime: {runtime}\n    capabilities:\n      task_io:\n{block}"
8243        )
8244    }
8245
8246    #[test]
8247    fn task_io_read_true_grants_the_hook() {
8248        let manifest =
8249            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: true\n"))
8250                .expect("task_io on a hook entry parses");
8251        assert_eq!(
8252            manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
8253            Some(TaskIoCapabilities { read: true })
8254        );
8255    }
8256
8257    /// `read: false` and an absent `task_io:` block both leave the hook ungranted; the
8258    /// difference between them is only whether the operator wrote the denial down.
8259    #[test]
8260    fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
8261        let explicit =
8262            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: false\n"))
8263                .expect("an explicit denial parses");
8264        assert_eq!(
8265            explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
8266            Some(TaskIoCapabilities { read: false })
8267        );
8268
8269        let absent = RuntimeManifest::from_yaml_str(
8270            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/gate\n    version: 1.2.3\n    \
8271             runtime: hook\n",
8272        )
8273        .expect("no capabilities block parses");
8274        assert!(absent.artifacts[0].capabilities.is_none());
8275    }
8276
8277    /// The capability is never inferred: a `task_io:` block that omits `read:` is a parse
8278    /// error naming the key, following `interpreter_runtime[].dirs[].list_dir`.
8279    #[test]
8280    fn task_io_without_read_is_rejected() {
8281        let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        {}\n"))
8282            .unwrap_err();
8283        let msg = err.to_string();
8284        assert!(
8285            msg.contains("capabilities.task_io.read"),
8286            "error was: {msg}"
8287        );
8288        assert!(msg.contains("explicitly"), "error was: {msg}");
8289    }
8290
8291    /// Nothing outside a hook can be handed the import, so declaring the key there is a parse
8292    /// error rather than a silently inert grant.
8293    #[test]
8294    fn task_io_outside_a_hook_entry_is_rejected() {
8295        for runtime in ["tool", "driver", "skill"] {
8296            let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
8297                runtime,
8298                "        read: true\n",
8299            ))
8300            .unwrap_err();
8301            let msg = err.to_string();
8302            assert!(
8303                msg.contains("capabilities") && msg.contains("runtime: hook"),
8304                "{runtime} entry: error was: {msg}"
8305            );
8306        }
8307    }
8308
8309    // ── capabilities.filesystem.records (per-hook, honored only on runtime: hook) ──
8310
8311    /// A capsule manifest with one artifact entry carrying `capabilities: filesystem: <block>`.
8312    fn manifest_with_filesystem(runtime: &str, block: &str) -> String {
8313        format!(
8314            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/thing\n    version: 0.1.0\n    \
8315             runtime: {runtime}\n    capabilities:\n      filesystem:\n{block}"
8316        )
8317    }
8318
8319    /// `records: true` on a hook entry grants it; absent, it is `false`.
8320    #[test]
8321    fn filesystem_records_on_a_hook_entry_parses() {
8322        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8323            "hook",
8324            "        records: true\n",
8325        ))
8326        .expect("records on a hook entry parses");
8327        let filesystem = manifest.artifacts[0]
8328            .capabilities
8329            .as_ref()
8330            .unwrap()
8331            .filesystem
8332            .as_ref()
8333            .unwrap();
8334        assert!(filesystem.records);
8335        assert_eq!(filesystem.scope, None);
8336
8337        let scoped =
8338            RuntimeManifest::from_yaml_str(&manifest_with_filesystem("hook", "        scope: .\n"))
8339                .unwrap();
8340        let filesystem = scoped.artifacts[0]
8341            .capabilities
8342            .as_ref()
8343            .unwrap()
8344            .filesystem
8345            .as_ref()
8346            .unwrap();
8347        assert!(!filesystem.records);
8348        assert!(scoped.unknown_keys.is_empty(), "{:?}", scoped.unknown_keys);
8349    }
8350
8351    /// Beside a `scope`, on any other role and in the capsule-wide block, `records: true` is
8352    /// refused, naming the field.
8353    #[test]
8354    fn filesystem_records_is_refused_beside_a_scope_off_a_hook_and_capsule_wide() {
8355        let beside = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8356            "hook",
8357            "        records: true\n        scope: .\n",
8358        ))
8359        .unwrap_err()
8360        .to_string();
8361        assert!(
8362            beside.contains("capabilities.filesystem.records"),
8363            "{beside}"
8364        );
8365        assert!(beside.contains("capabilities.filesystem.scope"), "{beside}");
8366        for runtime in ["tool", "driver"] {
8367            let off_hook = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8368                runtime,
8369                "        records: true\n",
8370            ))
8371            .unwrap_err()
8372            .to_string();
8373            assert!(
8374                off_hook.contains("capabilities.filesystem.records"),
8375                "{off_hook}"
8376            );
8377            assert!(off_hook.contains("runtime: hook"), "{off_hook}");
8378        }
8379        let capsule_wide = RuntimeManifest::from_yaml_str(
8380            "name: cap\nversion: 0.0.1\ncapabilities:\n  filesystem:\n    records: true\n",
8381        )
8382        .unwrap_err()
8383        .to_string();
8384        assert!(
8385            capsule_wide.contains("capabilities.filesystem.records"),
8386            "{capsule_wide}"
8387        );
8388    }
8389
8390    /// The capsule-wide block reaches capsule, tool and driver components, none of which can
8391    /// receive the import — so it is rejected there too, naming the key.
8392    #[test]
8393    fn task_io_in_the_capsule_wide_block_is_rejected() {
8394        let err = RuntimeManifest::from_yaml_str(
8395            "name: cap\nversion: 0.0.1\ncapabilities:\n  task_io:\n    read: true\n",
8396        )
8397        .unwrap_err();
8398        let msg = err.to_string();
8399        assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
8400        assert!(msg.contains("runtime: hook"), "error was: {msg}");
8401    }
8402
8403    // ── capabilities.conversation (per-hook, honored only on runtime: hook) ───
8404
8405    /// A capsule manifest with one artifact entry carrying `capabilities: conversation: <block>`.
8406    fn manifest_with_conversation(runtime: &str, block: &str) -> String {
8407        format!(
8408            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/memory\n    version: 1.2.3\n    \
8409             runtime: {runtime}\n    capabilities:\n      conversation:\n{block}"
8410        )
8411    }
8412
8413    #[test]
8414    fn conversation_read_lowers_onto_the_hook_entry() {
8415        for (declared, expected) in [("true", true), ("false", false)] {
8416            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8417                "hook",
8418                &format!("        read: {declared}\n"),
8419            ))
8420            .expect("conversation on a hook entry parses");
8421            assert_eq!(
8422                manifest.artifacts[0]
8423                    .capabilities
8424                    .as_ref()
8425                    .unwrap()
8426                    .conversation,
8427                Some(ConversationCapabilities { read: expected })
8428            );
8429        }
8430    }
8431
8432    /// The capability is never inferred: a block that omits `read:` is a parse error naming the
8433    /// key, exactly as `capabilities.task_io.read` is.
8434    #[test]
8435    fn conversation_without_read_is_rejected() {
8436        let err =
8437            RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", "        {}\n"))
8438                .unwrap_err();
8439        let msg = err.to_string();
8440        assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
8441        assert!(msg.contains("explicitly"), "was: {msg}");
8442    }
8443
8444    /// Only a hook's world can import the interface, so declaring the grant on any other role is
8445    /// a parse error rather than a silently inert block.
8446    #[test]
8447    fn conversation_outside_a_hook_entry_is_rejected() {
8448        for runtime in ["tool", "driver", "skill"] {
8449            let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8450                runtime,
8451                "        read: true\n",
8452            ))
8453            .unwrap_err();
8454            let msg = err.to_string();
8455            assert!(
8456                msg.contains("capabilities") && msg.contains("runtime: hook"),
8457                "{runtime} entry: error was: {msg}"
8458            );
8459        }
8460    }
8461
8462    /// Unlike `task_io`, the capsule-wide block parses: it is inert, and the runtime says so once
8463    /// as `W-SEC-016` rather than refusing a manifest an operator can fix at leisure.
8464    #[test]
8465    fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
8466        let manifest = RuntimeManifest::from_yaml_str(
8467            "name: cap\nversion: 0.0.1\ncapabilities:\n  conversation:\n    read: true\n",
8468        )
8469        .expect("a capsule-wide block is accepted");
8470        assert_eq!(
8471            manifest.capabilities.unwrap().conversation,
8472            Some(ConversationCapabilities { read: true })
8473        );
8474    }
8475
8476    // ── context.record / context.record_store ────────────────────────────────
8477
8478    /// The record is on unless the manifest says otherwise, and `record_store` defaults to
8479    /// nothing — the runtime substitutes the capsule name.
8480    #[test]
8481    fn a_context_block_records_by_default() {
8482        let context = context_of("context:\n  max_tokens: 1000\n").unwrap();
8483        assert!(context.record);
8484        assert_eq!(context.record_store, None);
8485    }
8486
8487    #[test]
8488    fn record_off_and_on_are_the_two_accepted_values() {
8489        assert!(!context_of("context:\n  record: off\n").unwrap().record);
8490        assert!(context_of("context:\n  record: on\n").unwrap().record);
8491
8492        let err = RuntimeManifest::from_yaml_str(
8493            "name: cap\nversion: 0.0.1\ncontext:\n  record: sometimes\n",
8494        )
8495        .unwrap_err();
8496        let msg = err.to_string();
8497        assert!(msg.contains("context.record"), "was: {msg}");
8498        assert!(msg.contains("sometimes"), "was: {msg}");
8499    }
8500
8501    /// `record_store` is a name the runtime turns into a directory, so it is kept verbatim apart
8502    /// from surrounding whitespace; its *shape* is refused at launch, where the path is built.
8503    #[test]
8504    fn record_store_is_kept_verbatim_and_trimmed() {
8505        assert_eq!(
8506            context_of("context:\n  record_store: '  shey  '\n")
8507                .unwrap()
8508                .record_store,
8509            Some("shey".to_string())
8510        );
8511        assert_eq!(
8512            context_of("context:\n  record: off\n  record_store: shey\n")
8513                .unwrap()
8514                .record_store,
8515            Some("shey".to_string()),
8516            "a store declared beside record: off is kept and simply never used"
8517        );
8518    }
8519
8520    #[test]
8521    fn interpreter_runtime_rejects_missing_list_dir() {
8522        let msg = interpreter_runtime_reject(
8523            "      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.11\n",
8524        );
8525        assert!(
8526            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
8527            "{msg}"
8528        );
8529        assert!(msg.contains("explicitly"), "{msg}");
8530    }
8531
8532    #[test]
8533    fn interpreter_runtime_rejects_empty_dirs() {
8534        let msg = interpreter_runtime_reject("      - binary: python3\n        dirs: []\n");
8535        assert!(
8536            msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
8537            "{msg}"
8538        );
8539        assert!(msg.contains("at least one"), "{msg}");
8540    }
8541
8542    #[test]
8543    fn staged_runtime_parses_accepted_shape() {
8544        let manifest = RuntimeManifest::from_yaml_str(
8545            r#"
8546name: cap
8547version: 0.0.1
8548artifacts: []
8549capabilities:
8550  containment: sealed
8551  shell:
8552    allow:
8553      - python3
8554      - bash
8555    staged_runtime:
8556      - binary: python3
8557        source_path: /opt/testbed/conda/envs/django__django
8558        pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
8559"#,
8560        )
8561        .unwrap();
8562
8563        let shell = manifest.capabilities.unwrap().shell.unwrap();
8564        assert_eq!(
8565            shell.staged_runtime,
8566            vec![StagedRuntimeGrant {
8567                binary: "python3".to_string(),
8568                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
8569                pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
8570            }]
8571        );
8572        // The two mechanisms are alternatives, so an accepted `staged_runtime` grant leaves the
8573        // interpreter_runtime list untouched rather than populating it as a side effect.
8574        assert!(shell.interpreter_runtime.is_empty());
8575    }
8576
8577    #[test]
8578    fn staged_runtime_defaults_to_empty_when_absent() {
8579        let manifest = RuntimeManifest::from_yaml_str(
8580            r#"
8581name: cap
8582version: 0.0.1
8583artifacts: []
8584capabilities:
8585  shell:
8586    allow:
8587      - python3
8588"#,
8589        )
8590        .unwrap();
8591
8592        let shell = manifest.capabilities.unwrap().shell.unwrap();
8593        assert!(shell.staged_runtime.is_empty());
8594    }
8595
8596    /// Shared helper: parse a manifest whose sole `staged_runtime` grant is `grant_yaml` (indented
8597    /// to sit under `staged_runtime:`), returning the error message. `extra` is spliced into the
8598    /// same `shell` block, which is how the mutual-exclusion case adds an `interpreter_runtime`.
8599    fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
8600        let yaml = format!(
8601            r#"
8602name: cap
8603version: 0.0.1
8604artifacts: []
8605capabilities:
8606  shell:
8607    allow:
8608      - python3
8609{extra}    staged_runtime:
8610{grant_yaml}
8611"#,
8612        );
8613        RuntimeManifest::from_yaml_str(&yaml)
8614            .expect_err("grant should be rejected")
8615            .to_string()
8616    }
8617
8618    #[test]
8619    fn staged_runtime_rejects_binary_not_in_allow() {
8620        let msg = staged_runtime_reject(
8621            "      - binary: ruby\n        source_path: /opt/ruby\n        pin: ruby-3.2.2\n",
8622            "",
8623        );
8624        assert!(
8625            msg.contains("capabilities.shell.staged_runtime[0].binary"),
8626            "{msg}"
8627        );
8628        assert!(msg.contains("ruby"), "{msg}");
8629        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
8630    }
8631
8632    #[test]
8633    fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
8634        let msg = staged_runtime_reject(
8635            "      - binary: python3\n        source_path: /opt/py\n        pin: cpython-3.9.19\n",
8636            "    interpreter_runtime:\n      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.9\n            list_dir: true\n",
8637        );
8638        assert!(
8639            msg.contains("capabilities.shell.staged_runtime[0].binary"),
8640            "{msg}"
8641        );
8642        assert!(msg.contains("python3"), "{msg}");
8643        assert!(msg.contains("interpreter_runtime"), "{msg}");
8644        assert!(msg.contains("mutually exclusive"), "{msg}");
8645    }
8646
8647    #[test]
8648    fn staged_runtime_rejects_relative_source_path() {
8649        let msg = staged_runtime_reject(
8650            "      - binary: python3\n        source_path: opt/testbed/conda\n        pin: cpython-3.9.19\n",
8651            "",
8652        );
8653        assert!(
8654            msg.contains("capabilities.shell.staged_runtime[0].source_path"),
8655            "{msg}"
8656        );
8657        assert!(msg.contains("absolute"), "{msg}");
8658    }
8659
8660    #[test]
8661    fn staged_runtime_rejects_missing_or_empty_pin() {
8662        // Omitted entirely, and present-but-blank: both are "unpinned", and the field exists
8663        // precisely so that state is unrepresentable.
8664        for grant in [
8665            "      - binary: python3\n        source_path: /opt/testbed/conda\n",
8666            "      - binary: python3\n        source_path: /opt/testbed/conda\n        pin: \"   \"\n",
8667        ] {
8668            let msg = staged_runtime_reject(grant, "");
8669            assert!(
8670                msg.contains("capabilities.shell.staged_runtime[0].pin"),
8671                "{msg}"
8672            );
8673            assert!(msg.contains("never inferred"), "{msg}");
8674        }
8675    }
8676
8677    #[test]
8678    fn parses_inference_block() {
8679        let manifest = RuntimeManifest::from_yaml_str(
8680            r#"
8681name: cap
8682version: 0.0.1
8683artifacts:
8684  - name: murmur/driver-anthropic
8685    version: 0.1.0
8686    runtime: driver
8687    gateway:
8688      endpoint: http://127.0.0.1:8080
8689      api_key: literal-key
8690inference:
8691  transport: http
8692  model: test-model
8693  driver:
8694    artifact: murmur/driver-anthropic
8695    config:
8696      temperature: 0.2
8697      headers:
8698        x-test: true
8699"#,
8700        )
8701        .unwrap();
8702
8703        assert_eq!(
8704            manifest.artifacts[0].gateway,
8705            Some(ArtifactGateway {
8706                endpoint: "http://127.0.0.1:8080".to_string(),
8707                api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
8708                keyless: false,
8709            })
8710        );
8711        let inference = manifest.inference.expect("inference should exist");
8712        assert_eq!(inference.transport, "http");
8713        assert_eq!(inference.model, "test-model");
8714        let driver = inference.driver.as_ref().expect("driver should be present");
8715        assert_eq!(driver.artifact, "murmur/driver-anthropic");
8716        assert_eq!(
8717            driver.config,
8718            Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
8719        );
8720        assert!(inference.system_prompt.is_none());
8721        assert!(inference.system_prompt_file.is_none());
8722    }
8723
8724    /// A capsule whose one tool entry declares `gateway:` with `block` as its body, indented
8725    /// under the key.
8726    fn tool_gateway_manifest(block: &str) -> String {
8727        format!(
8728            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/web-search\n    version: 0.1.0\n    runtime: tool\n    gateway:{block}\n"
8729        )
8730    }
8731
8732    fn gateway_endpoint_error(endpoint: &str) -> String {
8733        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
8734            "\n      endpoint: \"{endpoint}\""
8735        )))
8736        .unwrap_err()
8737        .to_string()
8738    }
8739
8740    #[test]
8741    fn gateway_endpoint_https_is_accepted() {
8742        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8743            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
8744        ))
8745        .unwrap();
8746        assert_eq!(
8747            manifest.artifacts[0].gateway,
8748            Some(ArtifactGateway {
8749                endpoint: "https://api.tavily.com".to_string(),
8750                api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
8751                keyless: false,
8752            })
8753        );
8754    }
8755
8756    #[test]
8757    fn gateway_endpoint_http_localhost_is_accepted() {
8758        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8759            "\n      endpoint: http://localhost:11434\n      keyless: true",
8760        ))
8761        .unwrap();
8762        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
8763        assert_eq!(gateway.endpoint, "http://localhost:11434");
8764        assert_eq!(gateway.api_key, None);
8765        assert!(gateway.keyless);
8766    }
8767
8768    #[test]
8769    fn gateway_endpoint_http_loopback_ip_is_accepted() {
8770        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8771            "\n      endpoint: http://127.0.0.1:8080\n      api_key: ${LOCAL_KEY}",
8772        ))
8773        .unwrap();
8774        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
8775        assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
8776        assert_eq!(
8777            gateway.api_key,
8778            Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
8779        );
8780    }
8781
8782    #[test]
8783    fn gateway_endpoint_without_scheme_is_refused() {
8784        let msg = gateway_endpoint_error("api.tavily.com");
8785        assert!(msg.contains("gateway.endpoint"), "{msg}");
8786        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
8787        assert!(msg.contains("does not parse as a URL"), "{msg}");
8788    }
8789
8790    #[test]
8791    fn gateway_endpoint_ftp_scheme_is_refused() {
8792        let msg = gateway_endpoint_error("ftp://example.com");
8793        assert!(msg.contains("gateway.endpoint"), "{msg}");
8794        assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
8795    }
8796
8797    #[test]
8798    fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
8799        let msg = gateway_endpoint_error("http://api.attacker.example.com");
8800        assert!(msg.contains("gateway.endpoint"), "{msg}");
8801        assert!(msg.contains("api.attacker.example.com"), "{msg}");
8802        assert!(msg.contains("non-loopback"), "{msg}");
8803    }
8804
8805    #[test]
8806    fn gateway_endpoint_with_a_query_is_refused() {
8807        let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
8808        assert!(msg.contains("gateway.endpoint"), "{msg}");
8809        assert!(msg.contains("carries a query"), "{msg}");
8810    }
8811
8812    #[test]
8813    fn gateway_endpoint_with_a_fragment_is_refused() {
8814        let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
8815        assert!(msg.contains("gateway.endpoint"), "{msg}");
8816        assert!(msg.contains("carries a fragment"), "{msg}");
8817    }
8818
8819    #[test]
8820    fn gateway_endpoint_with_userinfo_is_refused() {
8821        let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
8822        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
8823        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
8824        assert!(msg.contains("host 'api.example.com'"), "{msg}");
8825        assert!(msg.contains("gateway.api_key"), "{msg}");
8826        assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
8827        assert!(
8828            !msg.contains("sk-in-url"),
8829            "a password in the URL is never echoed: {msg}"
8830        );
8831    }
8832
8833    #[test]
8834    fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
8835        let msg = gateway_endpoint_error("https://user:sk-in-url@");
8836        assert!(msg.contains("does not parse as a URL"), "{msg}");
8837        assert!(
8838            !msg.contains("sk-in-url"),
8839            "a password in the URL is never echoed: {msg}"
8840        );
8841    }
8842
8843    #[test]
8844    fn gateway_endpoint_with_a_masquerading_host_is_refused() {
8845        let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
8846        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
8847        assert!(
8848            msg.contains("addresses host '127.0.0.1'"),
8849            "the message names the host the URL really addresses: {msg}"
8850        );
8851    }
8852
8853    #[test]
8854    fn gateway_endpoint_with_interpolation_is_refused() {
8855        let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
8856        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
8857        assert!(msg.contains("contains '${'"), "{msg}");
8858        assert!(msg.contains("not interpolated"), "{msg}");
8859    }
8860
8861    #[test]
8862    fn gateway_endpoint_http_unspecified_address_is_refused() {
8863        let msg = gateway_endpoint_error("http://0.0.0.0:8080");
8864        assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
8865    }
8866
8867    #[test]
8868    fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
8869        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8870            "\n      endpoint: http://[::1]:8080\n      api_key: ${LOCAL_KEY}",
8871        ))
8872        .unwrap();
8873        assert_eq!(
8874            manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
8875            "http://[::1]:8080"
8876        );
8877    }
8878
8879    /// The error a tool entry whose `gateway:` body is `block` draws, parsed with secrets resolved.
8880    fn gateway_credential_error(block: &str) -> RuntimeManifestError {
8881        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
8882    }
8883
8884    fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
8885        match &error {
8886            RuntimeManifestError::GatewayWithoutCredential {
8887                index,
8888                name,
8889                endpoint: refused,
8890            } => {
8891                assert_eq!(*index, 0);
8892                assert_eq!(name, "local/web-search");
8893                assert_eq!(refused, endpoint);
8894            }
8895            other => panic!("expected GatewayWithoutCredential, got {other:?}"),
8896        }
8897        let msg = error.to_string();
8898        assert!(
8899            msg.contains(&format!(
8900                "artifact 'local/web-search' declares gateway.endpoint '{endpoint}' but binds no \
8901                 credential"
8902            )),
8903            "{msg}"
8904        );
8905    }
8906
8907    #[test]
8908    fn gateway_credential_absent_is_refused() {
8909        assert_without_credential(
8910            gateway_credential_error("\n      endpoint: https://api.tavily.com"),
8911            "https://api.tavily.com",
8912        );
8913    }
8914
8915    #[test]
8916    fn gateway_credential_blank_is_refused() {
8917        for key in ["\"\"", "\"   \"", "''"] {
8918            assert_without_credential(
8919                gateway_credential_error(&format!(
8920                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
8921                )),
8922                "https://api.tavily.com",
8923            );
8924        }
8925    }
8926
8927    #[test]
8928    fn gateway_credential_null_is_refused() {
8929        for key in ["~", "null", ""] {
8930            assert_without_credential(
8931                gateway_credential_error(&format!(
8932                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
8933                )),
8934                "https://api.tavily.com",
8935            );
8936        }
8937    }
8938
8939    #[test]
8940    fn gateway_credential_keyless_false_is_refused() {
8941        assert_without_credential(
8942            gateway_credential_error(
8943                "\n      endpoint: https://api.tavily.com\n      keyless: false",
8944            ),
8945            "https://api.tavily.com",
8946        );
8947    }
8948
8949    #[test]
8950    fn gateway_credential_keyless_is_accepted() {
8951        for (parse, mode) in [
8952            (
8953                RuntimeManifest::from_yaml_str as fn(&str) -> _,
8954                "with secrets",
8955            ),
8956            (
8957                RuntimeManifest::from_yaml_str_without_secrets,
8958                "without secrets",
8959            ),
8960        ] {
8961            let manifest = parse(&tool_gateway_manifest(
8962                "\n      endpoint: https://api.example.com\n      keyless: true",
8963            ))
8964            .unwrap();
8965            assert_eq!(
8966                manifest.artifacts[0].gateway,
8967                Some(ArtifactGateway {
8968                    endpoint: "https://api.example.com".to_string(),
8969                    api_key: None,
8970                    keyless: true,
8971                }),
8972                "{mode}"
8973            );
8974        }
8975    }
8976
8977    #[test]
8978    fn gateway_credential_keyless_not_a_boolean_is_refused() {
8979        for value in ["\"true\"", "yes please", "1"] {
8980            let error = gateway_credential_error(&format!(
8981                "\n      endpoint: https://api.tavily.com\n      keyless: {value}"
8982            ));
8983            assert!(
8984                matches!(
8985                    error,
8986                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
8987                ),
8988                "{value}: {error:?}"
8989            );
8990            assert!(
8991                error
8992                    .to_string()
8993                    .contains("gateway.keyless with a value that is not a boolean"),
8994                "{error}"
8995            );
8996        }
8997    }
8998
8999    #[test]
9000    fn gateway_credential_and_keyless_together_are_refused() {
9001        for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
9002            let error = gateway_credential_error(&format!(
9003                "\n      endpoint: https://api.tavily.com\n      api_key: {key}\n      keyless: true"
9004            ));
9005            assert!(
9006                matches!(
9007                    error,
9008                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
9009                ),
9010                "{error:?}"
9011            );
9012            assert!(
9013                error.to_string().contains(
9014                    "artifact 'local/web-search' declares both gateway.api_key and gateway.keyless: \
9015                     true; a gateway either presents a key or is keyless"
9016                ),
9017                "{error}"
9018            );
9019        }
9020    }
9021
9022    #[test]
9023    fn gateway_credential_absent_is_refused_without_secrets() {
9024        for block in [
9025            "\n      endpoint: https://api.tavily.com",
9026            "\n      endpoint: https://api.tavily.com\n      api_key: \"\"",
9027            "\n      endpoint: https://api.tavily.com\n      keyless: false",
9028        ] {
9029            assert_without_credential(
9030                RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
9031                    .unwrap_err(),
9032                "https://api.tavily.com",
9033            );
9034        }
9035        let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
9036            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
9037        ))
9038        .unwrap();
9039        let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
9040        assert_eq!(gateway.api_key, None, "the key reference is left unread");
9041        assert!(!gateway.keyless, "a keyed gateway is never keyless");
9042    }
9043
9044    /// The refusal reads what the manifest wrote, never the address: a loopback or private
9045    /// endpoint with no credential is refused exactly as a public one is.
9046    #[test]
9047    fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
9048        for endpoint in [
9049            "http://127.0.0.1:11434",
9050            "http://localhost:11434",
9051            "http://[::1]:11434",
9052            "https://10.0.0.5",
9053            "https://192.168.1.10:8443",
9054        ] {
9055            assert_without_credential(
9056                gateway_credential_error(&format!("\n      endpoint: {endpoint}")),
9057                endpoint,
9058            );
9059        }
9060    }
9061
9062    #[test]
9063    fn gateway_endpoint_missing_from_the_block_is_refused() {
9064        for block in [
9065            "\n      api_key: ${TAVILY_API_KEY}",
9066            "",
9067            " {}",
9068            "\n      endpoint: \"  \"",
9069        ] {
9070            let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
9071                .unwrap_err()
9072                .to_string();
9073            assert!(
9074                msg.contains("without 'gateway.endpoint'"),
9075                "{block:?}: {msg}"
9076            );
9077            assert!(
9078                msg.contains("artifact 'local/web-search'"),
9079                "{block:?}: {msg}"
9080            );
9081        }
9082    }
9083
9084    #[test]
9085    fn gateway_block_unknown_key_is_reported() {
9086        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9087            "\n      endpoint: https://api.tavily.com\n      keyless: true\n      api_kee: x",
9088        ))
9089        .unwrap();
9090        let unknown = manifest.unknown_keys;
9091        assert_eq!(unknown.len(), 1, "{unknown:?}");
9092        assert_eq!(unknown[0].key, "api_kee");
9093        assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
9094    }
9095
9096    #[test]
9097    fn gateway_on_skill_entry_is_refused() {
9098        let msg = RuntimeManifest::from_yaml_str(
9099            "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",
9100        )
9101        .unwrap_err()
9102        .to_string();
9103        assert!(
9104            msg.contains("artifact 'local/notes' declares 'gateway:'"),
9105            "{msg}"
9106        );
9107        assert!(msg.contains("runtime: skill"), "{msg}");
9108    }
9109
9110    #[test]
9111    fn gateway_on_hook_entry_is_accepted() {
9112        let manifest = RuntimeManifest::from_yaml_str(
9113            "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",
9114        )
9115        .unwrap();
9116        assert!(manifest.artifacts[0].gateway.is_some());
9117    }
9118
9119    /// An `http` driver capsule. `driver_entry` is the YAML of the driver's artifact entry, or
9120    /// empty for none.
9121    fn http_driver_manifest(artifacts: &str) -> String {
9122        format!(
9123            "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n  transport: http\n  model: test-model\n  driver:\n    artifact: murmur/driver-anthropic\n"
9124        )
9125    }
9126
9127    #[test]
9128    fn http_driver_entry_without_gateway_endpoint_is_refused() {
9129        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9130            "\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver",
9131        ))
9132        .unwrap_err()
9133        .to_string();
9134        assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9135        assert!(msg.contains("gateway.endpoint"), "{msg}");
9136    }
9137
9138    #[test]
9139    fn http_driver_absent_from_artifacts_is_refused() {
9140        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
9141            .unwrap_err()
9142            .to_string();
9143        assert!(msg.contains("inference.driver.artifact"), "{msg}");
9144        assert!(msg.contains("not declared in artifacts:"), "{msg}");
9145    }
9146
9147    #[test]
9148    fn gateway_on_unconfigured_driver_entry_is_refused() {
9149        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9150            "\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",
9151        ))
9152        .unwrap_err()
9153        .to_string();
9154        assert!(msg.contains("'murmur/driver-openai'"), "{msg}");
9155        assert!(
9156            msg.contains("accepted only on the entry inference.driver.artifact names"),
9157            "{msg}"
9158        );
9159
9160        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";
9161        let msg = RuntimeManifest::from_yaml_str(no_inference)
9162            .unwrap_err()
9163            .to_string();
9164        assert!(msg.contains("not the configured inference driver"), "{msg}");
9165    }
9166
9167    #[test]
9168    fn gateway_on_driver_entry_under_process_transport_is_refused() {
9169        let msg = RuntimeManifest::from_yaml_str(
9170            "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",
9171        )
9172        .unwrap_err()
9173        .to_string();
9174        assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9175        assert!(msg.contains("transport: process"), "{msg}");
9176    }
9177
9178    #[test]
9179    fn inference_api_key_is_refused_naming_gateway_api_key() {
9180        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";
9181        for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
9182            let manifest = http_driver_manifest(entry)
9183                .replace("  model:", &format!("  api_key: {value}\n  model:"));
9184            let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
9185            let msg = err.to_string();
9186            assert!(msg.contains("'inference.api_key'"), "{msg}");
9187            assert!(msg.contains("was removed"), "{msg}");
9188            assert!(
9189                msg.contains(
9190                    "set gateway.api_key on the artifacts: entry 'murmur/driver-anthropic'"
9191                ),
9192                "{msg}"
9193            );
9194            assert!(!msg.contains("sk-literal"), "{msg}");
9195        }
9196
9197        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  api_key: ${KEY}\n";
9198        let msg = RuntimeManifest::from_yaml_str(process)
9199            .unwrap_err()
9200            .to_string();
9201        assert!(msg.contains("'inference.api_key'"), "{msg}");
9202        assert!(
9203            msg.contains("on the artifacts: entry inference.driver.artifact names"),
9204            "{msg}"
9205        );
9206        assert!(
9207            RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
9208            "the refusal does not depend on resolving secrets"
9209        );
9210    }
9211
9212    #[test]
9213    fn inference_endpoint_is_refused_naming_gateway_endpoint() {
9214        let manifest = http_driver_manifest(
9215            "\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",
9216        )
9217        .replace("  model:", "  endpoint: https://api.anthropic.com\n  model:");
9218        let msg = RuntimeManifest::from_yaml_str(&manifest)
9219            .unwrap_err()
9220            .to_string();
9221        assert!(msg.contains("'inference.endpoint'"), "{msg}");
9222        assert!(
9223            msg.contains("set gateway.endpoint on the artifacts: entry 'murmur/driver-anthropic'"),
9224            "{msg}"
9225        );
9226
9227        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  endpoint: http://localhost:8080\n";
9228        let msg = RuntimeManifest::from_yaml_str(process)
9229            .unwrap_err()
9230            .to_string();
9231        assert!(msg.contains("'inference.endpoint'"), "{msg}");
9232        assert!(msg.contains("was removed"), "{msg}");
9233    }
9234
9235    #[test]
9236    fn without_secrets_leaves_gateway_api_key_unread() {
9237        let manifest = tool_gateway_manifest(
9238            "\n      endpoint: https://api.tavily.com\n      api_key: tvly-literal",
9239        );
9240        let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
9241        assert_eq!(
9242            resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
9243            Some(ApiKeyReference::Literal("tvly-literal".to_string()))
9244        );
9245        let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
9246        let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
9247        assert_eq!(gateway.endpoint, "https://api.tavily.com");
9248        assert_eq!(gateway.api_key, None);
9249    }
9250
9251    #[test]
9252    fn referenced_env_variables_reads_gateway_api_key() {
9253        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";
9254        let referenced = referenced_env_variables(manifest);
9255        assert_eq!(
9256            referenced,
9257            vec![
9258                ReferencedEnvVariable {
9259                    field: "artifacts.murmur/driver-anthropic.gateway.api_key".to_string(),
9260                    variable: "ANTHROPIC_API_KEY".to_string(),
9261                },
9262                ReferencedEnvVariable {
9263                    field: "artifacts.local/web-search.gateway.api_key".to_string(),
9264                    variable: "TAVILY_API_KEY".to_string(),
9265                },
9266            ]
9267        );
9268        assert!(referenced
9269            .iter()
9270            .all(ReferencedEnvVariable::is_gateway_credential));
9271    }
9272
9273    #[test]
9274    fn parses_inline_system_prompt() {
9275        let manifest = RuntimeManifest::from_yaml_str(
9276            r#"
9277name: cap
9278version: 0.0.1
9279artifacts:
9280  - name: murmur/driver-anthropic
9281    version: 0.1.0
9282    runtime: driver
9283    gateway:
9284      endpoint: http://127.0.0.1:8080
9285      keyless: true
9286inference:
9287  model: test-model
9288  system_prompt: "Always begin with CONFIRMED:"
9289  driver:
9290    artifact: murmur/driver-anthropic
9291"#,
9292        )
9293        .unwrap();
9294
9295        let inference = manifest.inference.unwrap();
9296        assert_eq!(
9297            inference.system_prompt,
9298            Some("Always begin with CONFIRMED:".to_string())
9299        );
9300        assert!(inference.system_prompt_file.is_none());
9301    }
9302
9303    #[test]
9304    fn parses_compaction_system_prompt() {
9305        let manifest = RuntimeManifest::from_yaml_str(
9306            r#"
9307name: cap
9308version: 0.0.1
9309artifacts:
9310  - name: murmur/driver-anthropic
9311    version: 0.1.0
9312    runtime: driver
9313    gateway:
9314      endpoint: http://127.0.0.1:8080
9315      keyless: true
9316inference:
9317  model: test-model
9318  compaction:
9319    model: compaction-model
9320    system_prompt: "task = X, currently editing Y, already tried Z."
9321  driver:
9322    artifact: murmur/driver-anthropic
9323"#,
9324        )
9325        .unwrap();
9326
9327        let compaction = manifest.inference.unwrap().compaction.unwrap();
9328        assert_eq!(
9329            compaction.system_prompt,
9330            Some("task = X, currently editing Y, already tried Z.".to_string())
9331        );
9332        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9333    }
9334
9335    /// The two override fields resolve independently: `model` set, `system_prompt`
9336    /// absent leaves the latter `None` with no default string substituted.
9337    #[test]
9338    fn compaction_system_prompt_absent_stays_none() {
9339        let manifest = RuntimeManifest::from_yaml_str(
9340            r#"
9341name: cap
9342version: 0.0.1
9343artifacts:
9344  - name: murmur/driver-anthropic
9345    version: 0.1.0
9346    runtime: driver
9347    gateway:
9348      endpoint: http://127.0.0.1:8080
9349      keyless: true
9350inference:
9351  model: test-model
9352  compaction:
9353    model: compaction-model
9354  driver:
9355    artifact: murmur/driver-anthropic
9356"#,
9357        )
9358        .unwrap();
9359
9360        let compaction = manifest.inference.unwrap().compaction.unwrap();
9361        assert!(compaction.system_prompt.is_none());
9362        assert!(compaction.system_prompt_file.is_none());
9363        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9364    }
9365
9366    /// `system_prompt_file` parses as the second, independent prompt source: the path is
9367    /// carried through verbatim (resolution against the manifest dir happens in the
9368    /// runtime), the inline field stays `None`, and `model` is unaffected.
9369    #[test]
9370    fn parses_compaction_system_prompt_file() {
9371        let manifest = RuntimeManifest::from_yaml_str(
9372            r#"
9373name: cap
9374version: 0.0.1
9375artifacts:
9376  - name: murmur/driver-anthropic
9377    version: 0.1.0
9378    runtime: driver
9379    gateway:
9380      endpoint: http://127.0.0.1:8080
9381      keyless: true
9382inference:
9383  model: test-model
9384  compaction:
9385    model: compaction-model
9386    system_prompt_file: "compaction-instructions.md"
9387  driver:
9388    artifact: murmur/driver-anthropic
9389"#,
9390        )
9391        .unwrap();
9392
9393        let compaction = manifest.inference.unwrap().compaction.unwrap();
9394        assert_eq!(
9395            compaction.system_prompt_file,
9396            Some("compaction-instructions.md".to_string())
9397        );
9398        assert!(compaction.system_prompt.is_none());
9399        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9400    }
9401
9402    /// `system_prompt_file` alone, with no `model`, still parses — the two fields do not
9403    /// depend on each other in either direction.
9404    #[test]
9405    fn compaction_system_prompt_file_independent_of_model() {
9406        let manifest = RuntimeManifest::from_yaml_str(
9407            r#"
9408name: cap
9409version: 0.0.1
9410artifacts:
9411  - name: murmur/driver-anthropic
9412    version: 0.1.0
9413    runtime: driver
9414    gateway:
9415      endpoint: http://127.0.0.1:8080
9416      keyless: true
9417inference:
9418  model: test-model
9419  compaction:
9420    threshold: 0.5
9421    system_prompt_file: "compaction-instructions.md"
9422  driver:
9423    artifact: murmur/driver-anthropic
9424"#,
9425        )
9426        .unwrap();
9427
9428        let compaction = manifest.inference.unwrap().compaction.unwrap();
9429        assert_eq!(
9430            compaction.system_prompt_file,
9431            Some("compaction-instructions.md".to_string())
9432        );
9433        assert!(compaction.model.is_none());
9434        assert_eq!(compaction.threshold, Some(0.5));
9435    }
9436
9437    /// `dump_summaries` is a plain optional boolean: present-true, present-false and absent
9438    /// are three distinguishable states, so the runtime can tell "explicitly off" from
9439    /// "never configured" even though both resolve to the same behavior.
9440    #[test]
9441    fn parses_compaction_dump_summaries() {
9442        let parse = |line: &str| {
9443            RuntimeManifest::from_yaml_str(&format!(
9444                r#"
9445name: cap
9446version: 0.0.1
9447artifacts:
9448  - name: murmur/driver-anthropic
9449    version: 0.1.0
9450    runtime: driver
9451    gateway:
9452      endpoint: http://127.0.0.1:8080
9453      keyless: true
9454inference:
9455  model: test-model
9456  compaction:
9457    model: compaction-model
9458{line}  driver:
9459    artifact: murmur/driver-anthropic
9460"#
9461            ))
9462            .unwrap()
9463            .inference
9464            .unwrap()
9465            .compaction
9466            .unwrap()
9467        };
9468
9469        assert_eq!(
9470            parse("    dump_summaries: true\n").dump_summaries,
9471            Some(true)
9472        );
9473        assert_eq!(
9474            parse("    dump_summaries: false\n").dump_summaries,
9475            Some(false)
9476        );
9477        assert_eq!(parse("").dump_summaries, None);
9478    }
9479
9480    /// `dump_summaries` is orthogonal to every other compaction field: it neither trips the
9481    /// prompt-source exclusivity check nor the threshold range check, and does not disturb
9482    /// the values those fields parse to.
9483    #[test]
9484    fn compaction_dump_summaries_does_not_interact_with_other_fields() {
9485        let compaction = RuntimeManifest::from_yaml_str(
9486            r#"
9487name: cap
9488version: 0.0.1
9489artifacts:
9490  - name: murmur/driver-anthropic
9491    version: 0.1.0
9492    runtime: driver
9493    gateway:
9494      endpoint: http://127.0.0.1:8080
9495      keyless: true
9496inference:
9497  model: test-model
9498  compaction:
9499    threshold: 0.5
9500    model: compaction-model
9501    system_prompt_file: "compaction-instructions.md"
9502    dump_summaries: true
9503  driver:
9504    artifact: murmur/driver-anthropic
9505"#,
9506        )
9507        .unwrap()
9508        .inference
9509        .unwrap()
9510        .compaction
9511        .unwrap();
9512
9513        assert_eq!(compaction.dump_summaries, Some(true));
9514        assert_eq!(compaction.threshold, Some(0.5));
9515        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9516        assert_eq!(
9517            compaction.system_prompt_file,
9518            Some("compaction-instructions.md".to_string())
9519        );
9520        assert!(compaction.system_prompt.is_none());
9521
9522        // Still mutually exclusive with dump_summaries in the mix...
9523        let err = RuntimeManifest::from_yaml_str(
9524            r#"
9525name: cap
9526version: 0.0.1
9527artifacts:
9528  - name: murmur/driver-anthropic
9529    version: 0.1.0
9530    runtime: driver
9531    gateway:
9532      endpoint: http://127.0.0.1:8080
9533      keyless: true
9534inference:
9535  model: test-model
9536  compaction:
9537    dump_summaries: true
9538    system_prompt: "inline"
9539    system_prompt_file: "compaction-instructions.md"
9540  driver:
9541    artifact: murmur/driver-anthropic
9542"#,
9543        )
9544        .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
9545        assert!(matches!(
9546            err,
9547            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9548                if field == "inference.compaction.system_prompt"
9549        ));
9550
9551        // ...and the threshold range is still enforced.
9552        let err = RuntimeManifest::from_yaml_str(
9553            r#"
9554name: cap
9555version: 0.0.1
9556artifacts:
9557  - name: murmur/driver-anthropic
9558    version: 0.1.0
9559    runtime: driver
9560    gateway:
9561      endpoint: http://127.0.0.1:8080
9562      keyless: true
9563inference:
9564  model: test-model
9565  compaction:
9566    dump_summaries: true
9567    threshold: 1.5
9568  driver:
9569    artifact: murmur/driver-anthropic
9570"#,
9571        )
9572        .expect_err("dump_summaries must not suppress the threshold range check");
9573        assert!(matches!(
9574            err,
9575            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9576                if field == "inference.compaction.threshold"
9577        ));
9578    }
9579
9580    fn compaction_call_manifest(lines: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
9581        RuntimeManifest::from_yaml_str(&format!(
9582            r#"
9583name: cap
9584version: 0.0.1
9585artifacts:
9586  - name: murmur/driver-anthropic
9587    version: 0.1.0
9588    runtime: driver
9589    gateway:
9590      endpoint: http://127.0.0.1:8080
9591      keyless: true
9592inference:
9593  model: test-model
9594  driver:
9595    artifact: murmur/driver-anthropic
9596    config:
9597      reasoning_effort: max
9598  compaction:
9599{lines}"#
9600        ))
9601    }
9602
9603    #[test]
9604    fn parses_compaction_max_tokens_and_driver_config() {
9605        let manifest = compaction_call_manifest(
9606            "    max_tokens: 8000\n    driver_config:\n      reasoning_effort: low\n      extra: [1, 2]\n",
9607        )
9608        .unwrap();
9609        assert!(
9610            manifest.unknown_keys.is_empty(),
9611            "neither key is unknown: {:?}",
9612            manifest.unknown_keys
9613        );
9614        let compaction = manifest.inference.unwrap().compaction.unwrap();
9615        assert_eq!(compaction.max_tokens, Some(8000));
9616        assert_eq!(
9617            compaction.driver_config.as_deref(),
9618            Some(r#"{"extra":[1,2],"reasoning_effort":"low"}"#)
9619        );
9620
9621        let compaction = compaction_call_manifest("    threshold: 0.5\n")
9622            .unwrap()
9623            .inference
9624            .unwrap()
9625            .compaction
9626            .unwrap();
9627        assert_eq!(compaction.max_tokens, None);
9628        assert_eq!(compaction.driver_config, None);
9629
9630        // An empty mapping is an overlay that changes nothing, not an absent one.
9631        let compaction = compaction_call_manifest("    driver_config: {}\n")
9632            .unwrap()
9633            .inference
9634            .unwrap()
9635            .compaction
9636            .unwrap();
9637        assert_eq!(compaction.driver_config.as_deref(), Some("{}"));
9638    }
9639
9640    #[test]
9641    fn compaction_max_tokens_zero_is_refused() {
9642        match compaction_call_manifest("    max_tokens: 0\n").unwrap_err() {
9643            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9644                assert_eq!(field, "inference.compaction.max_tokens");
9645                assert_eq!(message, "must be greater than 0");
9646            }
9647            other => panic!("unexpected error: {other:?}"),
9648        }
9649    }
9650
9651    #[test]
9652    fn compaction_driver_config_must_be_a_mapping() {
9653        for value in ["low", "[reasoning_effort]", "3"] {
9654            match compaction_call_manifest(&format!("    driver_config: {value}\n")).unwrap_err() {
9655                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9656                    assert_eq!(field, "inference.compaction.driver_config");
9657                    assert_eq!(message, "expected mapping/object");
9658                }
9659                other => panic!("unexpected error for {value}: {other:?}"),
9660            }
9661        }
9662    }
9663
9664    /// The harness runs its own compaction under `transport: process`, so neither key would
9665    /// shape any call there.
9666    #[test]
9667    fn process_transport_rejects_compaction_call_keys() {
9668        for (key, value) in [
9669            ("max_tokens", "8000"),
9670            ("driver_config", "{reasoning_effort: low}"),
9671        ] {
9672            let err = RuntimeManifest::from_yaml_str(&format!(
9673                "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-claude-code\n    \
9674                 version: 0.1.0\n    runtime: driver\ninference:\n  transport: process\n  \
9675                 model: claude-haiku-4-5-20251001\n  driver:\n    artifact: murmur/driver-claude-code\n  \
9676                 compaction:\n    {key}: {value}\n"
9677            ))
9678            .unwrap_err();
9679            match err {
9680                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9681                    assert_eq!(field, format!("inference.compaction.{key}"));
9682                    assert_eq!(message, "is not valid with transport: process");
9683                }
9684                other => panic!("unexpected error for {key}: {other:?}"),
9685            }
9686        }
9687    }
9688
9689    /// Setting both compaction prompt sources is a parse error naming the compaction
9690    /// fields — distinguishable from the top-level three-way `inference.system_prompt`
9691    /// exclusivity error, and with neither value silently preferred.
9692    #[test]
9693    fn compaction_prompt_sources_are_mutually_exclusive() {
9694        let err = RuntimeManifest::from_yaml_str(
9695            r#"
9696name: cap
9697version: 0.0.1
9698artifacts:
9699  - name: murmur/driver-anthropic
9700    version: 0.1.0
9701    runtime: driver
9702    gateway:
9703      endpoint: http://127.0.0.1:8080
9704      keyless: true
9705inference:
9706  model: test-model
9707  compaction:
9708    system_prompt: "inline"
9709    system_prompt_file: "compaction-instructions.md"
9710  driver:
9711    artifact: murmur/driver-anthropic
9712"#,
9713        )
9714        .expect_err("both compaction prompt sources set must fail");
9715
9716        match err {
9717            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9718                assert_eq!(field, "inference.compaction.system_prompt");
9719                assert_eq!(
9720                    message,
9721                    "at most one of inference.compaction.system_prompt, \
9722                     inference.compaction.system_prompt_file may be set"
9723                );
9724            }
9725            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
9726        }
9727    }
9728
9729    /// The compaction-level exclusivity check is separate from the top-level one: an
9730    /// inline top-level `system_prompt` alongside a compaction `system_prompt_file` is a
9731    /// perfectly legal manifest.
9732    #[test]
9733    fn top_level_and_compaction_prompt_sources_do_not_collide() {
9734        let manifest = RuntimeManifest::from_yaml_str(
9735            r#"
9736name: cap
9737version: 0.0.1
9738artifacts:
9739  - name: murmur/driver-anthropic
9740    version: 0.1.0
9741    runtime: driver
9742    gateway:
9743      endpoint: http://127.0.0.1:8080
9744      keyless: true
9745inference:
9746  model: test-model
9747  system_prompt: "be helpful"
9748  compaction:
9749    system_prompt_file: "compaction-instructions.md"
9750  driver:
9751    artifact: murmur/driver-anthropic
9752"#,
9753        )
9754        .unwrap();
9755
9756        let inference = manifest.inference.unwrap();
9757        assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
9758        assert_eq!(
9759            inference.compaction.unwrap().system_prompt_file,
9760            Some("compaction-instructions.md".to_string())
9761        );
9762    }
9763
9764    #[test]
9765    fn parses_system_prompt_file() {
9766        let manifest = RuntimeManifest::from_yaml_str(
9767            r#"
9768name: cap
9769version: 0.0.1
9770artifacts:
9771  - name: murmur/driver-anthropic
9772    version: 0.1.0
9773    runtime: driver
9774    gateway:
9775      endpoint: http://127.0.0.1:8080
9776      keyless: true
9777inference:
9778  model: test-model
9779  system_prompt_file: "conventions.md"
9780  driver:
9781    artifact: murmur/driver-anthropic
9782"#,
9783        )
9784        .unwrap();
9785
9786        let inference = manifest.inference.unwrap();
9787        assert_eq!(
9788            inference.system_prompt_file,
9789            Some("conventions.md".to_string())
9790        );
9791        assert!(inference.system_prompt.is_none());
9792    }
9793
9794    #[test]
9795    fn rejects_both_inline_and_file_system_prompt() {
9796        let err = RuntimeManifest::from_yaml_str(
9797            r#"
9798name: cap
9799version: 0.0.1
9800artifacts:
9801  - name: murmur/driver-anthropic
9802    version: 0.1.0
9803    runtime: driver
9804    gateway:
9805      endpoint: http://127.0.0.1:8080
9806      keyless: true
9807inference:
9808  model: test-model
9809  system_prompt: "inline"
9810  system_prompt_file: "conventions.md"
9811  driver:
9812    artifact: murmur/driver-anthropic
9813"#,
9814        )
9815        .unwrap_err();
9816
9817        let msg = err.to_string();
9818        assert!(msg.contains("inference.system_prompt"));
9819        assert!(msg.contains("system_prompt_file"));
9820    }
9821
9822    /// A `${NAME}` is kept as the name it is, whether or not this process holds the variable: the
9823    /// parse never reads the environment.
9824    #[test]
9825    fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
9826        let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
9827        let yaml = format!(
9828            "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"
9829        );
9830
9831        assert_eq!(
9832            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
9833                .gateway
9834                .clone()
9835                .unwrap()
9836                .api_key,
9837            Some(ApiKeyReference::Environment(key.to_string()))
9838        );
9839        assert_eq!(
9840            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
9841                .unwrap()
9842                .artifacts[0]
9843                .gateway
9844                .clone()
9845                .unwrap()
9846                .api_key,
9847            None
9848        );
9849    }
9850
9851    #[test]
9852    fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
9853        let literal = ["sk-", "ant-", "debug"].concat();
9854        let debug = format!(
9855            "{:?} {:?}",
9856            ApiKeyReference::Environment("SOME_KEY".to_string()),
9857            ApiKeyReference::Literal(literal.clone())
9858        );
9859        assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
9860        assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
9861        assert!(!debug.contains(&literal), "{debug}");
9862    }
9863
9864    #[test]
9865    fn parse_without_secrets_drops_a_literal_api_key() {
9866        // Key assembled at runtime so the source never contains a
9867        // credential-shaped literal that secret scanners could flag.
9868        let literal = ["sk-", "ant-", "literal"].concat();
9869        let yaml = format!(
9870            "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"
9871        );
9872
9873        assert_eq!(
9874            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
9875                .gateway
9876                .clone()
9877                .unwrap()
9878                .api_key,
9879            Some(ApiKeyReference::Literal(literal))
9880        );
9881        assert_eq!(
9882            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
9883                .unwrap()
9884                .artifacts[0]
9885                .gateway
9886                .clone()
9887                .unwrap()
9888                .api_key,
9889            None
9890        );
9891    }
9892
9893    /// The non-resolving parse is narrower only in what it resolves, never in what it validates:
9894    /// a manifest roost accepts is a manifest `mur run` would also accept.
9895    #[test]
9896    fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
9897        let process_with_key = format!(
9898            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  model: test-model\n  api_key: {}\n",
9899            ["sk-", "ant-", "secret"].concat()
9900        );
9901        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";
9902
9903        for yaml in [process_with_key.as_str(), http_without_model] {
9904            let resolving = RuntimeManifest::from_yaml_str(yaml)
9905                .unwrap_err()
9906                .to_string();
9907            let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
9908                .unwrap_err()
9909                .to_string();
9910            assert_eq!(resolving, without_secrets);
9911        }
9912    }
9913
9914    /// The reference scan answers from the manifest text alone: it reports a reference, ignores
9915    /// a literal, and has nothing to say about a manifest that carries no `inference` block or
9916    /// does not parse at all.
9917    #[test]
9918    fn referenced_env_variables_reports_a_reference_and_nothing_else() {
9919        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";
9920        let referenced = referenced_env_variables(referencing);
9921        assert_eq!(referenced.len(), 1);
9922        assert_eq!(
9923            referenced[0].field,
9924            "artifacts.murmur/driver-anthropic.gateway.api_key"
9925        );
9926        assert!(referenced[0].is_gateway_credential());
9927        assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
9928
9929        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";
9930        assert!(referenced_env_variables(literal).is_empty());
9931
9932        let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
9933        assert!(referenced_env_variables(no_inference).is_empty());
9934
9935        let unparseable = "name: broken\nversion: [\n";
9936        assert!(referenced_env_variables(unparseable).is_empty());
9937    }
9938
9939    #[test]
9940    fn inference_requires_driver_artifact() {
9941        let err = RuntimeManifest::from_yaml_str(
9942            r#"
9943name: cap
9944version: 0.0.1
9945artifacts: []
9946inference:
9947  transport: http
9948  endpoint: http://127.0.0.1:8080
9949  model: test-model
9950"#,
9951        )
9952        .unwrap_err();
9953
9954        assert!(err.to_string().contains("inference.driver"));
9955    }
9956
9957    #[test]
9958    fn test_runtime_driver_deserializes() {
9959        let manifest = RuntimeManifest::from_yaml_str(
9960            r#"
9961name: cap
9962version: 0.0.1
9963artifacts:
9964  - name: local/my-provider
9965    version: 1.0.0
9966    runtime: driver
9967"#,
9968        )
9969        .unwrap();
9970
9971        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
9972    }
9973
9974    #[test]
9975    fn test_runtime_unknown_is_error() {
9976        let err = RuntimeManifest::from_yaml_str(
9977            r#"
9978name: cap
9979version: 0.0.1
9980artifacts:
9981  - name: local/foo
9982    version: 1.0.0
9983    runtime: unknown_type
9984"#,
9985        )
9986        .unwrap_err();
9987
9988        let msg = err.to_string();
9989        assert!(msg.contains("runtime"), "error should mention 'runtime'");
9990        assert!(
9991            msg.contains("unknown_type"),
9992            "error should identify the bad value"
9993        );
9994    }
9995
9996    #[test]
9997    fn test_runtime_all_variants_roundtrip() {
9998        for variant in [
9999            ArtifactRuntime::Tool,
10000            ArtifactRuntime::Driver,
10001            ArtifactRuntime::Hook,
10002            ArtifactRuntime::Skill,
10003        ] {
10004            let json = serde_json::to_string(&variant).unwrap();
10005            let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
10006            assert_eq!(variant, back);
10007        }
10008    }
10009
10010    #[test]
10011    fn skill_source_without_version_defaults_to_local() {
10012        let manifest = RuntimeManifest::from_yaml_str(
10013            r#"
10014name: cap
10015version: 0.0.1
10016artifacts:
10017  - name: my-skill
10018    source: ./skills/my-skill/skill.md
10019    runtime: skill
10020"#,
10021        )
10022        .unwrap();
10023
10024        assert_eq!(manifest.artifacts.len(), 1);
10025        let artifact = &manifest.artifacts[0];
10026        assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
10027        assert_eq!(
10028            artifact.source.as_deref(),
10029            Some("./skills/my-skill/skill.md")
10030        );
10031        assert_eq!(artifact.version, "local");
10032    }
10033
10034    #[test]
10035    fn skill_source_directory_path_is_accepted() {
10036        let manifest = RuntimeManifest::from_yaml_str(
10037            r#"
10038name: cap
10039version: 0.0.1
10040artifacts:
10041  - name: my-skill
10042    source: ./skills/my-skill/
10043    runtime: skill
10044"#,
10045        )
10046        .unwrap();
10047
10048        assert_eq!(
10049            manifest.artifacts[0].source.as_deref(),
10050            Some("./skills/my-skill/")
10051        );
10052        assert_eq!(manifest.artifacts[0].version, "local");
10053    }
10054
10055    #[test]
10056    fn skill_source_with_explicit_version_substitutes_local() {
10057        // Both set is allowed; version is ignored and "local" is substituted.
10058        let manifest = RuntimeManifest::from_yaml_str(
10059            r#"
10060name: cap
10061version: 0.0.1
10062artifacts:
10063  - name: my-skill
10064    source: ./skills/my-skill/skill.md
10065    version: 9.9.9
10066    runtime: skill
10067"#,
10068        )
10069        .unwrap();
10070
10071        assert_eq!(manifest.artifacts[0].version, "local");
10072    }
10073
10074    #[test]
10075    fn local_source_true_allows_source_on_non_skill_runtime() {
10076        for runtime in ["tool", "driver", "hook"] {
10077            let yaml = format!(
10078                r#"
10079name: cap
10080version: 0.0.1
10081artifacts:
10082  - name: my-thing
10083    source: ./local/path
10084    local_source: true
10085    runtime: {runtime}
10086"#
10087            );
10088            let manifest = RuntimeManifest::from_yaml_str(&yaml)
10089                .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
10090            let artifact = &manifest.artifacts[0];
10091            assert!(artifact.local_source, "runtime {runtime}");
10092            assert_eq!(artifact.source.as_deref(), Some("./local/path"));
10093            assert_eq!(artifact.version, "local", "runtime {runtime}");
10094        }
10095    }
10096
10097    #[test]
10098    fn local_source_false_on_skill_with_source_is_error() {
10099        // The gate reads the declared field, not the role: an explicit `local_source: false`
10100        // overrides the skill default and rejects `source:`.
10101        let err = RuntimeManifest::from_yaml_str(
10102            r#"
10103name: cap
10104version: 0.0.1
10105artifacts:
10106  - name: my-skill
10107    source: ./skills/my-skill
10108    local_source: false
10109    runtime: skill
10110"#,
10111        )
10112        .unwrap_err();
10113        let msg = err.to_string();
10114        assert!(msg.contains("'local_source: true'"), "error was: {msg}");
10115        assert!(msg.contains("my-skill"), "error was: {msg}");
10116    }
10117
10118    #[test]
10119    fn local_source_defaults_from_role_when_absent() {
10120        let manifest = RuntimeManifest::from_yaml_str(
10121            r#"
10122name: cap
10123version: 0.0.1
10124artifacts:
10125  - name: my-skill
10126    source: ./skills/my-skill
10127    runtime: skill
10128  - name: local/my-tool
10129    version: 1.0.0
10130    runtime: tool
10131"#,
10132        )
10133        .unwrap();
10134        assert!(manifest.artifacts[0].local_source, "skill defaults to true");
10135        assert!(
10136            !manifest.artifacts[1].local_source,
10137            "tool defaults to false"
10138        );
10139    }
10140
10141    #[test]
10142    fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
10143        let manifest = RuntimeManifest::from_yaml_str(
10144            r#"
10145name: cap
10146version: 0.0.1
10147artifacts:
10148  - name: local/my-tool
10149    version: 1.0.0
10150    runtime: tool
10151    prompt_payload: true
10152  - name: murmur/driver-anthropic
10153    version: 0.1.0
10154    runtime: driver
10155    gateway:
10156      endpoint: http://127.0.0.1:8080
10157      keyless: true
10158inference:
10159  model: test-model
10160  system_prompt_artifact: local/my-tool
10161  driver:
10162    artifact: murmur/driver-anthropic
10163"#,
10164        )
10165        .unwrap();
10166        assert!(manifest.artifacts[0].prompt_payload);
10167    }
10168
10169    #[test]
10170    fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
10171        let err = RuntimeManifest::from_yaml_str(
10172            r#"
10173name: cap
10174version: 0.0.1
10175artifacts:
10176  - name: local/my-skill
10177    version: 1.0.0
10178    runtime: skill
10179    prompt_payload: false
10180  - name: murmur/driver-anthropic
10181    version: 0.1.0
10182    runtime: driver
10183    gateway:
10184      endpoint: http://127.0.0.1:8080
10185      keyless: true
10186inference:
10187  model: test-model
10188  system_prompt_artifact: local/my-skill
10189  driver:
10190    artifact: murmur/driver-anthropic
10191"#,
10192        )
10193        .unwrap_err();
10194        let msg = err.to_string();
10195        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
10196    }
10197
10198    #[test]
10199    fn prompt_payload_defaults_from_role_when_absent() {
10200        let manifest = RuntimeManifest::from_yaml_str(
10201            r#"
10202name: cap
10203version: 0.0.1
10204artifacts:
10205  - name: local/my-skill
10206    version: 1.0.0
10207    runtime: skill
10208  - name: local/my-tool
10209    version: 1.0.0
10210    runtime: tool
10211"#,
10212        )
10213        .unwrap();
10214        assert!(
10215            manifest.artifacts[0].prompt_payload,
10216            "skill defaults to true"
10217        );
10218        assert!(
10219            !manifest.artifacts[1].prompt_payload,
10220            "tool defaults to false"
10221        );
10222    }
10223
10224    #[test]
10225    fn source_on_non_skill_runtime_is_error() {
10226        for runtime in ["tool", "driver", "hook"] {
10227            let yaml = format!(
10228                r#"
10229name: cap
10230version: 0.0.1
10231artifacts:
10232  - name: my-thing
10233    source: ./local/path
10234    runtime: {runtime}
10235"#
10236            );
10237            let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
10238            let msg = err.to_string();
10239            assert!(
10240                msg.contains("'local_source: true'"),
10241                "runtime {runtime}: error was: {msg}"
10242            );
10243            assert!(
10244                msg.contains("my-thing"),
10245                "runtime {runtime}: error was: {msg}"
10246            );
10247        }
10248    }
10249
10250    #[test]
10251    fn registry_skill_still_requires_version() {
10252        let err = RuntimeManifest::from_yaml_str(
10253            r#"
10254name: cap
10255version: 0.0.1
10256artifacts:
10257  - name: local/my-skill
10258    runtime: skill
10259"#,
10260        )
10261        .unwrap_err();
10262
10263        let msg = err.to_string();
10264        assert!(
10265            msg.contains("missing required field 'version'"),
10266            "error was: {msg}"
10267        );
10268    }
10269
10270    #[test]
10271    fn rejects_unknown_inference_transport() {
10272        let err = RuntimeManifest::from_yaml_str(
10273            r#"
10274name: cap
10275version: 0.0.1
10276artifacts: []
10277inference:
10278  transport: grpc
10279  model: test-model
10280  driver:
10281    artifact: murmur/driver-anthropic
10282"#,
10283        )
10284        .unwrap_err();
10285
10286        let msg = err.to_string();
10287        assert!(msg.contains("inference.transport"), "error was: {msg}");
10288        assert!(msg.contains("unknown value"), "error was: {msg}");
10289        assert!(msg.contains("grpc"), "error was: {msg}");
10290    }
10291
10292    /// The driver is what knows a harness, so `command:` alone selects nothing: a manifest that
10293    /// names only a command is refused and told which field to add.
10294    #[test]
10295    fn process_transport_with_command_and_no_driver_is_refused() {
10296        let err = RuntimeManifest::from_yaml_str(
10297            r#"
10298name: cap
10299version: 0.0.1
10300artifacts: []
10301inference:
10302  transport: process
10303  command: some-cli
10304  model: some-model
10305  max_turns: 20
10306"#,
10307        )
10308        .unwrap_err();
10309
10310        let msg = err.to_string();
10311        assert!(
10312            msg.contains("inference.driver.artifact"),
10313            "error was: {msg}"
10314        );
10315        assert!(msg.contains("inference.command"), "error was: {msg}");
10316        assert!(
10317            msg.contains("murmur/driver-claude-code"),
10318            "error was: {msg}"
10319        );
10320    }
10321
10322    #[test]
10323    fn process_transport_with_neither_driver_nor_command_is_refused() {
10324        let err = RuntimeManifest::from_yaml_str(
10325            r#"
10326name: cap
10327version: 0.0.1
10328artifacts: []
10329inference:
10330  transport: process
10331  model: some-model
10332"#,
10333        )
10334        .unwrap_err();
10335
10336        let msg = err.to_string();
10337        assert!(
10338            msg.contains("inference.driver.artifact"),
10339            "error was: {msg}"
10340        );
10341        assert!(
10342            msg.contains("murmur/driver-claude-code"),
10343            "error was: {msg}"
10344        );
10345    }
10346
10347    #[test]
10348    fn process_transport_allows_absent_model() {
10349        // model is optional for transport: process — an absent model leaves the choice to the
10350        // harness, which has an account default of its own.
10351        let manifest =
10352            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
10353                .expect("process transport should parse without a model");
10354        let inference = manifest.inference.expect("inference present");
10355        assert_eq!(inference.transport, "process");
10356        assert!(inference.command.is_none());
10357        assert_eq!(
10358            inference.model, "",
10359            "absent model resolves to empty (harness default)"
10360        );
10361    }
10362
10363    fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
10364        format!(
10365            "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n  transport: process\n  \
10366             driver:\n    artifact: local/fixture-process-driver\n{inference_extra}"
10367        )
10368    }
10369
10370    const PROCESS_DRIVER_ENTRY: &str =
10371        "  - name: local/fixture-process-driver\n    version: 0.1.0\n    runtime: driver";
10372
10373    #[test]
10374    fn process_transport_accepts_driver() {
10375        let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10376            PROCESS_DRIVER_ENTRY,
10377            "    config:\n      profile: work\n  command: /usr/local/bin/fixture-cli\n",
10378        ))
10379        .unwrap();
10380
10381        let inference = manifest.inference.expect("inference should exist");
10382        assert_eq!(inference.transport, "process");
10383        let driver = inference.driver.expect("driver should be parsed");
10384        assert_eq!(driver.artifact, "local/fixture-process-driver");
10385        assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
10386        assert_eq!(
10387            inference.command.as_deref(),
10388            Some("/usr/local/bin/fixture-cli")
10389        );
10390    }
10391
10392    #[test]
10393    fn process_transport_accepts_driver_without_command() {
10394        let manifest =
10395            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
10396                .unwrap();
10397
10398        let inference = manifest.inference.expect("inference should exist");
10399        assert_eq!(
10400            inference.driver.map(|d| d.artifact).as_deref(),
10401            Some("local/fixture-process-driver")
10402        );
10403        assert!(inference.command.is_none());
10404        assert_eq!(inference.model, "");
10405    }
10406
10407    #[test]
10408    fn process_driver_entry_must_be_declared() {
10409        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10410            "  - name: local/other\n    version: 0.1.0\n    runtime: tool",
10411            "",
10412        ))
10413        .unwrap_err()
10414        .to_string();
10415        assert!(msg.contains("inference.driver.artifact"), "{msg}");
10416        assert!(
10417            msg.contains("artifact 'local/fixture-process-driver' is not declared in artifacts:"),
10418            "{msg}"
10419        );
10420    }
10421
10422    #[test]
10423    fn process_driver_entry_must_be_runtime_driver() {
10424        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10425            "  - name: local/fixture-process-driver\n    version: 0.1.0\n    runtime: tool",
10426            "",
10427        ))
10428        .unwrap_err()
10429        .to_string();
10430        assert!(
10431            msg.contains("must be declared with runtime: driver"),
10432            "{msg}"
10433        );
10434    }
10435
10436    #[test]
10437    fn process_driver_entry_refuses_capabilities() {
10438        let entry =
10439            format!("{PROCESS_DRIVER_ENTRY}\n    capabilities:\n      env:\n        allow: [HOME]");
10440        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
10441            .unwrap_err()
10442            .to_string();
10443        assert!(msg.contains("granted nothing"), "{msg}");
10444        assert!(msg.contains("inference.driver.config"), "{msg}");
10445    }
10446
10447    #[test]
10448    fn process_driver_entry_refuses_config() {
10449        let entry = format!("{PROCESS_DRIVER_ENTRY}\n    config:\n      profile: work");
10450        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
10451            .unwrap_err()
10452            .to_string();
10453        assert!(msg.contains("granted nothing"), "{msg}");
10454        assert!(msg.contains("remove capabilities:/config:"), "{msg}");
10455    }
10456
10457    #[test]
10458    fn process_transport_rejects_endpoint() {
10459        let err = RuntimeManifest::from_yaml_str(
10460            r#"
10461name: cap
10462version: 0.0.1
10463artifacts: []
10464inference:
10465  transport: process
10466  command: claude
10467  model: claude-haiku-4-5-20251001
10468  endpoint: http://localhost:8080
10469"#,
10470        )
10471        .unwrap_err();
10472
10473        let msg = err.to_string();
10474        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
10475        assert!(msg.contains("was removed"), "error was: {msg}");
10476    }
10477
10478    #[test]
10479    fn process_transport_rejects_api_key() {
10480        // Key assembled at runtime so the source never contains a
10481        // credential-shaped literal that secret scanners could flag.
10482        let key = ["sk-", "ant-", "secret"].concat();
10483        let err = RuntimeManifest::from_yaml_str(&format!(
10484            r#"
10485name: cap
10486version: 0.0.1
10487artifacts: []
10488inference:
10489  transport: process
10490  command: claude
10491  model: claude-haiku-4-5-20251001
10492  api_key: {key}
10493"#
10494        ))
10495        .unwrap_err();
10496
10497        let msg = err.to_string();
10498        assert!(msg.contains("inference.api_key"), "error was: {msg}");
10499        assert!(msg.contains("was removed"), "error was: {msg}");
10500        assert!(!msg.contains(&key), "error was: {msg}");
10501    }
10502
10503    #[test]
10504    fn process_transport_rejects_max_tokens() {
10505        // Rejected even at a perfectly valid value: the CLI subprocess path never builds a
10506        // driver payload, so accepting it would leave it silently inert.
10507        let err = RuntimeManifest::from_yaml_str(
10508            r#"
10509name: cap
10510version: 0.0.1
10511artifacts: []
10512inference:
10513  transport: process
10514  command: claude
10515  model: claude-haiku-4-5-20251001
10516  max_tokens: 4096
10517"#,
10518        )
10519        .unwrap_err();
10520
10521        let msg = err.to_string();
10522        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
10523        assert!(
10524            msg.contains("not valid with transport: process"),
10525            "error was: {msg}"
10526        );
10527    }
10528
10529    /// The harness's own reported counts are what this transport meters, so the ceiling parses
10530    /// here as it does under `http`. A driver that reports no counts is refused at load, not
10531    /// here: the manifest cannot see which driver it names.
10532    #[test]
10533    fn process_transport_carries_max_session_tokens() {
10534        let manifest = RuntimeManifest::from_yaml_str(
10535            r#"
10536name: cap
10537version: 0.0.1
10538artifacts:
10539  - name: murmur/driver-claude-code
10540    version: 0.1.0
10541    runtime: driver
10542inference:
10543  transport: process
10544  driver:
10545    artifact: murmur/driver-claude-code
10546  model: claude-haiku-4-5-20251001
10547  max_session_tokens: 200000
10548"#,
10549        )
10550        .unwrap();
10551
10552        assert_eq!(
10553            manifest.inference.unwrap().max_session_tokens,
10554            Some(200_000)
10555        );
10556    }
10557
10558    #[test]
10559    fn process_transport_rejects_a_zero_max_session_tokens() {
10560        let err = RuntimeManifest::from_yaml_str(
10561            r#"
10562name: cap
10563version: 0.0.1
10564artifacts:
10565  - name: murmur/driver-claude-code
10566    version: 0.1.0
10567    runtime: driver
10568inference:
10569  transport: process
10570  driver:
10571    artifact: murmur/driver-claude-code
10572  model: claude-haiku-4-5-20251001
10573  max_session_tokens: 0
10574"#,
10575        )
10576        .unwrap_err();
10577
10578        let msg = err.to_string();
10579        assert!(
10580            msg.contains("inference.max_session_tokens"),
10581            "error was: {msg}"
10582        );
10583        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
10584    }
10585
10586    #[test]
10587    fn inference_max_session_tokens_round_trips() {
10588        let manifest = RuntimeManifest::from_yaml_str(
10589            r#"
10590name: cap
10591version: 0.0.1
10592artifacts:
10593  - name: murmur/driver-anthropic
10594    version: 0.1.0
10595    runtime: driver
10596    gateway:
10597      endpoint: https://api.anthropic.com
10598      api_key: test-key
10599inference:
10600  transport: http
10601  model: claude-opus-4-5
10602  max_session_tokens: 5000000000
10603  driver:
10604    artifact: murmur/driver-anthropic
10605"#,
10606        )
10607        .unwrap();
10608
10609        assert_eq!(
10610            manifest.inference.unwrap().max_session_tokens,
10611            Some(5_000_000_000)
10612        );
10613    }
10614
10615    #[test]
10616    fn inference_max_session_tokens_absent_is_none() {
10617        let manifest = RuntimeManifest::from_yaml_str(
10618            r#"
10619name: cap
10620version: 0.0.1
10621artifacts:
10622  - name: murmur/driver-anthropic
10623    version: 0.1.0
10624    runtime: driver
10625    gateway:
10626      endpoint: https://api.anthropic.com
10627      api_key: test-key
10628inference:
10629  transport: http
10630  model: claude-opus-4-5
10631  driver:
10632    artifact: murmur/driver-anthropic
10633"#,
10634        )
10635        .unwrap();
10636
10637        assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
10638    }
10639
10640    #[test]
10641    fn inference_max_session_tokens_zero_is_rejected() {
10642        let err = RuntimeManifest::from_yaml_str(
10643            r#"
10644name: cap
10645version: 0.0.1
10646artifacts:
10647  - name: murmur/driver-anthropic
10648    version: 0.1.0
10649    runtime: driver
10650    gateway:
10651      endpoint: https://api.anthropic.com
10652      api_key: test-key
10653inference:
10654  transport: http
10655  model: claude-opus-4-5
10656  max_session_tokens: 0
10657  driver:
10658    artifact: murmur/driver-anthropic
10659"#,
10660        )
10661        .unwrap_err();
10662
10663        let msg = err.to_string();
10664        assert!(
10665            msg.contains("inference.max_session_tokens"),
10666            "error was: {msg}"
10667        );
10668        assert!(msg.contains("greater than 0"), "error was: {msg}");
10669    }
10670
10671    /// An http capsule manifest with `extra` appended to its `inference:` block.
10672    fn http_manifest_with_inference(extra: &str) -> String {
10673        format!(
10674            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    \
10675             version: 0.1.0\n    runtime: driver\n    gateway:\n      \
10676             endpoint: https://api.anthropic.com\n      api_key: test-key\n\
10677             inference:\n  transport: http\n  model: claude-opus-4-5\n  driver:\n    \
10678             artifact: murmur/driver-anthropic\n{extra}"
10679        )
10680    }
10681
10682    #[test]
10683    fn inference_tool_refresh_absent_is_compaction() {
10684        let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
10685        assert_eq!(
10686            manifest.inference.unwrap().tool_refresh,
10687            ToolRefresh::Compaction
10688        );
10689        assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
10690    }
10691
10692    #[test]
10693    fn inference_tool_refresh_parses_both_values() {
10694        for (value, expected) in [
10695            ("compaction", ToolRefresh::Compaction),
10696            ("immediate", ToolRefresh::Immediate),
10697        ] {
10698            let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
10699                "  tool_refresh: {value}\n"
10700            )))
10701            .unwrap();
10702            assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
10703            assert_eq!(expected.wire_name(), value);
10704            assert!(
10705                manifest.unknown_keys.is_empty(),
10706                "tool_refresh is a known key: {:?}",
10707                manifest.unknown_keys
10708            );
10709        }
10710    }
10711
10712    #[test]
10713    fn inference_tool_refresh_rejects_an_unknown_value() {
10714        let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
10715            "  tool_refresh: sometimes\n",
10716        ))
10717        .unwrap_err();
10718        match err {
10719            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10720                assert_eq!(field, "inference.tool_refresh");
10721                assert_eq!(
10722                    message,
10723                    "must be one of: compaction, immediate; got 'sometimes'"
10724                );
10725            }
10726            other => panic!("unexpected error: {other:?}"),
10727        }
10728    }
10729
10730    /// Refused whatever its value: the harness owns its tool list under `transport: process`,
10731    /// so the key would be silently inert there.
10732    #[test]
10733    fn process_transport_rejects_tool_refresh() {
10734        for value in ["compaction", "immediate"] {
10735            let err = RuntimeManifest::from_yaml_str(&format!(
10736                "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  \
10737                 command: claude\n  model: claude-haiku-4-5-20251001\n  tool_refresh: {value}\n"
10738            ))
10739            .unwrap_err();
10740            match err {
10741                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10742                    assert_eq!(field, "inference.tool_refresh");
10743                    assert_eq!(message, "is not valid with transport: process");
10744                }
10745                other => panic!("unexpected error for {value}: {other:?}"),
10746            }
10747        }
10748    }
10749
10750    #[test]
10751    fn inference_max_tokens_round_trips() {
10752        let manifest = RuntimeManifest::from_yaml_str(
10753            r#"
10754name: cap
10755version: 0.0.1
10756artifacts:
10757  - name: murmur/driver-anthropic
10758    version: 0.1.0
10759    runtime: driver
10760    gateway:
10761      endpoint: https://api.anthropic.com
10762      api_key: test-key
10763inference:
10764  transport: http
10765  model: claude-opus-4-5
10766  max_tokens: 4096
10767  driver:
10768    artifact: murmur/driver-anthropic
10769"#,
10770        )
10771        .unwrap();
10772
10773        let inference = manifest.inference.unwrap();
10774        assert_eq!(inference.max_tokens, Some(4096));
10775    }
10776
10777    #[test]
10778    fn inference_max_tokens_absent_is_none() {
10779        // Absent means "the manifest didn't set it" all the way down; the runtime resolves each
10780        // driver choice's cap from its driver's `models:` table, not here.
10781        let manifest = RuntimeManifest::from_yaml_str(
10782            r#"
10783name: cap
10784version: 0.0.1
10785artifacts:
10786  - name: murmur/driver-anthropic
10787    version: 0.1.0
10788    runtime: driver
10789    gateway:
10790      endpoint: https://api.anthropic.com
10791      api_key: test-key
10792inference:
10793  transport: http
10794  model: claude-opus-4-5
10795  driver:
10796    artifact: murmur/driver-anthropic
10797"#,
10798        )
10799        .unwrap();
10800
10801        assert_eq!(manifest.inference.unwrap().max_tokens, None);
10802    }
10803
10804    #[test]
10805    fn inference_max_tokens_zero_is_rejected() {
10806        let err = RuntimeManifest::from_yaml_str(
10807            r#"
10808name: cap
10809version: 0.0.1
10810artifacts:
10811  - name: murmur/driver-anthropic
10812    version: 0.1.0
10813    runtime: driver
10814    gateway:
10815      endpoint: https://api.anthropic.com
10816      api_key: test-key
10817inference:
10818  transport: http
10819  model: claude-opus-4-5
10820  max_tokens: 0
10821  driver:
10822    artifact: murmur/driver-anthropic
10823"#,
10824        )
10825        .unwrap_err();
10826
10827        let msg = err.to_string();
10828        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
10829        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
10830    }
10831
10832    #[test]
10833    fn inference_max_tokens_large_value_is_accepted() {
10834        // Manifest validation stays advisory: an over-large cap is the provider's to reject
10835        // at request time, not ours to clamp or block at load time.
10836        let manifest = RuntimeManifest::from_yaml_str(
10837            r#"
10838name: cap
10839version: 0.0.1
10840artifacts:
10841  - name: murmur/driver-anthropic
10842    version: 0.1.0
10843    runtime: driver
10844    gateway:
10845      endpoint: https://api.anthropic.com
10846      api_key: test-key
10847inference:
10848  transport: http
10849  model: claude-opus-4-5
10850  max_tokens: 999999
10851  driver:
10852    artifact: murmur/driver-anthropic
10853"#,
10854        )
10855        .unwrap();
10856
10857        assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
10858    }
10859
10860    #[test]
10861    fn inference_and_context_max_tokens_are_independent() {
10862        // Two unrelated concepts that happen to share a leaf name: per-turn output cap vs.
10863        // session compaction budget. Neither may leak into the other.
10864        let manifest = RuntimeManifest::from_yaml_str(
10865            r#"
10866name: cap
10867version: 0.0.1
10868artifacts:
10869  - name: murmur/driver-anthropic
10870    version: 0.1.0
10871    runtime: driver
10872    gateway:
10873      endpoint: https://api.anthropic.com
10874      api_key: test-key
10875inference:
10876  transport: http
10877  model: claude-opus-4-5
10878  max_tokens: 4096
10879  driver:
10880    artifact: murmur/driver-anthropic
10881context:
10882  max_tokens: 200000
10883"#,
10884        )
10885        .unwrap();
10886
10887        assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
10888        assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
10889    }
10890
10891    #[test]
10892    fn hook_config_defaults_when_fields_absent() {
10893        let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
10894        assert_eq!(config.binding, HookBinding::All);
10895        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
10896        assert_eq!(config.commit_policy, HookCommitPolicy::None);
10897    }
10898
10899    #[test]
10900    fn hook_config_parses_all_fields() {
10901        let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
10902        let config = parse_hook_config_from_yaml(yaml).unwrap();
10903        assert_eq!(config.binding, HookBinding::OnCompaction);
10904        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
10905        assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
10906    }
10907
10908    #[test]
10909    fn hook_config_parses_on_task_start() {
10910        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
10911        let config = parse_hook_config_from_yaml(yaml).unwrap();
10912        assert_eq!(config.binding, HookBinding::OnTaskStart);
10913    }
10914
10915    #[test]
10916    fn hook_config_parses_on_task_end() {
10917        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
10918        let config = parse_hook_config_from_yaml(yaml).unwrap();
10919        assert_eq!(config.binding, HookBinding::OnTaskEnd);
10920    }
10921
10922    #[test]
10923    fn hook_config_unknown_binding_lists_task_events() {
10924        let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
10925        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10926        assert!(err.contains("on-task-start"), "error was: {err}");
10927        assert!(err.contains("on-task-end"), "error was: {err}");
10928    }
10929
10930    #[test]
10931    fn hook_config_async_with_commit_is_rejected() {
10932        let yaml =
10933            "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
10934        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10935        assert!(err.contains("async-with-commit"), "error was: {err}");
10936    }
10937
10938    #[test]
10939    fn hook_config_on_stage_async_is_rejected() {
10940        let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
10941        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10942        assert!(err.contains("on-stage"), "error was: {err}");
10943        assert!(err.contains("blocking"), "error was: {err}");
10944    }
10945
10946    #[test]
10947    fn hook_config_async_none_is_valid() {
10948        let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
10949        let config = parse_hook_config_from_yaml(yaml).unwrap();
10950        assert_eq!(config.execution_mode, HookExecutionMode::Async);
10951        assert_eq!(config.commit_policy, HookCommitPolicy::None);
10952    }
10953
10954    #[test]
10955    fn hook_config_parses_reopen_task_commit_policy() {
10956        let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
10957        let config = parse_hook_config_from_yaml(yaml).unwrap();
10958        assert_eq!(config.binding, HookBinding::OnTaskEnd);
10959        assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
10960    }
10961
10962    #[test]
10963    fn hook_config_unknown_commit_policy_lists_reopen_task() {
10964        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
10965        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10966        assert!(err.contains("reopen-task"), "error was: {err}");
10967    }
10968
10969    #[test]
10970    fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
10971        for binding in ["on-shell", "on-tool-call"] {
10972            let yaml =
10973                format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
10974            let config = parse_hook_config_from_yaml(&yaml).unwrap();
10975            assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
10976        }
10977        assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
10978    }
10979
10980    /// `deny` is the one policy an omitted `binding:` cannot carry: a hook that does not name
10981    /// which of the two gated events it decides on would be asked at both.
10982    #[test]
10983    fn hook_config_deny_without_a_binding_is_rejected() {
10984        let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
10985        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10986        assert!(
10987            err.contains("requires an explicit binding"),
10988            "error was: {err}"
10989        );
10990        assert!(err.contains("on-shell"), "error was: {err}");
10991        assert!(err.contains("on-tool-call"), "error was: {err}");
10992    }
10993
10994    /// Every other binding refuses `deny` through the existing binding-mismatch check.
10995    #[test]
10996    fn hook_config_deny_on_an_ungated_binding_is_rejected() {
10997        let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
10998        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10999        assert!(err.contains("is not valid for binding"), "error was: {err}");
11000        assert!(err.contains("replace-context"), "error was: {err}");
11001    }
11002
11003    /// The unchanged async/commit check covers `deny` for free; this pins that it does.
11004    #[test]
11005    fn hook_config_async_deny_is_rejected() {
11006        let yaml =
11007            "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
11008        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11009        assert!(err.contains("async-with-commit"), "error was: {err}");
11010        assert!(err.contains("deny"), "error was: {err}");
11011    }
11012
11013    #[test]
11014    fn hook_config_unknown_commit_policy_lists_deny() {
11015        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11016        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11017        assert!(err.contains("deny"), "error was: {err}");
11018    }
11019
11020    /// The existing, unmodified async/commit validation must also reject
11021    /// `commit_policy: reopen-task` combined with `execution_mode: async`.
11022    #[test]
11023    fn hook_config_async_reopen_task_is_rejected() {
11024        let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
11025        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11026        assert!(err.contains("async-with-commit"), "error was: {err}");
11027        assert!(err.contains("reopen-task"), "error was: {err}");
11028    }
11029
11030    // ── binding is the single source of truth for what a hook commits ─────────────
11031
11032    /// The two bindings whose events honor no `hook-output` arm at all cannot declare
11033    /// any non-`none` `commit_policy`. The error names the binding, the declared policy,
11034    /// and `none` as what the binding honors.
11035    #[test]
11036    fn hook_config_non_committing_binding_rejects_any_commit_policy() {
11037        for binding in ["on-session-start", "on-session-end"] {
11038            let yaml = format!(
11039                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
11040            );
11041            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11042            assert!(err.contains(binding), "error was: {err}");
11043            assert!(err.contains("replace-context"), "error was: {err}");
11044            assert!(
11045                err.contains("honors commit_policy 'none'"),
11046                "error was: {err}"
11047            );
11048        }
11049    }
11050
11051    /// A binding that honors one arm rejects every *other* policy, naming the one it does
11052    /// honor so the fix is obvious from the message alone.
11053    #[test]
11054    fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
11055        for (binding, declared, honored) in [
11056            ("on-stage", "reopen-task", "write-manifests"),
11057            ("on-compaction", "write-manifests", "replace-context"),
11058            ("on-task-end", "replace-context", "reopen-task"),
11059            ("on-task-start", "replace-context", "seed-context"),
11060            ("on-shell", "replace-context", "deny"),
11061            ("on-tool-call", "replace-context", "deny"),
11062        ] {
11063            let yaml = format!(
11064                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
11065            );
11066            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11067            assert!(err.contains(binding), "error was: {err}");
11068            assert!(
11069                err.contains(&format!("commit_policy '{declared}' is not valid")),
11070                "error was: {err}"
11071            );
11072            assert!(
11073                err.contains(&format!("honors commit_policy '{honored}'")),
11074                "error was: {err}"
11075            );
11076        }
11077    }
11078
11079    /// `on-inference` honors the `artifact` arm, which has no `commit_policy` spelling —
11080    /// so *no* non-`none` policy is ever valid for it, including `write-manifests`.
11081    #[test]
11082    fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
11083        for declared in ["replace-context", "write-manifests", "reopen-task"] {
11084            let yaml = format!(
11085                "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
11086            );
11087            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11088            assert!(err.contains("on-inference"), "error was: {err}");
11089            assert!(err.contains(declared), "error was: {err}");
11090            assert!(
11091                err.contains("honors commit_policy 'none'"),
11092                "error was: {err}"
11093            );
11094            assert!(err.contains("artifact"), "error was: {err}");
11095        }
11096    }
11097
11098    /// A binding declaring exactly the policy it honors is accepted, for every
11099    /// representable pair.
11100    #[test]
11101    fn hook_config_matching_binding_and_commit_policy_is_valid() {
11102        for (binding, policy, expected) in [
11103            (
11104                "on-stage",
11105                "write-manifests",
11106                HookCommitPolicy::WriteManifests,
11107            ),
11108            (
11109                "on-compaction",
11110                "replace-context",
11111                HookCommitPolicy::ReplaceContext,
11112            ),
11113            ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
11114            ("on-shell", "deny", HookCommitPolicy::Deny),
11115            ("on-tool-call", "deny", HookCommitPolicy::Deny),
11116        ] {
11117            let yaml =
11118                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
11119            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11120            assert_eq!(config.commit_policy, expected);
11121        }
11122    }
11123
11124    /// Every binding may declare `commit_policy: none` — including the ones that honor
11125    /// an arm, which simply means the hook opts out of committing.
11126    #[test]
11127    fn hook_config_commit_policy_none_is_valid_for_every_binding() {
11128        for binding in [
11129            "on-stage",
11130            "on-session-start",
11131            "on-task-start",
11132            "on-inference",
11133            "on-tool-call",
11134            "on-shell",
11135            "on-compaction",
11136            "on-task-end",
11137            "on-session-end",
11138        ] {
11139            let yaml =
11140                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
11141            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11142            assert_eq!(config.commit_policy, HookCommitPolicy::None);
11143        }
11144    }
11145
11146    /// Resolved ambiguity: an omitted `binding:` is `HookBinding::All`, which is dispatched
11147    /// to every event — including every one that honors an arm — so every `commit_policy` is
11148    /// accepted for it. Deliberate: no narrowing for `All`. `deny` is the one exception,
11149    /// covered by [`hook_config_deny_without_a_binding_is_rejected`].
11150    #[test]
11151    fn hook_config_omitted_binding_accepts_every_commit_policy() {
11152        for (policy, expected) in [
11153            ("replace-context", HookCommitPolicy::ReplaceContext),
11154            ("write-manifests", HookCommitPolicy::WriteManifests),
11155            ("reopen-task", HookCommitPolicy::ReopenTask),
11156            ("none", HookCommitPolicy::None),
11157        ] {
11158            let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
11159            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11160            assert_eq!(config.binding, HookBinding::All);
11161            assert_eq!(config.commit_policy, expected);
11162        }
11163    }
11164
11165    /// Every hook artifact shipped in `default-artifacts` today must keep parsing after the
11166    /// binding/commit_policy check exists — this change is non-breaking for all eight. The
11167    /// field combinations are reproduced literally rather than read from that repo, so this
11168    /// suite stays self-contained.
11169    #[test]
11170    fn hook_config_shipped_default_artifact_manifests_still_parse() {
11171        let shipped: [(&str, &str, HookConfig); 8] = [
11172            (
11173                "murmur-hook-compact",
11174                "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
11175                HookConfig {
11176                    binding: HookBinding::OnCompaction,
11177                    execution_mode: HookExecutionMode::Blocking,
11178                    commit_policy: HookCommitPolicy::ReplaceContext,
11179                },
11180            ),
11181            (
11182                "murmur-hook-shell-desc",
11183                "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
11184                HookConfig {
11185                    binding: HookBinding::OnStage,
11186                    execution_mode: HookExecutionMode::Blocking,
11187                    commit_policy: HookCommitPolicy::WriteManifests,
11188                },
11189            ),
11190            (
11191                // No `binding:` line in the shipped manifest — `All`, not `on-task-end`.
11192                "murmur-hook-regression-verifier",
11193                "execution_mode: blocking\ncommit_policy: reopen-task\n",
11194                HookConfig {
11195                    binding: HookBinding::All,
11196                    execution_mode: HookExecutionMode::Blocking,
11197                    commit_policy: HookCommitPolicy::ReopenTask,
11198                },
11199            ),
11200            (
11201                "murmur-hook-memory",
11202                "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
11203                HookConfig {
11204                    binding: HookBinding::OnTaskStart,
11205                    execution_mode: HookExecutionMode::Blocking,
11206                    commit_policy: HookCommitPolicy::SeedContext,
11207                },
11208            ),
11209            (
11210                "murmur-hook-debug",
11211                "execution_mode: async\ncommit_policy: none\n",
11212                HookConfig {
11213                    binding: HookBinding::All,
11214                    execution_mode: HookExecutionMode::Async,
11215                    commit_policy: HookCommitPolicy::None,
11216                },
11217            ),
11218            (
11219                "murmur-hook-diff-summary",
11220                "execution_mode: blocking\ncommit_policy: none\n",
11221                HookConfig {
11222                    binding: HookBinding::All,
11223                    execution_mode: HookExecutionMode::Blocking,
11224                    commit_policy: HookCommitPolicy::None,
11225                },
11226            ),
11227            ("murmur-hook-eval", "", HookConfig::default()),
11228            ("murmur-hook-grafana", "", HookConfig::default()),
11229        ];
11230
11231        for (name, fields, expected) in shipped {
11232            let yaml = format!("name: {name}\nruntime: hook\n{fields}");
11233            let config = parse_hook_config_from_yaml(&yaml)
11234                .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
11235            assert_eq!(config, expected, "shipped hook {name}");
11236        }
11237    }
11238
11239    /// The manifest-side honored-policy table agrees with the manifest spellings the
11240    /// parser accepts; `capsule-runtime` separately cross-checks it against its own
11241    /// `HONORED_OUTPUT_ARM` dispatch table.
11242    #[test]
11243    fn commit_policy_for_binding_matches_the_declarable_pairs() {
11244        assert_eq!(
11245            commit_policy_for_binding(&HookBinding::OnStage),
11246            Some(HookCommitPolicy::WriteManifests)
11247        );
11248        assert_eq!(
11249            commit_policy_for_binding(&HookBinding::OnCompaction),
11250            Some(HookCommitPolicy::ReplaceContext)
11251        );
11252        assert_eq!(
11253            commit_policy_for_binding(&HookBinding::OnTaskEnd),
11254            Some(HookCommitPolicy::ReopenTask)
11255        );
11256        assert_eq!(
11257            commit_policy_for_binding(&HookBinding::OnTaskStart),
11258            Some(HookCommitPolicy::SeedContext)
11259        );
11260        assert_eq!(
11261            commit_policy_for_binding(&HookBinding::OnToolCall),
11262            Some(HookCommitPolicy::Deny)
11263        );
11264        assert_eq!(
11265            commit_policy_for_binding(&HookBinding::OnShell),
11266            Some(HookCommitPolicy::Deny)
11267        );
11268        // `on-inference` honors `artifact`, which has no `commit_policy` spelling; the
11269        // other two honor nothing. `All` is unconstrained by design, apart from the `deny`
11270        // carve-out the parser applies.
11271        for binding in [
11272            HookBinding::OnSessionStart,
11273            HookBinding::OnInference,
11274            HookBinding::OnSessionEnd,
11275            HookBinding::All,
11276        ] {
11277            assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
11278        }
11279    }
11280
11281    /// The `on-task-start` pairing is declarable end to end: the parser accepts it, and
11282    /// refuses the same policy on any other binding by name.
11283    #[test]
11284    fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
11285        let config = parse_hook_config_from_yaml(
11286            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
11287             commit_policy: seed-context\n",
11288        )
11289        .expect("on-task-start + seed-context is the declarable pairing");
11290        assert_eq!(config.binding, HookBinding::OnTaskStart);
11291        assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
11292
11293        let error = parse_hook_config_from_yaml(
11294            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
11295             commit_policy: seed-context\n",
11296        )
11297        .expect_err("on-compaction cannot commit a seed");
11298        assert!(error.contains("on-compaction"), "{error}");
11299        assert!(error.contains("seed-context"), "{error}");
11300    }
11301
11302    /// The unknown-value diagnostic lists every accepted spelling, so an operator who
11303    /// mistypes one reads the whole set rather than guessing.
11304    #[test]
11305    fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
11306        let error = parse_hook_config_from_yaml(
11307            "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
11308        )
11309        .expect_err("a misspelled policy is rejected");
11310        assert!(error.contains("seed-context"), "{error}");
11311    }
11312
11313    // ── context.seed_budget / context.seed_overflow_margin ───────────────────
11314
11315    fn context_of(yaml_block: &str) -> Option<ContextConfig> {
11316        RuntimeManifest::from_yaml_str(&format!(
11317            "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
11318        ))
11319        .expect("the manifest under test parses")
11320        .context
11321    }
11322
11323    #[test]
11324    fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
11325        let context = context_of(
11326            "context:\n  max_tokens: 200000\n  seed_budget: 0.10\n  seed_overflow_margin: 0.10\n",
11327        )
11328        .expect("a declared context block parses to Some");
11329        assert_eq!(context.max_tokens, Some(200_000));
11330        assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
11331        assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
11332    }
11333
11334    #[test]
11335    fn seed_budget_and_overflow_margin_default_when_omitted() {
11336        let context = context_of("context:\n  max_tokens: 200000\n")
11337            .expect("a declared context block parses to Some");
11338        assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
11339        assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
11340    }
11341
11342    /// No `context:` block at all stays `None` — the two fractions default *within* a
11343    /// declared block, and never conjure one that the operator did not write.
11344    #[test]
11345    fn seed_budget_defaults_do_not_create_a_context_block() {
11346        assert_eq!(context_of(""), None);
11347    }
11348
11349    #[test]
11350    fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
11351        for (block, field) in [
11352            ("context:\n  seed_budget: 1.5\n", "context.seed_budget"),
11353            ("context:\n  seed_budget: -0.1\n", "context.seed_budget"),
11354            (
11355                "context:\n  seed_overflow_margin: 2.0\n",
11356                "context.seed_overflow_margin",
11357            ),
11358        ] {
11359            let error = RuntimeManifest::from_yaml_str(&format!(
11360                "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
11361            ))
11362            .expect_err("an out-of-range fraction is rejected");
11363            match error {
11364                RuntimeManifestError::InvalidInferenceConfig {
11365                    field: got,
11366                    ref message,
11367                } => {
11368                    assert_eq!(got, field);
11369                    assert!(message.contains("0.0 and 1.0"), "{message}");
11370                }
11371                other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
11372            }
11373        }
11374    }
11375
11376    #[test]
11377    fn parse_tool_implementation_defaults_to_wasm() {
11378        let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
11379        assert_eq!(impl_, ArtifactImplementation::Wasm);
11380    }
11381
11382    #[test]
11383    fn parse_tool_implementation_native() {
11384        let impl_ = parse_tool_implementation_from_yaml(
11385            "name: my-tool\nruntime: tool\nimplementation: native\n",
11386        );
11387        assert_eq!(impl_, ArtifactImplementation::Native);
11388    }
11389
11390    #[test]
11391    fn deploy_block_mur_version_parses() {
11392        let manifest = RuntimeManifest::from_yaml_str(
11393            "name: cap\nversion: 0.1.0\nartifacts: []\ndeploy:\n  mur_version: \"0.4.5\"\n",
11394        )
11395        .unwrap();
11396        assert_eq!(manifest.deploy_mur_version(), Some("0.4.5"));
11397        assert_eq!(
11398            manifest.deploy,
11399            Some(DeployConfig {
11400                mur_version: Some("0.4.5".to_string())
11401            })
11402        );
11403    }
11404
11405    #[test]
11406    fn deploy_block_absent_is_none() {
11407        let manifest =
11408            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
11409        assert_eq!(manifest.deploy, None);
11410        assert_eq!(manifest.deploy_mur_version(), None);
11411    }
11412
11413    /// A blank pin and an empty block both mean "install the deploying mur's own version".
11414    #[test]
11415    fn deploy_block_blank_mur_version_is_none() {
11416        for deploy in [
11417            "deploy:\n  mur_version: \"\"\n",
11418            "deploy:\n  mur_version: \"  \"\n",
11419        ] {
11420            let manifest = RuntimeManifest::from_yaml_str(&format!(
11421                "name: cap\nversion: 0.1.0\nartifacts: []\n{deploy}"
11422            ))
11423            .unwrap();
11424            assert_eq!(manifest.deploy_mur_version(), None, "{deploy:?}");
11425        }
11426        let manifest =
11427            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\ndeploy: {}\n").unwrap();
11428        assert_eq!(manifest.deploy, Some(DeployConfig::default()));
11429        assert_eq!(manifest.deploy_mur_version(), None);
11430    }
11431
11432    #[test]
11433    fn deploy_block_misspelled_key_is_reported_under_deploy() {
11434        assert_eq!(
11435            unknown_keys_of("name: cap\nversion: 0.1.0\ndeploy:\n  mur_versoin: \"0.4.5\"\n"),
11436            vec![(
11437                "mur_versoin".to_string(),
11438                "deploy".to_string(),
11439                Some("mur_version".to_string())
11440            )]
11441        );
11442    }
11443
11444    #[test]
11445    fn a_top_level_mur_version_is_refused_naming_deploy_mur_version() {
11446        for pin in [
11447            "mur_version: \"0.4.5\"\n",
11448            "mur_version: 0.4.0\n",
11449            "mur_version: \"\"\n",
11450            "mur_version:\n",
11451        ] {
11452            let error = RuntimeManifest::from_yaml_str(&format!(
11453                "name: cap\nversion: 0.1.0\nartifacts: []\n{pin}"
11454            ))
11455            .expect_err(pin);
11456            assert!(
11457                matches!(error, RuntimeManifestError::TopLevelMurVersion),
11458                "{pin:?}: {error:?}"
11459            );
11460            assert!(error.to_string().contains("deploy.mur_version"), "{error}");
11461        }
11462        assert_eq!(
11463            RuntimeManifestError::TopLevelMurVersion.to_string(),
11464            "murmur.yaml: 'mur_version' is not a top-level key — the mur release a deploy \
11465             installs is pinned as 'deploy.mur_version'"
11466        );
11467    }
11468
11469    #[test]
11470    fn a_top_level_mur_version_is_refused_beside_a_deploy_block() {
11471        let yaml = "name: cap\nversion: 0.1.0\nmur_version: \"0.4.5\"\ndeploy:\n  mur_version: \"0.4.5\"\n";
11472        assert!(matches!(
11473            RuntimeManifest::from_yaml_str(yaml),
11474            Err(RuntimeManifestError::TopLevelMurVersion)
11475        ));
11476        assert!(matches!(
11477            RuntimeManifest::from_yaml_str_without_secrets(yaml),
11478            Err(RuntimeManifestError::TopLevelMurVersion)
11479        ));
11480    }
11481
11482    #[test]
11483    fn inference_max_turns_absent_sets_no_cap() {
11484        let manifest = RuntimeManifest::from_yaml_str(
11485            "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",
11486        ).unwrap();
11487        assert_eq!(manifest.inference.unwrap().max_turns, None);
11488    }
11489
11490    #[test]
11491    fn inference_max_turns_explicit_value() {
11492        let manifest = RuntimeManifest::from_yaml_str(
11493            "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",
11494        ).unwrap();
11495        assert_eq!(manifest.inference.unwrap().max_turns, Some(20));
11496    }
11497
11498    #[test]
11499    fn inference_max_turns_zero_is_rejected() {
11500        let err = RuntimeManifest::from_yaml_str(
11501            "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",
11502        ).unwrap_err();
11503        let msg = err.to_string();
11504        assert!(msg.contains("inference.max_turns"), "error was: {msg}");
11505        assert!(msg.contains("greater than 0"), "error was: {msg}");
11506    }
11507
11508    /// A `lifecycle:` block that says nothing about the budget still permits one reopen.
11509    #[test]
11510    fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
11511        let manifest = RuntimeManifest::from_yaml_str(
11512            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 45\n",
11513        )
11514        .unwrap();
11515        let lifecycle = manifest.lifecycle.unwrap();
11516        assert_eq!(lifecycle.shell_grace_secs, 45);
11517        // Every other lifecycle field is untouched by declaring this one.
11518        assert_eq!(
11519            lifecycle.task_acceptance,
11520            LifecycleConfig::default().task_acceptance
11521        );
11522        assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
11523        assert_eq!(
11524            lifecycle.queue_depth,
11525            LifecycleConfig::default().queue_depth
11526        );
11527        assert_eq!(lifecycle.input_timeout_secs, None);
11528        assert_eq!(
11529            lifecycle.conversation_mode,
11530            LifecycleConfig::default().conversation_mode
11531        );
11532        assert_eq!(
11533            lifecycle.max_task_reopens,
11534            LifecycleConfig::default().max_task_reopens
11535        );
11536    }
11537
11538    #[test]
11539    fn lifecycle_shell_grace_secs_defaults_to_10() {
11540        let manifest = RuntimeManifest::from_yaml_str(
11541            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
11542        )
11543        .unwrap();
11544        let lifecycle = manifest.lifecycle.unwrap();
11545        assert_eq!(lifecycle.shell_grace_secs, 10);
11546        assert_eq!(lifecycle.queue_depth, 3);
11547
11548        let without_block =
11549            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11550        assert_eq!(without_block.effective_lifecycle().shell_grace_secs, 10);
11551    }
11552
11553    /// The bound a capsule delegates under is declared, and a capsule that declares nothing is
11554    /// still bounded: absent means a ceiling, never "wait forever".
11555    #[test]
11556    fn lifecycle_delegation_deadline_secs_defaults_to_600() {
11557        let without_block =
11558            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11559        assert_eq!(
11560            without_block.effective_lifecycle().delegation_deadline_secs,
11561            600
11562        );
11563
11564        let without_key = RuntimeManifest::from_yaml_str(
11565            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
11566        )
11567        .unwrap();
11568        let lifecycle = without_key.lifecycle.unwrap();
11569        assert_eq!(lifecycle.delegation_deadline_secs, 600);
11570        assert_eq!(lifecycle.queue_depth, 3);
11571    }
11572
11573    #[test]
11574    fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
11575        let manifest = RuntimeManifest::from_yaml_str(
11576            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
11577             delegation_deadline_secs: 30\n",
11578        )
11579        .unwrap();
11580        let lifecycle = manifest.lifecycle.unwrap();
11581        assert_eq!(lifecycle.delegation_deadline_secs, 30);
11582        // Every other lifecycle field is untouched by declaring this one.
11583        assert_eq!(
11584            lifecycle.shell_grace_secs,
11585            LifecycleConfig::default().shell_grace_secs
11586        );
11587        assert_eq!(
11588            lifecycle.max_task_reopens,
11589            LifecycleConfig::default().max_task_reopens
11590        );
11591    }
11592
11593    /// Declaring the key produces no unknown-key report, and a misspelling of it still does.
11594    #[test]
11595    fn lifecycle_delegation_deadline_secs_is_a_known_key() {
11596        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
11597        let manifest = RuntimeManifest::from_yaml_str(
11598            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
11599             delegation_deadline_secs: 30\n  delegation_deadline: 30\n",
11600        )
11601        .unwrap();
11602        let reported: Vec<(String, String)> = manifest
11603            .unknown_keys
11604            .iter()
11605            .map(|key| (key.key.clone(), key.block_path.clone()))
11606            .collect();
11607        assert_eq!(
11608            reported,
11609            vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
11610            "the declared key is known and only the misspelt neighbour is reported"
11611        );
11612        assert_eq!(
11613            manifest.lifecycle.unwrap().delegation_deadline_secs,
11614            30,
11615            "the misspelt neighbour does not disturb the declared value"
11616        );
11617    }
11618
11619    /// The deadline notice's margin is optional: absent leaves the runtime's default in force.
11620    #[test]
11621    fn lifecycle_deadline_notice_secs_defaults_to_none() {
11622        let without_block =
11623            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11624        assert_eq!(
11625            without_block.effective_lifecycle().deadline_notice_secs,
11626            None
11627        );
11628        let without_key = RuntimeManifest::from_yaml_str(
11629            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
11630        )
11631        .unwrap();
11632        assert_eq!(without_key.lifecycle.unwrap().deadline_notice_secs, None);
11633    }
11634
11635    /// `0` and a positive value are both taken as declared: `0` is "send no notice", not absent.
11636    #[test]
11637    fn lifecycle_deadline_notice_secs_takes_zero_and_positive_values() {
11638        for (declared, expected) in [("0", 0), ("90", 90)] {
11639            let manifest = RuntimeManifest::from_yaml_str(&format!(
11640                "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
11641                 deadline_notice_secs: {declared}\n"
11642            ))
11643            .unwrap();
11644            assert!(manifest.unknown_keys.is_empty());
11645            assert_eq!(
11646                manifest.lifecycle.unwrap().deadline_notice_secs,
11647                Some(expected)
11648            );
11649        }
11650    }
11651
11652    #[test]
11653    fn lifecycle_deadline_notice_secs_refuses_a_negative_or_non_integer_value() {
11654        for declared in ["-5", "1.5", "soon"] {
11655            let refused = RuntimeManifest::from_yaml_str(&format!(
11656                "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
11657                 deadline_notice_secs: {declared}\n"
11658            ));
11659            assert!(refused.is_err(), "{declared} is refused: {refused:?}");
11660        }
11661    }
11662
11663    #[test]
11664    fn lifecycle_deadline_notice_secs_is_a_known_key() {
11665        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"deadline_notice_secs"));
11666    }
11667
11668    fn lifecycle_with_keep_working(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
11669        RuntimeManifest::from_yaml_str(&format!(
11670            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  keep_working:\n{block}"
11671        ))
11672    }
11673
11674    fn refused_keep_working_field(block: &str) -> (String, String) {
11675        match lifecycle_with_keep_working(block) {
11676            Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
11677                (field, message)
11678            }
11679            other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
11680        }
11681    }
11682
11683    #[test]
11684    fn lifecycle_keep_working_is_off_when_absent() {
11685        let without_block =
11686            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11687        assert_eq!(without_block.effective_lifecycle().keep_working, None);
11688        let without_key = RuntimeManifest::from_yaml_str(
11689            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
11690        )
11691        .unwrap();
11692        assert_eq!(without_key.lifecycle.unwrap().keep_working, None);
11693    }
11694
11695    #[test]
11696    fn lifecycle_keep_working_takes_the_declared_message_and_limit() {
11697        let manifest =
11698            lifecycle_with_keep_working("    message: \"go on\"\n    limit: 3\n").unwrap();
11699        assert!(
11700            manifest.unknown_keys.is_empty(),
11701            "{:?}",
11702            manifest.unknown_keys
11703        );
11704        assert_eq!(
11705            manifest.lifecycle.unwrap().keep_working,
11706            Some(KeepWorking {
11707                message: "go on".into(),
11708                limit: 3
11709            })
11710        );
11711    }
11712
11713    /// The blank check trims; the message the runtime sends is the operator's bytes as written.
11714    #[test]
11715    fn lifecycle_keep_working_keeps_the_message_as_written() {
11716        let manifest =
11717            lifecycle_with_keep_working("    message: \"  go on \\n\"\n    limit: 1\n").unwrap();
11718        assert_eq!(
11719            manifest.lifecycle.unwrap().keep_working.unwrap().message,
11720            "  go on \n"
11721        );
11722    }
11723
11724    #[test]
11725    fn lifecycle_keep_working_refuses_a_missing_or_blank_message() {
11726        for block in [
11727            "    limit: 3\n",
11728            "    message: \"\"\n    limit: 3\n",
11729            "    message: \"  \\n\\t \"\n    limit: 3\n",
11730        ] {
11731            let (field, _) = refused_keep_working_field(block);
11732            assert_eq!(field, "lifecycle.keep_working.message", "{block:?}");
11733        }
11734    }
11735
11736    #[test]
11737    fn lifecycle_keep_working_refuses_a_missing_limit() {
11738        let (field, message) = refused_keep_working_field("    message: go on\n");
11739        assert_eq!(field, "lifecycle.keep_working.limit");
11740        assert_eq!(message, "is required");
11741    }
11742
11743    #[test]
11744    fn lifecycle_keep_working_refuses_a_zero_limit_and_says_how_to_turn_it_off() {
11745        let (field, message) = refused_keep_working_field("    message: go on\n    limit: 0\n");
11746        assert_eq!(field, "lifecycle.keep_working.limit");
11747        assert!(
11748            message.contains("omit lifecycle.keep_working"),
11749            "the refusal says how to turn the option off: {message}"
11750        );
11751    }
11752
11753    #[test]
11754    fn lifecycle_keep_working_reports_an_unknown_key_inside_the_block() {
11755        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"keep_working"));
11756        let manifest =
11757            lifecycle_with_keep_working("    message: go on\n    limit: 2\n    lmit: 3\n").unwrap();
11758        let reported: Vec<(String, String)> = manifest
11759            .unknown_keys
11760            .iter()
11761            .map(|key| (key.key.clone(), key.block_path.clone()))
11762            .collect();
11763        assert_eq!(
11764            reported,
11765            vec![("lmit".to_string(), "lifecycle.keep_working".to_string())]
11766        );
11767    }
11768
11769    fn lifecycle_with_done_check(
11770        allow: &str,
11771        block: &str,
11772    ) -> Result<RuntimeManifest, RuntimeManifestError> {
11773        RuntimeManifest::from_yaml_str(&format!(
11774            "name: cap\nversion: 0.0.1\nartifacts: []\n{allow}lifecycle:\n  done_check:\n{block}"
11775        ))
11776    }
11777
11778    const ALLOW_BASH: &str = "capabilities:\n  shell:\n    allow: [bash]\n";
11779
11780    fn refused_done_check_field(allow: &str, block: &str) -> (String, String) {
11781        match lifecycle_with_done_check(allow, block) {
11782            Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
11783                (field, message)
11784            }
11785            other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
11786        }
11787    }
11788
11789    #[test]
11790    fn lifecycle_done_check_is_off_when_absent() {
11791        let without_block =
11792            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11793        assert_eq!(without_block.effective_lifecycle().done_check, None);
11794        let without_key = RuntimeManifest::from_yaml_str(
11795            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
11796        )
11797        .unwrap();
11798        assert_eq!(without_key.lifecycle.unwrap().done_check, None);
11799    }
11800
11801    #[test]
11802    fn lifecycle_done_check_takes_the_command_and_limit_with_the_default_timeout() {
11803        let manifest = lifecycle_with_done_check(
11804            ALLOW_BASH,
11805            "    command: \"bash -c 'true'\"\n    limit: 2\n",
11806        )
11807        .unwrap();
11808        assert!(
11809            manifest.unknown_keys.is_empty(),
11810            "{:?}",
11811            manifest.unknown_keys
11812        );
11813        let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
11814        assert_eq!(
11815            done_check,
11816            DoneCheck {
11817                command: "bash -c 'true'".into(),
11818                limit: 2,
11819                timeout_secs: 600,
11820            }
11821        );
11822        assert_eq!(done_check.argv(), ["bash", "-c", "true"]);
11823    }
11824
11825    /// `0` observes without reopening; an explicit timeout is kept.
11826    #[test]
11827    fn lifecycle_done_check_accepts_a_zero_limit_and_an_explicit_timeout() {
11828        let manifest = lifecycle_with_done_check(
11829            ALLOW_BASH,
11830            "    command: bash -c 'exit 1'\n    limit: 0\n    timeout_secs: 5\n",
11831        )
11832        .unwrap();
11833        let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
11834        assert_eq!((done_check.limit, done_check.timeout_secs), (0, 5));
11835    }
11836
11837    #[test]
11838    fn lifecycle_done_check_refuses_each_invalid_field() {
11839        for (allow, block, field, message) in [
11840            (ALLOW_BASH, "    limit: 1\n", "command", "is required"),
11841            (
11842                ALLOW_BASH,
11843                "    command: \"  \"\n    limit: 1\n",
11844                "command",
11845                "must not be blank",
11846            ),
11847            (
11848                ALLOW_BASH,
11849                "    command: \"''\"\n    limit: 1\n",
11850                "command",
11851                "must name a program",
11852            ),
11853            (
11854                ALLOW_BASH,
11855                "    command: cargo test\n    limit: 1\n",
11856                "command",
11857                "runs 'cargo', which is not in capabilities.shell.allow",
11858            ),
11859            (
11860                "",
11861                "    command: bash -c true\n    limit: 1\n",
11862                "command",
11863                "runs 'bash', which is not in capabilities.shell.allow",
11864            ),
11865            (
11866                ALLOW_BASH,
11867                "    command: bash -c true\n",
11868                "limit",
11869                "is required",
11870            ),
11871            (
11872                ALLOW_BASH,
11873                "    command: bash -c true\n    limit: 1\n    timeout_secs: 0\n",
11874                "timeout_secs",
11875                "must be at least 1",
11876            ),
11877        ] {
11878            assert_eq!(
11879                refused_done_check_field(allow, block),
11880                (format!("lifecycle.done_check.{field}"), message.to_string()),
11881                "{block:?}"
11882            );
11883        }
11884    }
11885
11886    #[test]
11887    fn lifecycle_done_check_reports_an_unknown_key_inside_the_block() {
11888        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"done_check"));
11889        let manifest = lifecycle_with_done_check(
11890            ALLOW_BASH,
11891            "    command: bash -c true\n    limit: 2\n    limt: 3\n",
11892        )
11893        .unwrap();
11894        let reported: Vec<(String, String)> = manifest
11895            .unknown_keys
11896            .iter()
11897            .map(|key| (key.key.clone(), key.block_path.clone()))
11898            .collect();
11899        assert_eq!(
11900            reported,
11901            vec![("limt".to_string(), "lifecycle.done_check".to_string())]
11902        );
11903    }
11904
11905    /// `0` is a valid explicit value, not an absent one: it demotes at the first poll after the
11906    /// spawn, so effectively every command detaches.
11907    #[test]
11908    fn lifecycle_shell_grace_secs_zero_is_accepted() {
11909        let manifest = RuntimeManifest::from_yaml_str(
11910            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 0\n",
11911        )
11912        .unwrap();
11913        let lifecycle = manifest.lifecycle.unwrap();
11914        assert_eq!(lifecycle.shell_grace_secs, 0);
11915        assert_eq!(
11916            lifecycle.max_task_reopens,
11917            LifecycleConfig::default().max_task_reopens
11918        );
11919    }
11920
11921    #[test]
11922    fn lifecycle_max_task_reopens_defaults_to_1() {
11923        let manifest = RuntimeManifest::from_yaml_str(
11924            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  after_task: sleep\n",
11925        )
11926        .unwrap();
11927        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
11928    }
11929
11930    /// No `lifecycle:` block at all lands on the same default through `effective_lifecycle`.
11931    #[test]
11932    fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
11933        let manifest =
11934            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11935        assert!(manifest.lifecycle.is_none());
11936        assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
11937    }
11938
11939    #[test]
11940    fn lifecycle_max_task_reopens_explicit_value() {
11941        let manifest = RuntimeManifest::from_yaml_str(
11942            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 3\n",
11943        )
11944        .unwrap();
11945        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
11946    }
11947
11948    /// Unlike `inference.max_turns`, `0` is a valid explicit value — it disables reopening.
11949    #[test]
11950    fn lifecycle_max_task_reopens_zero_is_accepted() {
11951        let manifest = RuntimeManifest::from_yaml_str(
11952            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 0\n",
11953        )
11954        .unwrap();
11955        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
11956    }
11957
11958    /// The budget is independent of the inference transport in play.
11959    #[test]
11960    fn lifecycle_max_task_reopens_with_process_transport() {
11961        let manifest = RuntimeManifest::from_yaml_str(
11962            "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",
11963        ).unwrap();
11964        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
11965    }
11966
11967    #[test]
11968    fn inference_max_task_reopens_is_rejected_under_http_transport() {
11969        let err = RuntimeManifest::from_yaml_str(
11970            "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",
11971        ).unwrap_err();
11972        assert!(
11973            matches!(
11974                &err,
11975                RuntimeManifestError::InvalidInferenceConfig { field, .. }
11976                    if field == "inference.max_task_reopens"
11977            ),
11978            "error was: {err:?}"
11979        );
11980        let msg = err.to_string();
11981        assert!(
11982            msg.contains("lifecycle.max_task_reopens"),
11983            "error was: {msg}"
11984        );
11985    }
11986
11987    #[test]
11988    fn inference_max_task_reopens_is_rejected_under_process_transport() {
11989        let err = RuntimeManifest::from_yaml_str(
11990            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  max_task_reopens: 2\n",
11991        ).unwrap_err();
11992        assert!(
11993            matches!(
11994                &err,
11995                RuntimeManifestError::InvalidInferenceConfig { field, .. }
11996                    if field == "inference.max_task_reopens"
11997            ),
11998            "error was: {err:?}"
11999        );
12000        let msg = err.to_string();
12001        assert!(
12002            msg.contains("lifecycle.max_task_reopens"),
12003            "error was: {msg}"
12004        );
12005    }
12006
12007    /// The rejection is unconditional: the new key being present alongside it changes nothing.
12008    #[test]
12009    fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
12010        let err = RuntimeManifest::from_yaml_str(
12011            "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",
12012        ).unwrap_err();
12013        assert!(
12014            matches!(
12015                &err,
12016                RuntimeManifestError::InvalidInferenceConfig { field, .. }
12017                    if field == "inference.max_task_reopens"
12018            ),
12019            "error was: {err:?}"
12020        );
12021    }
12022
12023    #[test]
12024    fn skill_is_llm_visible() {
12025        assert!(ArtifactRuntime::Skill.is_llm_visible());
12026    }
12027
12028    #[test]
12029    fn driver_and_hook_are_not_llm_visible() {
12030        assert!(!ArtifactRuntime::Driver.is_llm_visible());
12031        assert!(!ArtifactRuntime::Hook.is_llm_visible());
12032    }
12033
12034    #[test]
12035    fn parses_system_prompt_artifact() {
12036        let manifest = RuntimeManifest::from_yaml_str(
12037            r#"
12038name: cap
12039version: 0.0.1
12040artifacts:
12041  - name: local/my-skill
12042    version: 1.0.0
12043    runtime: skill
12044  - name: murmur/driver-anthropic
12045    version: 0.1.0
12046    runtime: driver
12047    gateway:
12048      endpoint: http://127.0.0.1:8080
12049      keyless: true
12050inference:
12051  model: test-model
12052  system_prompt_artifact: local/my-skill
12053  driver:
12054    artifact: murmur/driver-anthropic
12055"#,
12056        )
12057        .unwrap();
12058
12059        let inference = manifest.inference.unwrap();
12060        assert_eq!(
12061            inference.system_prompt_artifact,
12062            Some("local/my-skill".to_string())
12063        );
12064        assert!(inference.system_prompt.is_none());
12065        assert!(inference.system_prompt_file.is_none());
12066    }
12067
12068    #[test]
12069    fn rejects_system_prompt_artifact_with_inline_prompt() {
12070        let err = RuntimeManifest::from_yaml_str(
12071            r#"
12072name: cap
12073version: 0.0.1
12074artifacts:
12075  - name: local/my-skill
12076    version: 1.0.0
12077    runtime: skill
12078  - name: murmur/driver-anthropic
12079    version: 0.1.0
12080    runtime: driver
12081    gateway:
12082      endpoint: http://127.0.0.1:8080
12083      keyless: true
12084inference:
12085  model: test-model
12086  system_prompt: "inline"
12087  system_prompt_artifact: local/my-skill
12088  driver:
12089    artifact: murmur/driver-anthropic
12090"#,
12091        )
12092        .unwrap_err();
12093
12094        let msg = err.to_string();
12095        assert!(msg.contains("at most one"), "error was: {msg}");
12096    }
12097
12098    #[test]
12099    fn rejects_system_prompt_artifact_naming_non_skill() {
12100        let err = RuntimeManifest::from_yaml_str(
12101            r#"
12102name: cap
12103version: 0.0.1
12104artifacts:
12105  - name: local/my-tool
12106    version: 1.0.0
12107    runtime: tool
12108  - name: murmur/driver-anthropic
12109    version: 0.1.0
12110    runtime: driver
12111    gateway:
12112      endpoint: http://127.0.0.1:8080
12113      keyless: true
12114inference:
12115  model: test-model
12116  system_prompt_artifact: local/my-tool
12117  driver:
12118    artifact: murmur/driver-anthropic
12119"#,
12120        )
12121        .unwrap_err();
12122
12123        let msg = err.to_string();
12124        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12125        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
12126    }
12127
12128    #[test]
12129    fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
12130        let err = RuntimeManifest::from_yaml_str(
12131            r#"
12132name: cap
12133version: 0.0.1
12134artifacts:
12135  - name: murmur/driver-anthropic
12136    version: 0.1.0
12137    runtime: driver
12138    gateway:
12139      endpoint: http://127.0.0.1:8080
12140      keyless: true
12141inference:
12142  model: test-model
12143  system_prompt_artifact: missing-skill
12144  driver:
12145    artifact: murmur/driver-anthropic
12146"#,
12147        )
12148        .unwrap_err();
12149
12150        let msg = err.to_string();
12151        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12152        assert!(msg.contains("not declared"), "error was: {msg}");
12153    }
12154
12155    #[test]
12156    fn rejects_all_three_system_prompt_sources() {
12157        let err = RuntimeManifest::from_yaml_str(
12158            r#"
12159name: cap
12160version: 0.0.1
12161artifacts:
12162  - name: local/my-skill
12163    version: 1.0.0
12164    runtime: skill
12165  - name: murmur/driver-anthropic
12166    version: 0.1.0
12167    runtime: driver
12168    gateway:
12169      endpoint: http://127.0.0.1:8080
12170      keyless: true
12171inference:
12172  model: test-model
12173  system_prompt: "inline"
12174  system_prompt_file: "conventions.md"
12175  system_prompt_artifact: local/my-skill
12176  driver:
12177    artifact: murmur/driver-anthropic
12178"#,
12179        )
12180        .unwrap_err();
12181
12182        let msg = err.to_string();
12183        assert!(msg.contains("at most one"), "error was: {msg}");
12184    }
12185
12186    // ── Per-artifact capability grants ───────────────────────────────────────
12187
12188    #[test]
12189    fn hook_artifact_capabilities_parse_network_and_filesystem() {
12190        let manifest = RuntimeManifest::from_yaml_str(
12191            r#"
12192name: cap
12193version: 0.0.1
12194artifacts:
12195  - name: local/telemetry-hook
12196    version: 1.0.0
12197    runtime: hook
12198    capabilities:
12199      network:
12200        allow:
12201          - https://telemetry.example.com
12202      filesystem:
12203        scope: hook-state
12204"#,
12205        )
12206        .unwrap();
12207
12208        let caps = manifest.artifacts[0]
12209            .capabilities
12210            .as_ref()
12211            .expect("hook entry carries a capability grant");
12212        assert_eq!(
12213            caps.network.as_ref().unwrap().allow,
12214            vec!["https://telemetry.example.com".to_string()]
12215        );
12216        assert_eq!(
12217            caps.filesystem.as_ref().unwrap().scope,
12218            Some("hook-state".to_string())
12219        );
12220    }
12221
12222    /// Default-deny: a hook entry with no `capabilities:` key parses to `None`, which the
12223    /// runtime lowers to zero network and zero preopened directories.
12224    #[test]
12225    fn hook_artifact_without_capabilities_is_none() {
12226        let manifest = RuntimeManifest::from_yaml_str(
12227            r#"
12228name: cap
12229version: 0.0.1
12230artifacts:
12231  - name: local/quiet-hook
12232    version: 1.0.0
12233    runtime: hook
12234"#,
12235        )
12236        .unwrap();
12237
12238        assert!(manifest.artifacts[0].capabilities.is_none());
12239    }
12240
12241    /// Tools and drivers execute, so they may carry a grant — the runtime reads it as a
12242    /// narrowing of the capsule-wide ceiling rather than as a default-deny baseline.
12243    #[test]
12244    fn tool_and_driver_artifact_capabilities_parse() {
12245        for role in ["tool", "driver"] {
12246            let yaml = format!(
12247                r#"
12248name: cap
12249version: 0.0.1
12250artifacts:
12251  - name: local/scoped
12252    version: 1.0.0
12253    runtime: {role}
12254    capabilities:
12255      network:
12256        allow:
12257          - https://api.example.com
12258      filesystem:
12259        scope: cache
12260"#
12261            );
12262            let manifest = RuntimeManifest::from_yaml_str(&yaml)
12263                .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
12264
12265            let caps = manifest.artifacts[0]
12266                .capabilities
12267                .as_ref()
12268                .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
12269            assert_eq!(
12270                caps.network.as_ref().unwrap().allow,
12271                vec!["https://api.example.com".to_string()]
12272            );
12273            assert_eq!(
12274                caps.filesystem.as_ref().unwrap().scope,
12275                Some("cache".to_string())
12276            );
12277        }
12278    }
12279
12280    /// A tool/driver entry with no `capabilities:` key parses to `None`, which the runtime
12281    /// lowers to the unchanged capsule ceiling.
12282    #[test]
12283    fn tool_artifact_without_capabilities_is_none() {
12284        let manifest = RuntimeManifest::from_yaml_str(
12285            r#"
12286name: cap
12287version: 0.0.1
12288artifacts:
12289  - name: local/plain-tool
12290    version: 1.0.0
12291    runtime: tool
12292"#,
12293        )
12294        .unwrap();
12295
12296        assert!(manifest.artifacts[0].capabilities.is_none());
12297    }
12298
12299    /// A skill has no execution surface, so a grant on one would be silently unenforced —
12300    /// rejected at parse time, with a message naming the roles that do work.
12301    #[test]
12302    fn skill_artifact_capabilities_are_rejected() {
12303        let yaml = r#"
12304name: cap
12305version: 0.0.1
12306artifacts:
12307  - name: local/sneaky
12308    version: 1.0.0
12309    runtime: skill
12310    capabilities:
12311      network:
12312        allow:
12313          - https://evil.example.com
12314"#;
12315        let err = match RuntimeManifest::from_yaml_str(yaml) {
12316            Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
12317            Err(err) => err,
12318        };
12319        let msg = err.to_string();
12320        assert!(msg.contains("sneaky"), "error was: {msg}");
12321        assert!(
12322            msg.contains(
12323                "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
12324                 entries"
12325            ),
12326            "error was: {msg}"
12327        );
12328        assert!(msg.contains("runtime: skill"), "error was: {msg}");
12329    }
12330
12331    // ── Per-artifact `config:` (operator-authored artifact configuration) ────
12332
12333    /// The block is carried verbatim as YAML. This crate accepts any mapping; the runtime is
12334    /// what lowers it to JSON and refuses a shape that cannot travel that way.
12335    #[test]
12336    fn artifact_config_is_carried_verbatim() {
12337        let manifest = RuntimeManifest::from_yaml_str(
12338            r#"
12339name: cap
12340version: 0.0.1
12341artifacts:
12342  - name: murmur/tool-corpus
12343    version: 0.1.0
12344    runtime: tool
12345    config:
12346      read_recent: { default: 20, max: 100 }
12347"#,
12348        )
12349        .unwrap();
12350
12351        let config = manifest.artifacts[0]
12352            .config
12353            .as_ref()
12354            .expect("a declared block parses");
12355        assert_eq!(
12356            config["read_recent"]["max"],
12357            serde_norway::Value::Number(100.into())
12358        );
12359    }
12360
12361    /// Absent means absent: no key, no variable, and nothing for the runtime to lower.
12362    #[test]
12363    fn an_undeclared_config_is_none() {
12364        let manifest = RuntimeManifest::from_yaml_str(
12365            r#"
12366name: cap
12367version: 0.0.1
12368artifacts:
12369  - name: local/git
12370    version: 1.0.0
12371    runtime: tool
12372"#,
12373        )
12374        .unwrap();
12375
12376        assert!(manifest.artifacts[0].config.is_none());
12377    }
12378
12379    /// `config:` with nothing under it is a written declaration that carries nothing, and must
12380    /// stay distinguishable from the key being absent — the runtime refuses it, and can only do
12381    /// so if the parser does not collapse YAML null into `None`.
12382    #[test]
12383    fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
12384        let manifest = RuntimeManifest::from_yaml_str(
12385            r#"
12386name: cap
12387version: 0.0.1
12388artifacts:
12389  - name: local/git
12390    version: 1.0.0
12391    runtime: tool
12392    config:
12393"#,
12394        )
12395        .unwrap();
12396
12397        assert_eq!(
12398            manifest.artifacts[0].config,
12399            Some(serde_norway::Value::Null),
12400            "an empty block must survive parsing as a declaration, not vanish into None"
12401        );
12402    }
12403
12404    /// A skill runs no component and holds no grant, so nothing would ever deliver a config
12405    /// block to one — rejected at parse time, with a message naming the roles that do.
12406    #[test]
12407    fn skill_artifact_config_is_rejected() {
12408        let yaml = r#"
12409name: cap
12410version: 0.0.1
12411artifacts:
12412  - name: local/sneaky
12413    version: 1.0.0
12414    runtime: skill
12415    config:
12416      who: me
12417"#;
12418        let err = match RuntimeManifest::from_yaml_str(yaml) {
12419            Ok(_) => panic!("config: must be rejected for runtime: skill"),
12420            Err(err) => err,
12421        };
12422        assert!(matches!(
12423            err,
12424            RuntimeManifestError::InvalidArtifact { index: 0, .. }
12425        ));
12426        let msg = err.to_string();
12427        assert!(msg.contains("sneaky"), "error was: {msg}");
12428        assert!(msg.contains("runtime: skill"), "error was: {msg}");
12429        assert!(
12430            msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
12431            "error was: {msg}"
12432        );
12433    }
12434
12435    /// Config is delivered on one artifact's own grant, so a capsule-wide block reaches nothing.
12436    /// Refused rather than silently ignored, and the message points at the artifact entry.
12437    #[test]
12438    fn a_capsule_wide_config_block_is_rejected() {
12439        let yaml = r#"
12440name: cap
12441version: 0.0.1
12442config:
12443  who: capsule
12444artifacts:
12445  - name: local/git
12446    version: 1.0.0
12447    runtime: tool
12448"#;
12449        let err = match RuntimeManifest::from_yaml_str(yaml) {
12450            Ok(_) => panic!("a capsule-wide config block must be rejected"),
12451            Err(err) => err,
12452        };
12453        let msg = err.to_string();
12454        assert!(msg.contains("config"), "error was: {msg}");
12455        assert!(msg.contains("declared per artifact"), "error was: {msg}");
12456    }
12457
12458    // ── Per-artifact `on_overflow:` (async hook queue policy) ────────────────
12459
12460    /// `on_overflow: block` on a hook entry parses to the blocking policy.
12461    #[test]
12462    fn hook_on_overflow_block_parses() {
12463        let manifest = RuntimeManifest::from_yaml_str(
12464            r#"
12465name: cap
12466version: 0.0.1
12467artifacts:
12468  - name: murmur/hook-grafana
12469    version: 1.0.0
12470    runtime: hook
12471    on_overflow: block
12472"#,
12473        )
12474        .unwrap();
12475
12476        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
12477    }
12478
12479    /// Omitting the key means `drop`: an operator who says nothing gets the loop kept moving,
12480    /// not backpressure they never asked for.
12481    #[test]
12482    fn hook_on_overflow_defaults_to_drop() {
12483        let manifest = RuntimeManifest::from_yaml_str(
12484            r#"
12485name: cap
12486version: 0.0.1
12487artifacts:
12488  - name: murmur/hook-grafana
12489    version: 1.0.0
12490    runtime: hook
12491"#,
12492        )
12493        .unwrap();
12494
12495        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
12496        assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
12497    }
12498
12499    /// Only a hook has a job queue, so the key is rejected on every other role rather than
12500    /// silently ignored — the same rule per-artifact `capabilities:` follows on a skill.
12501    #[test]
12502    fn on_overflow_on_a_non_hook_artifact_is_rejected() {
12503        let yaml = r#"
12504name: cap
12505version: 0.0.1
12506artifacts:
12507  - name: local/git
12508    version: 1.0.0
12509    runtime: tool
12510    on_overflow: block
12511"#;
12512        let err = match RuntimeManifest::from_yaml_str(yaml) {
12513            Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
12514            Err(err) => err,
12515        };
12516        assert!(matches!(
12517            err,
12518            RuntimeManifestError::InvalidArtifact { index: 0, .. }
12519        ));
12520        let msg = err.to_string();
12521        assert!(msg.contains("git"), "error was: {msg}");
12522        assert!(
12523            msg.contains("only recognized on 'runtime: hook' entries"),
12524            "error was: {msg}"
12525        );
12526    }
12527
12528    /// An unknown value is a typo, not a policy — rejected with both spellings named.
12529    #[test]
12530    fn unknown_on_overflow_value_is_rejected() {
12531        let yaml = r#"
12532name: cap
12533version: 0.0.1
12534artifacts:
12535  - name: murmur/hook-grafana
12536    version: 1.0.0
12537    runtime: hook
12538    on_overflow: spill
12539"#;
12540        let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
12541        let msg = err.to_string();
12542        assert!(msg.contains("spill"), "error was: {msg}");
12543        assert!(msg.contains("expected: drop, block"), "error was: {msg}");
12544    }
12545
12546    /// The key is legal on a *blocking* hook entry and simply inert: the operator manifest
12547    /// cannot know a hook's execution mode, which lives in the artifact's own manifest.
12548    #[test]
12549    fn on_overflow_is_accepted_on_any_hook_entry() {
12550        let manifest = RuntimeManifest::from_yaml_str(
12551            r#"
12552name: cap
12553version: 0.0.1
12554artifacts:
12555  - name: murmur/hook-eval
12556    version: 1.0.0
12557    runtime: hook
12558    on_overflow: drop
12559    capabilities:
12560      network:
12561        allow:
12562          - https://telemetry.example.com
12563"#,
12564        )
12565        .unwrap();
12566
12567        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
12568        assert!(manifest.artifacts[0].capabilities.is_some());
12569    }
12570
12571    /// The capsule-wide top-level block is untouched by the per-artifact field: both may be
12572    /// present in one manifest and neither shadows the other.
12573    #[test]
12574    fn capsule_wide_and_hook_capabilities_coexist() {
12575        let manifest = RuntimeManifest::from_yaml_str(
12576            r#"
12577name: cap
12578version: 0.0.1
12579artifacts:
12580  - name: local/telemetry-hook
12581    version: 1.0.0
12582    runtime: hook
12583    capabilities:
12584      network:
12585        allow:
12586          - https://telemetry.example.com
12587capabilities:
12588  network:
12589    allow:
12590      - https://api.anthropic.com
12591"#,
12592        )
12593        .unwrap();
12594
12595        assert_eq!(
12596            manifest.capabilities.unwrap().network.unwrap().allow,
12597            vec!["https://api.anthropic.com".to_string()]
12598        );
12599        assert_eq!(
12600            manifest.artifacts[0]
12601                .capabilities
12602                .as_ref()
12603                .unwrap()
12604                .network
12605                .as_ref()
12606                .unwrap()
12607                .allow,
12608            vec!["https://telemetry.example.com".to_string()]
12609        );
12610    }
12611
12612    /// No self-escalation: the hook artifact's *own* bundled murmur.yaml is parsed only by
12613    /// `parse_hook_config_from_yaml`, which has no capabilities field and no code path that
12614    /// reads one. A self-declared `capabilities:` block there is inert.
12615    #[test]
12616    fn hook_own_manifest_capabilities_are_not_read_into_config() {
12617        let config = parse_hook_config_from_yaml(
12618            r#"
12619name: telemetry-hook
12620version: 1.0.0
12621binding: on-inference
12622execution_mode: blocking
12623capabilities:
12624  network:
12625    allow:
12626      - https://evil.example.com
12627  filesystem:
12628    scope: /
12629"#,
12630        )
12631        .expect("a self-declared capabilities block is ignored, not an error");
12632
12633        assert_eq!(
12634            config,
12635            HookConfig {
12636                binding: HookBinding::OnInference,
12637                execution_mode: HookExecutionMode::Blocking,
12638                commit_policy: HookCommitPolicy::None,
12639            }
12640        );
12641    }
12642
12643    // ── Containment class ────────────────────────────────────────────────────
12644
12645    #[test]
12646    fn containment_classes_order_weakest_to_strongest() {
12647        assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
12648        assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
12649        assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
12650        assert_eq!(
12651            ContainmentClass::ALL.iter().copied().max(),
12652            Some(ContainmentClass::Sealed)
12653        );
12654    }
12655
12656    #[test]
12657    fn containment_class_defaults_to_advisory() {
12658        assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
12659    }
12660
12661    #[test]
12662    fn containment_class_round_trips_through_string() {
12663        for class in ContainmentClass::ALL {
12664            assert_eq!(
12665                class.to_string().parse::<ContainmentClass>(),
12666                Ok(class),
12667                "{class} must survive Display -> FromStr"
12668            );
12669        }
12670        assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
12671        assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
12672        assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
12673    }
12674
12675    #[test]
12676    fn containment_class_rejects_unknown_names() {
12677        let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
12678        assert_eq!(err.value, "paranoid");
12679        assert_eq!(
12680            err.to_string(),
12681            "must be one of: advisory, scoped, sealed; got 'paranoid'"
12682        );
12683        // Case-sensitive by design: the wire form is lowercase everywhere.
12684        assert!("Sealed".parse::<ContainmentClass>().is_err());
12685    }
12686
12687    #[test]
12688    fn containment_class_serializes_as_its_wire_name() {
12689        assert_eq!(
12690            serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
12691            "\"scoped\""
12692        );
12693        assert_eq!(
12694            serde_norway::from_str::<ContainmentClass>("sealed").unwrap(),
12695            ContainmentClass::Sealed
12696        );
12697    }
12698
12699    #[test]
12700    fn effective_floor_is_advisory_when_nothing_is_declared() {
12701        assert_eq!(
12702            effective_containment_floor(None, None, None),
12703            ContainmentClass::Advisory
12704        );
12705    }
12706
12707    #[test]
12708    fn effective_floor_takes_the_strongest_across_every_presence_combination() {
12709        use ContainmentClass::{Advisory, Scoped, Sealed};
12710
12711        // Exactly one source present: that source wins outright.
12712        assert_eq!(
12713            effective_containment_floor(Some(Scoped), None, None),
12714            Scoped
12715        );
12716        assert_eq!(
12717            effective_containment_floor(None, Some(Scoped), None),
12718            Scoped
12719        );
12720        assert_eq!(
12721            effective_containment_floor(None, None, Some(Scoped)),
12722            Scoped
12723        );
12724        assert_eq!(
12725            effective_containment_floor(Some(Sealed), None, None),
12726            Sealed
12727        );
12728        assert_eq!(
12729            effective_containment_floor(None, Some(Sealed), None),
12730            Sealed
12731        );
12732        assert_eq!(
12733            effective_containment_floor(None, None, Some(Sealed)),
12734            Sealed
12735        );
12736
12737        // Two sources present: the stronger wins regardless of which slot holds it, and a
12738        // weaker source can never pull the stronger one down.
12739        assert_eq!(
12740            effective_containment_floor(Some(Advisory), Some(Sealed), None),
12741            Sealed
12742        );
12743        assert_eq!(
12744            effective_containment_floor(Some(Sealed), Some(Advisory), None),
12745            Sealed
12746        );
12747        assert_eq!(
12748            effective_containment_floor(Some(Advisory), None, Some(Scoped)),
12749            Scoped
12750        );
12751        assert_eq!(
12752            effective_containment_floor(Some(Scoped), None, Some(Advisory)),
12753            Scoped
12754        );
12755        assert_eq!(
12756            effective_containment_floor(None, Some(Advisory), Some(Scoped)),
12757            Scoped
12758        );
12759        assert_eq!(
12760            effective_containment_floor(None, Some(Scoped), Some(Advisory)),
12761            Scoped
12762        );
12763
12764        // All three present: max wins from every slot, and all-equal is a fixed point.
12765        assert_eq!(
12766            effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
12767            Sealed
12768        );
12769        assert_eq!(
12770            effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
12771            Sealed
12772        );
12773        assert_eq!(
12774            effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
12775            Sealed
12776        );
12777        assert_eq!(
12778            effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
12779            Scoped
12780        );
12781        assert_eq!(
12782            effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
12783            Advisory
12784        );
12785    }
12786
12787    #[test]
12788    fn manifest_parses_each_declared_containment_class() {
12789        for class in ContainmentClass::ALL {
12790            let manifest = RuntimeManifest::from_yaml_str(&format!(
12791                r#"
12792name: cap
12793version: 0.0.1
12794capabilities:
12795  containment: {class}
12796"#
12797            ))
12798            .expect("a valid containment class parses");
12799
12800            assert_eq!(
12801                manifest.capabilities.unwrap().containment,
12802                Some(class),
12803                "capabilities.containment: {class}"
12804            );
12805        }
12806    }
12807
12808    #[test]
12809    fn manifest_without_containment_key_declares_nothing() {
12810        // Absent `capabilities:` block entirely.
12811        let manifest = RuntimeManifest::from_yaml_str(
12812            r#"
12813name: cap
12814version: 0.0.1
12815"#,
12816        )
12817        .unwrap();
12818        assert!(manifest.capabilities.is_none());
12819
12820        // Present `capabilities:` block that simply omits the key: still None, never a
12821        // silently-defaulted Advisory floor stored as if the operator had written it.
12822        let manifest = RuntimeManifest::from_yaml_str(
12823            r#"
12824name: cap
12825version: 0.0.1
12826capabilities:
12827  network:
12828    allow:
12829      - https://api.example.com
12830"#,
12831        )
12832        .unwrap();
12833        assert_eq!(manifest.capabilities.unwrap().containment, None);
12834    }
12835
12836    #[test]
12837    fn manifest_rejects_an_unknown_containment_class() {
12838        let err = RuntimeManifest::from_yaml_str(
12839            r#"
12840name: cap
12841version: 0.0.1
12842capabilities:
12843  containment: paranoid
12844"#,
12845        )
12846        .expect_err("an unknown containment class is rejected at parse time");
12847
12848        match err {
12849            RuntimeManifestError::InvalidCapabilities { field, message } => {
12850                assert_eq!(field, "capabilities.containment");
12851                assert_eq!(
12852                    message,
12853                    "must be one of: advisory, scoped, sealed; got 'paranoid'"
12854                );
12855            }
12856            other => panic!("expected InvalidCapabilities, got {other:?}"),
12857        }
12858    }
12859
12860    // ── exports.files ────────────────────────────────────────────────────────
12861
12862    fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
12863        RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
12864    }
12865
12866    fn export_error(block: &str) -> (String, String) {
12867        match exports_manifest(block).expect_err("this exports block must not parse") {
12868            RuntimeManifestError::InvalidExports { field, message } => (field, message),
12869            other => panic!("expected InvalidExports, got {other:?}"),
12870        }
12871    }
12872
12873    fn peer_fetch_error(block: &str) -> (String, String) {
12874        match exports_manifest(block).expect_err("this capabilities block must not parse") {
12875            RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
12876            other => panic!("expected InvalidCapabilities, got {other:?}"),
12877        }
12878    }
12879
12880    // ── exports.peer_files ───────────────────────────────────────────────────
12881
12882    #[test]
12883    fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
12884        assert_eq!(parse_duration_secs("90").unwrap(), 90);
12885        assert_eq!(parse_duration_secs("30s").unwrap(), 30);
12886        assert_eq!(parse_duration_secs("15m").unwrap(), 900);
12887        assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
12888        assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
12889        assert_eq!(parse_duration_secs("  2h  ").unwrap(), 7200);
12890        assert_eq!(parse_duration_secs("0").unwrap(), 0);
12891    }
12892
12893    /// Every spelling that is not the one accepted form, refused rather than guessed at: a
12894    /// manifest meaning 5 seconds and one meaning 5 minutes must not both parse.
12895    #[test]
12896    fn parse_duration_secs_rejects_every_other_spelling() {
12897        for input in [
12898            "",
12899            "  ",
12900            "5 minutes",
12901            "30S",
12902            "1H",
12903            "5min",
12904            "1.5h",
12905            "-1",
12906            "m",
12907            "h",
12908            "1h30m",
12909            "0x10",
12910            "30 s",
12911        ] {
12912            let error = parse_duration_secs(input)
12913                .expect_err(&format!("'{input}' must not parse as a duration"));
12914            assert!(
12915                error.contains(DURATION_ACCEPTED_FORM),
12916                "'{input}' must be refused with the accepted form; got: {error}"
12917            );
12918        }
12919    }
12920
12921    #[test]
12922    fn parse_duration_secs_refuses_to_overflow() {
12923        assert!(parse_duration_secs("18446744073709551615h")
12924            .unwrap_err()
12925            .contains("overflows"));
12926    }
12927
12928    #[test]
12929    fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
12930        let manifest = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
12931            .expect("a minimal peer_files block parses");
12932        let peer = manifest.exports.unwrap().peer_files.unwrap();
12933        assert_eq!(peer.root, "out/");
12934        assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
12935        assert_eq!(peer.max_bytes, 10_485_760);
12936        // Undeclared, not defaulted: the two ephemerality cases answer an absent value
12937        // differently, and substituting here would erase the difference.
12938        assert_eq!(peer.max_ttl_secs, None);
12939        assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
12940        assert_eq!(peer.effective_max_ttl_secs(), 3600);
12941    }
12942
12943    #[test]
12944    fn peer_files_accepts_every_duration_spelling() {
12945        for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
12946            let manifest = exports_manifest(&format!(
12947                "exports:\n  peer_files:\n    root: out/\n    max_ttl: {declared}\n"
12948            ))
12949            .expect("a declared max_ttl parses");
12950            let peer = manifest.exports.unwrap().peer_files.unwrap();
12951            assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
12952            assert_eq!(peer.effective_max_ttl_secs(), expected);
12953        }
12954    }
12955
12956    /// The two export blocks are separate authorisers: declaring one says nothing about the
12957    /// other, and both may be declared side by side over different subtrees.
12958    #[test]
12959    fn the_two_export_blocks_are_independent() {
12960        let both = exports_manifest(
12961            "exports:\n  files:\n    root: out/\n    mode: read-only\n  \
12962             peer_files:\n    root: out/handoff/\n",
12963        )
12964        .expect("both blocks parse together")
12965        .exports
12966        .unwrap();
12967        assert_eq!(both.files.unwrap().root, "out/");
12968        assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
12969
12970        let only_files =
12971            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
12972                .unwrap()
12973                .exports
12974                .unwrap();
12975        assert!(only_files.peer_files.is_none());
12976
12977        let only_peer = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
12978            .unwrap()
12979            .exports
12980            .unwrap();
12981        assert!(only_peer.files.is_none());
12982
12983        let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
12984        assert!(neither.files.is_none() && neither.peer_files.is_none());
12985    }
12986
12987    #[test]
12988    fn peer_files_root_must_be_relative_and_inside_the_workdir() {
12989        for root in ["../out", "/etc", "out/../../etc"] {
12990            let (field, message) =
12991                export_error(&format!("exports:\n  peer_files:\n    root: {root}\n"));
12992            assert_eq!(field, "exports.peer_files.root", "root: {root}");
12993            assert!(
12994                message.contains(EXPORT_ROOT_ACCEPTED_FORM),
12995                "root: {root}; got: {message}"
12996            );
12997        }
12998    }
12999
13000    #[test]
13001    fn peer_files_root_is_required() {
13002        let (field, message) = export_error("exports:\n  peer_files: {}\n");
13003        assert_eq!(field, "exports.peer_files.root");
13004        assert!(message.contains("is required"));
13005        assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
13006    }
13007
13008    #[test]
13009    fn peer_files_max_ttl_states_its_accepted_form() {
13010        for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
13011            let (field, message) = export_error(&format!(
13012                "exports:\n  peer_files:\n    root: out/\n    max_ttl: \"{declared}\"\n"
13013            ));
13014            assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
13015            assert!(
13016                message.contains(DURATION_ACCEPTED_FORM),
13017                "max_ttl: {declared}; got: {message}"
13018            );
13019        }
13020    }
13021
13022    /// A zero ceiling reads like a declared surface and refuses every mint. Better discovered at
13023    /// the line that wrote it than at the first `share-file` call.
13024    #[test]
13025    fn peer_files_max_ttl_rejects_zero() {
13026        let (field, message) =
13027            export_error("exports:\n  peer_files:\n    root: out/\n    max_ttl: 0\n");
13028        assert_eq!(field, "exports.peer_files.max_ttl");
13029        assert!(message.contains("greater than zero"));
13030    }
13031
13032    #[test]
13033    fn peer_files_max_bytes_states_its_accepted_form() {
13034        let (field, message) =
13035            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 10MB\n");
13036        assert_eq!(field, "exports.peer_files.max_bytes");
13037        assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
13038    }
13039
13040    #[test]
13041    fn peer_files_max_bytes_rejects_zero() {
13042        let (field, message) =
13043            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 0\n");
13044        assert_eq!(field, "exports.peer_files.max_bytes");
13045        assert!(message.contains("greater than zero"));
13046    }
13047
13048    // ── exports.peer_tasks ───────────────────────────────────────────────────
13049
13050    /// Absent and `accept: false` are the same posture; only an explicit `true` consents.
13051    #[test]
13052    fn peer_tasks_consents_only_on_an_explicit_true() {
13053        let absent = exports_manifest("artifacts: []\n").expect("no exports block parses");
13054        assert!(absent.exports.is_none());
13055        assert!(!absent.accepts_peer_tasks());
13056
13057        let refused = exports_manifest("exports:\n  peer_tasks:\n    accept: false\n")
13058            .expect("an explicit refusal parses");
13059        assert_eq!(
13060            refused.exports.as_ref().unwrap().peer_tasks,
13061            Some(PeerTasksExport { accept: false })
13062        );
13063        assert!(!refused.accepts_peer_tasks());
13064        assert!(!refused.exports.as_ref().unwrap().accepts_peer_tasks());
13065
13066        let accepted = exports_manifest("exports:\n  peer_tasks:\n    accept: true\n")
13067            .expect("consent parses");
13068        assert_eq!(
13069            accepted.exports.as_ref().unwrap().peer_tasks,
13070            Some(PeerTasksExport { accept: true })
13071        );
13072        assert!(accepted.accepts_peer_tasks());
13073        assert!(accepted.exports.as_ref().unwrap().accepts_peer_tasks());
13074    }
13075
13076    /// An `exports:` block declaring only `peer_tasks` opens neither file plane.
13077    #[test]
13078    fn peer_tasks_alone_declares_no_file_plane() {
13079        let manifest = exports_manifest("exports:\n  peer_tasks:\n    accept: true\n")
13080            .expect("a peer_tasks-only exports block parses");
13081        let exports = manifest.exports.unwrap();
13082        assert_eq!(exports.files, None);
13083        assert_eq!(exports.peer_files, None);
13084    }
13085
13086    /// A file export says nothing about peer tasks.
13087    #[test]
13088    fn a_file_export_does_not_consent_to_peer_tasks() {
13089        let manifest =
13090            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
13091                .expect("a files export parses");
13092        assert_eq!(manifest.exports.as_ref().unwrap().peer_tasks, None);
13093        assert!(!manifest.accepts_peer_tasks());
13094    }
13095
13096    /// Consent is never inferred: a `peer_tasks:` block without `accept:` is refused naming the
13097    /// key, as `capabilities.plan` without `submit:` is.
13098    #[test]
13099    fn peer_tasks_without_accept_is_refused() {
13100        let (field, message) = export_error("exports:\n  peer_tasks: {}\n");
13101        assert_eq!(field, "exports.peer_tasks.accept");
13102        assert_eq!(
13103            message,
13104            "must be set explicitly to true or false — consent to peer tasks is never inferred"
13105        );
13106    }
13107
13108    /// A value of the wrong type is refused at parse, and the refusal names where it is.
13109    #[test]
13110    fn peer_tasks_of_the_wrong_type_is_refused_naming_the_field() {
13111        for (block, field) in [
13112            (
13113                "exports:\n  peer_tasks:\n    accept: \"yes\"\n",
13114                "exports.peer_tasks.accept",
13115            ),
13116            (
13117                "exports:\n  peer_tasks:\n    accept: 1\n",
13118                "exports.peer_tasks.accept",
13119            ),
13120            ("exports:\n  peer_tasks: true\n", "exports.peer_tasks"),
13121            ("exports:\n  peer_tasks: [peer-a]\n", "exports.peer_tasks"),
13122        ] {
13123            let message = exports_manifest(block)
13124                .expect_err(&format!("{block:?} must not parse"))
13125                .to_string();
13126            assert!(message.contains(field), "{block:?}: {message}");
13127        }
13128    }
13129
13130    /// An unknown key under the block is a warning reported by path, not a refusal — and naming
13131    /// a peer there grants nothing.
13132    #[test]
13133    fn an_unknown_peer_tasks_key_is_reported_by_path() {
13134        let manifest =
13135            exports_manifest("exports:\n  peer_tasks:\n    accept: false\n    allow: [peer-a]\n")
13136                .expect("an unknown key is a warning, not a parse error");
13137        assert!(
13138            manifest
13139                .unknown_keys
13140                .iter()
13141                .any(|key| key.block_path == "exports.peer_tasks" && key.key == "allow"),
13142            "{:?}",
13143            manifest.unknown_keys
13144        );
13145        assert!(!manifest.accepts_peer_tasks());
13146    }
13147
13148    // ── capabilities.peer_fetch ──────────────────────────────────────────────
13149
13150    #[test]
13151    fn peer_fetch_allow_carries_its_declared_destinations() {
13152        let manifest = exports_manifest(
13153            "capabilities:\n  peer_fetch:\n    allow:\n      - localhost:41234\n      \
13154             - reporting.internal\n      - https://gateway.example.com\n",
13155        )
13156        .expect("a peer_fetch block parses");
13157        assert_eq!(
13158            manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
13159            vec![
13160                "localhost:41234".to_string(),
13161                "reporting.internal".to_string(),
13162                "https://gateway.example.com".to_string(),
13163            ]
13164        );
13165    }
13166
13167    /// An empty list is a parse error rather than a silent deny: `allow: []` reads as a declared
13168    /// grant and would otherwise refuse every fetch with no line to point at.
13169    #[test]
13170    fn peer_fetch_allow_must_not_be_empty() {
13171        let (field, message) = peer_fetch_error("capabilities:\n  peer_fetch:\n    allow: []\n");
13172        assert_eq!(field, "capabilities.peer_fetch.allow");
13173        assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
13174    }
13175
13176    #[test]
13177    fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
13178        for entry in [
13179            "http://[not a host",
13180            "\"\"",
13181            "localhost:notaport",
13182            "http://example.com/path",
13183            "ftp://example.com",
13184        ] {
13185            let (field, message) = peer_fetch_error(&format!(
13186                "capabilities:\n  peer_fetch:\n    allow:\n      - {entry}\n"
13187            ));
13188            assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
13189            assert!(
13190                message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
13191                "entry: {entry}; got: {message}"
13192            );
13193        }
13194    }
13195
13196    /// `peer_fetch` is a separate authoriser and never an alias: declaring it leaves
13197    /// `network.allow` exactly as the manifest wrote it, and vice versa.
13198    #[test]
13199    fn peer_fetch_does_not_widen_network_allow() {
13200        let capabilities = exports_manifest(
13201            "capabilities:\n  network:\n    allow:\n      - api.example.com\n  \
13202             peer_fetch:\n    allow:\n      - localhost:41234\n",
13203        )
13204        .unwrap()
13205        .capabilities
13206        .unwrap();
13207        assert_eq!(
13208            capabilities.network.unwrap().allow,
13209            vec!["api.example.com".to_string()]
13210        );
13211        assert_eq!(
13212            capabilities.peer_fetch.unwrap().allow,
13213            vec!["localhost:41234".to_string()]
13214        );
13215    }
13216
13217    #[test]
13218    fn an_absent_peer_fetch_block_is_absent() {
13219        assert!(exports_manifest("capabilities: {}\n")
13220            .unwrap()
13221            .capabilities
13222            .unwrap()
13223            .peer_fetch
13224            .is_none());
13225    }
13226
13227    #[test]
13228    fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
13229        assert_eq!(parse_byte_size("0").unwrap(), 0);
13230        assert_eq!(parse_byte_size("4096").unwrap(), 4096);
13231        assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
13232        assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
13233        assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
13234        assert_eq!(parse_byte_size("  10Mi  ").unwrap(), 10 * 1024 * 1024);
13235    }
13236
13237    /// Decimal suffixes are refused rather than guessed at: a manifest meaning 10 000 000 and one
13238    /// meaning 10 485 760 must not both parse.
13239    #[test]
13240    fn parse_byte_size_rejects_every_other_spelling() {
13241        for input in [
13242            "", "  ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
13243            "1Ki1",
13244        ] {
13245            let error = parse_byte_size(input)
13246                .expect_err(&format!("'{input}' must not parse as a byte size"));
13247            assert!(
13248                error.contains(BYTE_SIZE_ACCEPTED_FORM),
13249                "'{input}' must be refused with the accepted form; got: {error}"
13250            );
13251        }
13252    }
13253
13254    #[test]
13255    fn parse_byte_size_refuses_to_overflow() {
13256        assert!(parse_byte_size("18446744073709551615Gi")
13257            .unwrap_err()
13258            .contains("overflows"));
13259    }
13260
13261    #[test]
13262    fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
13263        let manifest =
13264            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
13265                .expect("a minimal exports block parses");
13266        let files = manifest.exports.unwrap().files.unwrap();
13267        assert_eq!(files.root, "out/");
13268        assert_eq!(files.mode, ExportMode::ReadOnly);
13269        assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
13270        assert_eq!(files.max_bytes, 10_485_760);
13271    }
13272
13273    #[test]
13274    fn exports_files_accepts_both_byte_spellings() {
13275        for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
13276            let manifest = exports_manifest(&format!(
13277                "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: {declared}\n"
13278            ))
13279            .expect("a declared max_bytes parses");
13280            assert_eq!(
13281                manifest.exports.unwrap().files.unwrap().max_bytes,
13282                expected,
13283                "max_bytes: {declared}"
13284            );
13285        }
13286    }
13287
13288    /// An `exports:` block with no `files:` is not a resource plane. It parses, and the plane
13289    /// stays undeclared — which is the deny case, not a defaulted-open one.
13290    #[test]
13291    fn an_exports_block_without_files_declares_no_plane() {
13292        let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
13293        assert!(manifest.exports.unwrap().files.is_none());
13294    }
13295
13296    #[test]
13297    fn an_absent_exports_block_is_absent() {
13298        let manifest = exports_manifest("").expect("a manifest without exports parses");
13299        assert!(manifest.exports.is_none());
13300    }
13301
13302    #[test]
13303    fn exports_files_root_must_be_relative_and_inside_the_workdir() {
13304        for root in ["../out", "/etc", "out/../../etc", "''"] {
13305            let (field, message) = export_error(&format!(
13306                "exports:\n  files:\n    root: {root}\n    mode: read-only\n"
13307            ));
13308            assert_eq!(field, "exports.files.root", "root: {root}");
13309            assert!(
13310                message.contains("must be a relative path inside the workdir"),
13311                "root: {root}; got: {message}"
13312            );
13313        }
13314    }
13315
13316    #[test]
13317    fn exports_files_root_is_required() {
13318        let (field, message) = export_error("exports:\n  files:\n    mode: read-only\n");
13319        assert_eq!(field, "exports.files.root");
13320        assert_eq!(
13321            message,
13322            "is required and must be a relative path inside the workdir"
13323        );
13324    }
13325
13326    #[test]
13327    fn exports_files_mode_is_required_and_read_only() {
13328        let (field, message) = export_error("exports:\n  files:\n    root: out/\n");
13329        assert_eq!(field, "exports.files.mode");
13330        assert_eq!(message, "is required and must be 'read-only'");
13331
13332        let (field, message) =
13333            export_error("exports:\n  files:\n    root: out/\n    mode: read-write\n");
13334        assert_eq!(field, "exports.files.mode");
13335        assert_eq!(message, "'read-write' must be 'read-only'");
13336    }
13337
13338    #[test]
13339    fn exports_files_max_bytes_states_its_accepted_form() {
13340        let (field, message) = export_error(
13341            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 10MB\n",
13342        );
13343        assert_eq!(field, "exports.files.max_bytes");
13344        assert_eq!(
13345            message,
13346            "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
13347        );
13348    }
13349
13350    /// A zero ceiling would refuse every read while reading like a declared export, so it is
13351    /// refused where it is written rather than at the first request.
13352    #[test]
13353    fn exports_files_max_bytes_rejects_zero() {
13354        let (field, message) = export_error(
13355            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 0\n",
13356        );
13357        assert_eq!(field, "exports.files.max_bytes");
13358        assert!(message.contains("must be greater than zero"), "{message}");
13359    }
13360
13361    /// A leading `./` is a relative path like any other and is not an escape.
13362    #[test]
13363    fn exports_files_root_accepts_a_current_dir_prefix() {
13364        let manifest =
13365            exports_manifest("exports:\n  files:\n    root: ./out\n    mode: read-only\n")
13366                .expect("./out is a legal root");
13367        assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
13368    }
13369
13370    // ── Unrecognized manifest keys (W-SEC-019) ────────────────────────────────
13371
13372    fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
13373        RuntimeManifest::from_yaml_str(yaml)
13374            .expect("fixture must parse")
13375            .unknown_keys
13376            .into_iter()
13377            .map(|key| (key.key, key.block_path, key.nearest_known))
13378            .collect()
13379    }
13380
13381    /// The typo the whole warning exists for: a hyphen where the key takes an underscore, named
13382    /// with the block that held it and with the key it should have been.
13383    #[test]
13384    fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
13385        assert_eq!(
13386            unknown_keys_of(
13387                "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    read-only:\n      - tests\n"
13388            ),
13389            vec![(
13390                "read-only".to_string(),
13391                "capabilities.filesystem".to_string(),
13392                Some("read_only".to_string())
13393            )]
13394        );
13395    }
13396
13397    /// A manifest exercising every layer the walk descends through — capsule-wide capabilities,
13398    /// a per-artifact block, shell, network and an artifact entry — reports nothing. A warning
13399    /// that fires on correct input is noise, and noise is ignored.
13400    #[test]
13401    fn a_manifest_of_recognized_keys_reports_nothing() {
13402        assert!(unknown_keys_of(
13403            r#"
13404name: cap
13405version: 0.1.0
13406deploy:
13407  mur_version: "0.2.0"
13408artifacts:
13409  - name: local/notes-tool
13410    version: 0.1.0
13411    runtime: tool
13412    capabilities:
13413      filesystem:
13414        scope: notes
13415      network:
13416        allow:
13417          - example.com:443
13418capabilities:
13419  filesystem:
13420    read_only:
13421      - tests
13422      - bench/fixtures
13423    workdir_exec: false
13424  shell:
13425    allow:
13426      - git
13427      - python3
13428  network:
13429    allow:
13430      - example.com:443
13431    unix_sockets: false
13432"#
13433        )
13434        .is_empty());
13435    }
13436
13437    /// A per-artifact capability block reports under its artifact's index, so an operator is not
13438    /// sent to the capsule-wide block for a key they wrote on one entry.
13439    #[test]
13440    fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
13441        assert_eq!(
13442            unknown_keys_of(
13443                "name: cap\nversion: 0.1.0\nartifacts:\n  - name: local/t\n    version: 0.1.0\n    \
13444                 runtime: tool\n    capabilities:\n      shell:\n        allow:\n          - git\n        \
13445                 allwo:\n          - git\n"
13446            ),
13447            vec![(
13448                "allwo".to_string(),
13449                "artifacts[0].capabilities.shell".to_string(),
13450                Some("allow".to_string())
13451            )]
13452        );
13453    }
13454
13455    /// A top-level key with no near neighbour reports an empty path and no suggestion — the two
13456    /// facts the emitter needs to word it as a possibly-newer key rather than a misspelling.
13457    #[test]
13458    fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
13459        assert_eq!(
13460            unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
13461            vec![("quantum_teleport".to_string(), String::new(), None)]
13462        );
13463    }
13464
13465    /// The minimum publishable delegated sub-capsule — `runtime:` plus `execution:` — reports
13466    /// nothing. Every registrant of a delegating capsule must be published, so every one of them
13467    /// carries these keys; warning on them would make the diagnostic unreadable exactly where it
13468    /// is needed most.
13469    #[test]
13470    fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
13471        assert!(unknown_keys_of(
13472            "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
13473        )
13474        .is_empty());
13475    }
13476
13477    /// All four keys `mur build` and `mur publish` read beyond `name:` and `version:`, together.
13478    #[test]
13479    fn every_build_side_key_is_recognized() {
13480        assert!(unknown_keys_of(
13481            "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
13482             wasm\nrequires_files:\n  - tool.wasm\n  - assets/logo.png\n"
13483        )
13484        .is_empty());
13485    }
13486
13487    /// `W-SEC-019` still covers every key no command reads: one sitting beside the four
13488    /// build-side keys reports, and reports alone.
13489    #[test]
13490    fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
13491        assert_eq!(
13492            unknown_keys_of(
13493                "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
13494                 static\nrequires_files:\n  - capsule.wasm\nquantum_teleport: true\n"
13495            ),
13496            vec![("quantum_teleport".to_string(), String::new(), None)]
13497        );
13498    }
13499
13500    /// The four keys are recognized and then dropped: they add no refusal and no run-time meaning,
13501    /// so a manifest carrying them parses to what it parses to without them. A wrong-shaped value
13502    /// is build's business to reject, not the run path's.
13503    #[test]
13504    fn the_build_side_keys_change_nothing_the_run_path_reads() {
13505        let body = "artifacts:\n  - name: local/notes-tool\n    version: 0.1.0\n    runtime: tool\n\
13506                    \x20 - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: \
13507                    driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\n\
13508                    capabilities:\n  filesystem:\n    read_only:\n      - tests\n\
13509                    inference:\n  transport: http\n  model: test-model\n  driver:\n    \
13510                    artifact: murmur/driver-anthropic\n";
13511        let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
13512            .expect("fixture must parse");
13513        let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
13514            "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n  \
13515             any: shape\nrequires_files: not-a-list\n{body}"
13516        ))
13517        .expect("a build-side key of any shape never refuses a manifest");
13518
13519        assert!(with_build_keys.unknown_keys.is_empty());
13520        assert!(
13521            !plain.artifacts.is_empty()
13522                && plain.capabilities.is_some()
13523                && plain.inference.is_some(),
13524            "the fixture must populate the blocks the comparison below rests on, or it \
13525             compares absence against absence"
13526        );
13527        assert_eq!(with_build_keys.name, plain.name);
13528        assert_eq!(with_build_keys.version, plain.version);
13529        assert_eq!(with_build_keys.artifacts, plain.artifacts);
13530        assert_eq!(with_build_keys.capabilities, plain.capabilities);
13531        assert_eq!(with_build_keys.inference, plain.inference);
13532    }
13533
13534    /// The overflow map captures the key without consuming the block: the rest of it still parses
13535    /// and still applies. A capsule with an unrecognized key launches.
13536    #[test]
13537    fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
13538        let manifest = RuntimeManifest::from_yaml_str(
13539            "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    scope: notes\n    \
13540             read-only:\n      - tests\n",
13541        )
13542        .expect("an unrecognized key never refuses a manifest");
13543        assert_eq!(
13544            manifest
13545                .capabilities
13546                .as_ref()
13547                .and_then(|caps| caps.filesystem.as_ref())
13548                .and_then(|fs| fs.scope.as_deref()),
13549            Some("notes")
13550        );
13551        assert_eq!(manifest.unknown_keys.len(), 1);
13552    }
13553
13554    /// Every block on the way down is walked, not just the capability sub-blocks.
13555    #[test]
13556    fn nested_blocks_outside_capabilities_are_walked_too() {
13557        let keys = unknown_keys_of(
13558            "name: cap\nversion: 0.1.0\ncontext:\n  max_tokns: 100\nexports:\n  files:\n    \
13559             root: out\n    mode: read-only\n    max_byts: 10\n",
13560        );
13561        assert_eq!(
13562            keys,
13563            vec![
13564                (
13565                    "max_tokns".to_string(),
13566                    "context".to_string(),
13567                    Some("max_tokens".to_string())
13568                ),
13569                (
13570                    "max_byts".to_string(),
13571                    "exports.files".to_string(),
13572                    Some("max_bytes".to_string())
13573                ),
13574            ]
13575        );
13576    }
13577
13578    /// Source-derived, so a `Raw*` struct added later cannot skip the overflow map, and a field
13579    /// added to one cannot skip its `KNOWN_KEYS`: the suggester would then never propose it.
13580    ///
13581    /// Reads this file's own text rather than a hand-written list for the same reason the
13582    /// `CapabilityPolicy` coverage test in `capsule-runtime` does — a list maintained by hand
13583    /// records what someone remembered, not what the code says.
13584    #[test]
13585    fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
13586        let source = include_str!("runtime_manifest.rs");
13587        let lines: Vec<&str> = source.lines().collect();
13588
13589        let mut checked = 0usize;
13590        for (start, line) in lines.iter().enumerate() {
13591            let Some(name) = line
13592                .strip_prefix("struct Raw")
13593                .and_then(|rest| rest.strip_suffix(" {"))
13594            else {
13595                continue;
13596            };
13597            let name = format!("Raw{name}");
13598            let end = (start + 1..lines.len())
13599                .find(|index| lines[*index] == "}")
13600                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
13601            let body = &lines[start + 1..end];
13602
13603            assert!(
13604                body.contains(&"    #[serde(flatten)]")
13605                    && body.contains(&"    unknown: UnknownKeys,"),
13606                "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
13607                 so a key it does not recognize would be dropped instead of reported"
13608            );
13609
13610            let declared = serde_field_names(body);
13611            let known = known_keys_of(source, &name);
13612            assert_eq!(
13613                declared, known,
13614                "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
13615                 suggester would propose the wrong set for that block"
13616            );
13617            checked += 1;
13618        }
13619        assert!(
13620            checked >= 30,
13621            "expected every Raw* struct to be scanned, found only {checked}"
13622        );
13623    }
13624
13625    /// Source-derived for the same reason its sibling above is: a `Raw*`-typed field added to a
13626    /// block and left unwalked reports none of the keys inside that block, and reports nothing
13627    /// about having done so. The scan is keyed on the *parent's field*, not on the child's type,
13628    /// because one type can sit under several parents — `RawInferenceDriver` is both
13629    /// `inference.driver` and each alternate's `driver` — and a check keyed on the type would call
13630    /// one covered because the other is walked.
13631    #[test]
13632    fn every_raw_struct_descends_into_the_blocks_it_owns() {
13633        let source = include_str!("runtime_manifest.rs");
13634        let lines: Vec<&str> = source.lines().collect();
13635
13636        let mut structs = 0usize;
13637        let mut children = 0usize;
13638        for (start, line) in lines.iter().enumerate() {
13639            let Some(name) = line
13640                .strip_prefix("struct Raw")
13641                .and_then(|rest| rest.strip_suffix(" {"))
13642            else {
13643                continue;
13644            };
13645            let name = format!("Raw{name}");
13646            let end = (start + 1..lines.len())
13647                .find(|index| lines[*index] == "}")
13648                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
13649            let owned = block_typed_fields(&lines[start + 1..end]);
13650            structs += 1;
13651
13652            if owned.is_empty() {
13653                continue;
13654            }
13655            let block = raw_block_impl_of(source, &name);
13656            assert!(
13657                block.contains("fn walk_children"),
13658                "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
13659                 `walk_children`, so every key written inside {} would go unreported",
13660                if owned.len() == 1 { "" } else { "s" },
13661                owned.join(", "),
13662                if owned.len() == 1 { "it" } else { "them" }
13663            );
13664            for field in &owned {
13665                assert!(
13666                    mentions_field(&block, field),
13667                    "{name}'s `walk_children` never touches `self.{field}`, so every key written \
13668                     inside that block would go unreported — descend into it with \
13669                     `collect_block(.., &child_path(path, \"{field}\"), out)`"
13670                );
13671                children += 1;
13672            }
13673        }
13674        assert!(
13675            structs >= 30,
13676            "expected every Raw* struct to be scanned, found only {structs}"
13677        );
13678        assert!(
13679            children >= 31,
13680            "expected every nested block field to be scanned, found only {children}"
13681        );
13682    }
13683
13684    /// Whether `impl` reads `self.<field>` as that whole field, not as the head of a longer one:
13685    /// a plain substring test lets a struct owning both `foo` and `foo_bar` walk only `foo_bar`
13686    /// and still be called covered, which is the silent pass this scan exists to deny.
13687    fn mentions_field(impl_body: &str, field: &str) -> bool {
13688        let needle = format!("self.{field}");
13689        impl_body.match_indices(&needle).any(|(at, _)| {
13690            impl_body[at + needle.len()..]
13691                .chars()
13692                .next()
13693                .is_none_or(|next| !next.is_alphanumeric() && next != '_')
13694        })
13695    }
13696
13697    /// The fields of one struct body whose declared type names another `Raw*` type, in
13698    /// declaration order. Comment and attribute lines are skipped, so a doc comment that mentions
13699    /// a `Raw*` type by name is not read as a field.
13700    fn block_typed_fields(body: &[&str]) -> Vec<String> {
13701        let mut fields = Vec::new();
13702        for line in body {
13703            let trimmed = line.trim();
13704            if trimmed.starts_with("//") || trimmed.starts_with("#[") {
13705                continue;
13706            }
13707            let Some((field, declared)) = line
13708                .strip_prefix("    ")
13709                .and_then(|rest| rest.split_once(':'))
13710            else {
13711                continue;
13712            };
13713            if field.is_empty()
13714                || !field
13715                    .chars()
13716                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
13717            {
13718                continue;
13719            }
13720            if declared.contains("Raw") {
13721                fields.push(field.to_string());
13722            }
13723        }
13724        fields
13725    }
13726
13727    /// The text of `impl RawBlock for <name>`, cut at the next line that is exactly `}` at column
13728    /// 0 so one impl's body cannot be read as the next one's.
13729    fn raw_block_impl_of(source: &str, name: &str) -> String {
13730        let header = format!("impl RawBlock for {name} {{");
13731        let body = source
13732            .split_once(&header)
13733            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
13734            .1;
13735        body.split_once("\n}\n")
13736            .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
13737            .0
13738            .to_string()
13739    }
13740
13741    /// The walk's own result, taken straight off a deserialized `RawRuntimeManifest`.
13742    ///
13743    /// Deliberately not routed through [`RuntimeManifest::from_yaml_str`]: that validates, and a
13744    /// fixture that names every block at once would have to satisfy every semantic rule as well
13745    /// to reach the walk. What is under test here is which blocks are descended into, which the
13746    /// validator has no part in.
13747    fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
13748        let raw: RawRuntimeManifest = serde_norway::from_str(yaml).expect("fixture must parse");
13749        collect_unknown_keys(&raw)
13750            .into_iter()
13751            .map(|key| (key.key, key.block_path))
13752            .collect()
13753    }
13754
13755    /// Every block the manifest type owns, each carrying one key no field of it claims, reported
13756    /// once and at its own dotted path. A block added after this one is held to the same rule by
13757    /// `every_raw_struct_descends_into_the_blocks_it_owns`; this is what "descended into" means.
13758    #[test]
13759    fn every_block_reports_its_own_unknown_key_at_its_own_path() {
13760        let keys = raw_unknown_keys(
13761            r#"
13762name: every-block
13763version: 0.1.0
13764probe_root: 1
13765artifacts:
13766  - name: local/t
13767    version: 0.1.0
13768    runtime: tool
13769    probe_artifact: 1
13770    capabilities:
13771      probe_artifact_capabilities: 1
13772      shell:
13773        probe_artifact_shell: 1
13774capabilities:
13775  probe_capabilities: 1
13776  network:
13777    probe_network: 1
13778  peer_fetch:
13779    probe_peer_fetch: 1
13780  filesystem:
13781    probe_filesystem: 1
13782  shell:
13783    probe_shell: 1
13784    interpreter_runtime:
13785      - binary: python3
13786        probe_interpreter_runtime: 1
13787        dirs:
13788          - path: /usr/lib/python3
13789            probe_interpreter_runtime_dir: 1
13790    staged_runtime:
13791      - binary: rg
13792        probe_staged_runtime: 1
13793  spawn:
13794    probe_spawn: 1
13795  env:
13796    probe_env: 1
13797  limits:
13798    probe_limits: 1
13799  resources:
13800    probe_resources: 1
13801  state:
13802    probe_state: 1
13803  task_io:
13804    probe_task_io: 1
13805  conversation:
13806    probe_conversation: 1
13807  install:
13808    probe_install: 1
13809inference:
13810  probe_inference: 1
13811  driver:
13812    probe_driver: 1
13813  compaction:
13814    probe_compaction: 1
13815context:
13816  probe_context: 1
13817observability:
13818  probe_observability: 1
13819  eval:
13820    probe_eval: 1
13821    scorers:
13822      - type: exact_match
13823        probe_scorer: 1
13824trace:
13825  probe_trace: 1
13826network:
13827  probe_network_config: 1
13828lifecycle:
13829  probe_lifecycle: 1
13830exports:
13831  probe_exports: 1
13832  files:
13833    probe_files: 1
13834  peer_files:
13835    probe_peer_files: 1
13836control:
13837  probe_control: 1
13838"#,
13839        );
13840
13841        let expected: Vec<(String, String)> = [
13842            ("probe_root", ""),
13843            ("probe_artifact", "artifacts[0]"),
13844            ("probe_artifact_capabilities", "artifacts[0].capabilities"),
13845            ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
13846            ("probe_capabilities", "capabilities"),
13847            ("probe_network", "capabilities.network"),
13848            ("probe_peer_fetch", "capabilities.peer_fetch"),
13849            ("probe_filesystem", "capabilities.filesystem"),
13850            ("probe_shell", "capabilities.shell"),
13851            (
13852                "probe_interpreter_runtime",
13853                "capabilities.shell.interpreter_runtime[0]",
13854            ),
13855            (
13856                "probe_interpreter_runtime_dir",
13857                "capabilities.shell.interpreter_runtime[0].dirs[0]",
13858            ),
13859            (
13860                "probe_staged_runtime",
13861                "capabilities.shell.staged_runtime[0]",
13862            ),
13863            ("probe_spawn", "capabilities.spawn"),
13864            ("probe_env", "capabilities.env"),
13865            ("probe_limits", "capabilities.limits"),
13866            ("probe_resources", "capabilities.resources"),
13867            ("probe_state", "capabilities.state"),
13868            ("probe_task_io", "capabilities.task_io"),
13869            ("probe_conversation", "capabilities.conversation"),
13870            ("probe_install", "capabilities.install"),
13871            ("probe_inference", "inference"),
13872            ("probe_driver", "inference.driver"),
13873            ("probe_compaction", "inference.compaction"),
13874            ("probe_context", "context"),
13875            ("probe_observability", "observability"),
13876            ("probe_eval", "observability.eval"),
13877            ("probe_scorer", "observability.eval.scorers[0]"),
13878            ("probe_trace", "trace"),
13879            ("probe_network_config", "network"),
13880            ("probe_lifecycle", "lifecycle"),
13881            ("probe_exports", "exports"),
13882            ("probe_files", "exports.files"),
13883            ("probe_peer_files", "exports.peer_files"),
13884            ("probe_control", "control"),
13885        ]
13886        .into_iter()
13887        .map(|(key, path)| (key.to_string(), path.to_string()))
13888        .collect();
13889
13890        assert_eq!(keys, expected);
13891    }
13892
13893    /// The names serde matches on for one struct body: field names in declaration order, with a
13894    /// `#[serde(rename = "...")]` field under its renamed spelling, and the overflow map itself
13895    /// excluded because it claims no name of its own.
13896    fn serde_field_names(body: &[&str]) -> Vec<String> {
13897        let mut names = Vec::new();
13898        let mut rename: Option<String> = None;
13899        for line in body {
13900            let trimmed = line.trim();
13901            if trimmed.starts_with("#[") {
13902                if let Some(rest) = trimmed.split("rename = \"").nth(1) {
13903                    if !trimmed.contains("rename_all") {
13904                        rename = rest.split('"').next().map(str::to_string);
13905                    }
13906                }
13907                continue;
13908            }
13909            if trimmed.starts_with("//") {
13910                continue;
13911            }
13912            let Some(field) = line
13913                .strip_prefix("    ")
13914                .and_then(|rest| rest.split(':').next())
13915            else {
13916                continue;
13917            };
13918            if field.is_empty()
13919                || !field
13920                    .chars()
13921                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
13922            {
13923                continue;
13924            }
13925            if field == "unknown" {
13926                rename = None;
13927                continue;
13928            }
13929            names.push(rename.take().unwrap_or_else(|| field.to_string()));
13930        }
13931        names
13932    }
13933
13934    /// The `KNOWN_KEYS` array `impl RawBlock for <name>` declares, read out of the source text so
13935    /// the assertion compares two independent readings of the same file rather than one value
13936    /// against itself.
13937    fn known_keys_of(source: &str, name: &str) -> Vec<String> {
13938        let header = format!("impl RawBlock for {name} {{");
13939        let body = source
13940            .split_once(&header)
13941            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
13942            .1;
13943        // rustfmt moves an array too long for the declaration's line onto the next one.
13944        let array = body
13945            .split_once("KNOWN_KEYS: &'static [&'static str] =")
13946            .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
13947            .1
13948            .trim_start()
13949            .strip_prefix("&[")
13950            .unwrap_or_else(|| panic!("{name}'s KNOWN_KEYS is not an array literal"))
13951            .split_once("];")
13952            .expect("KNOWN_KEYS array is unterminated")
13953            .0;
13954        array
13955            .split('"')
13956            .skip(1)
13957            .step_by(2)
13958            .map(str::to_string)
13959            .collect()
13960    }
13961}
13962
13963#[cfg(test)]
13964mod control_tests {
13965    use super::*;
13966
13967    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";
13968
13969    /// An `http` agent capsule whose driver is keyed by `${DRIVER_KEY}` and whose `card-api` tool
13970    /// is keyed by `${CARD_TOKEN}`, with `control` spliced in at the top level.
13971    fn agent(transport: &str, control: &str) -> String {
13972        let driver = if transport == "http" {
13973            "  - 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"
13974        } else {
13975            "  - name: local/drv\n    version: 0.1.0\n    runtime: driver\n"
13976        };
13977        format!(
13978            "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}"
13979        )
13980    }
13981
13982    fn refusal(yaml: &str) -> (String, String) {
13983        match RuntimeManifest::from_yaml_str(yaml) {
13984            Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
13985            other => panic!("expected InvalidControl, got {other:?}"),
13986        }
13987    }
13988
13989    #[test]
13990    fn control_absent_empty_and_empty_lists_load_as_none() {
13991        for control in [
13992            "",
13993            "control: {}\n",
13994            "control:\n",
13995            "control:\n  settings: []\n  secrets: []\n",
13996        ] {
13997            let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
13998                .unwrap_or_else(|err| panic!("{control:?}: {err}"));
13999            assert_eq!(manifest.control, None, "{control:?}");
14000        }
14001    }
14002
14003    #[test]
14004    fn control_parses_settings_and_secrets_in_manifest_order() {
14005        let yaml = agent(
14006            "http",
14007            "control:\n  settings: [inference.max_tokens]\n  secrets: [CARD_TOKEN]\n",
14008        );
14009        for manifest in [
14010            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14011            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14012        ] {
14013            let control = manifest.control.unwrap();
14014            assert_eq!(
14015                control.settings,
14016                vec![ControllableSetting::InferenceMaxTokens]
14017            );
14018            assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
14019            assert!(control.declares_secret("CARD_TOKEN"));
14020            assert!(!control.declares_secret("DRIVER_KEY"));
14021        }
14022    }
14023
14024    #[test]
14025    fn control_setting_wire_names_round_trip() {
14026        for setting in ControllableSetting::ALL {
14027            assert_eq!(
14028                ControllableSetting::from_wire_name(setting.wire_name()),
14029                Some(*setting)
14030            );
14031        }
14032        assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
14033    }
14034
14035    /// Every refusal names `control.settings` or `control.secrets` as its field and quotes the
14036    /// entry it refused.
14037    #[test]
14038    fn control_manifest_refusals_name_the_field() {
14039        let cases: &[(&str, &str, &str, &str)] = &[
14040            (
14041                "http",
14042                "control:\n  settings: [inference.model]\n",
14043                "control.settings",
14044                "'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
14045            ),
14046            (
14047                "process",
14048                "control:\n  settings: [inference.max_tokens]\n",
14049                "control.settings",
14050                "'inference.max_tokens' is controllable only under inference.transport: http",
14051            ),
14052            (
14053                "http",
14054                "control:\n  settings: [inference.max_tokens, inference.max_tokens]\n",
14055                "control.settings",
14056                "'inference.max_tokens' is listed more than once",
14057            ),
14058            (
14059                "http",
14060                "control:\n  secrets: [CARD_TOKEN, CARD_TOKEN]\n",
14061                "control.secrets",
14062                "'CARD_TOKEN' is listed more than once",
14063            ),
14064            (
14065                "http",
14066                "control:\n  secrets: [card-token]\n",
14067                "control.secrets",
14068                "'card-token' is not a credential name",
14069            ),
14070            (
14071                "http",
14072                "control:\n  secrets: [OTHER_TOKEN]\n",
14073                "control.secrets",
14074                "'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
14075            ),
14076            (
14077                "http",
14078                "control:\n  secrets: [DRIVER_KEY]\n",
14079                "control.secrets",
14080                "'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.local/drv.gateway.api_key)",
14081            ),
14082            (
14083                "http",
14084                "control:\n  settings: inference.max_tokens\n",
14085                "control.settings",
14086                "must be a list of names",
14087            ),
14088            (
14089                "http",
14090                "control:\n  secrets: [1]\n",
14091                "control.secrets",
14092                "must be a list of names",
14093            ),
14094        ];
14095        for (transport, control, field, message) in cases {
14096            let (got_field, got_message) = refusal(&agent(transport, control));
14097            assert_eq!(&got_field, field, "{control:?}");
14098            assert!(
14099                got_message.starts_with(message),
14100                "{control:?}: {got_message}"
14101            );
14102            let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
14103                .unwrap_err()
14104                .to_string();
14105            assert!(
14106                rendered.contains(&format!("invalid control config for '{field}'")),
14107                "{rendered}"
14108            );
14109        }
14110    }
14111
14112    #[test]
14113    fn control_on_a_capsule_with_no_inference_is_refused() {
14114        let yaml = format!(
14115            "name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n  secrets: [CARD_TOKEN]\n"
14116        );
14117        let (field, message) = refusal(&yaml);
14118        assert_eq!(field, "control");
14119        assert!(message.contains("no inference: block"), "{message}");
14120    }
14121
14122    #[test]
14123    fn control_unknown_key_is_reported_once_at_its_block() {
14124        let manifest = RuntimeManifest::from_yaml_str(&agent(
14125            "http",
14126            "control:\n  settings: [inference.max_tokens]\n  agent_may_call: true\n",
14127        ))
14128        .unwrap();
14129        let keys: Vec<(&str, &str)> = manifest
14130            .unknown_keys
14131            .iter()
14132            .map(|key| (key.key.as_str(), key.block_path.as_str()))
14133            .collect();
14134        assert_eq!(keys, vec![("agent_may_call", "control")]);
14135        assert_eq!(
14136            manifest.control.unwrap().settings,
14137            vec![ControllableSetting::InferenceMaxTokens]
14138        );
14139    }
14140}
14141
14142#[cfg(test)]
14143mod driver_choice_tests {
14144    use super::*;
14145
14146    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";
14147    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";
14148    const ALT_NO_GATEWAY: &str = "  - name: local/alt\n    version: 0.1.0\n    runtime: driver\n";
14149    const TOOL: &str = "  - name: local/card-api\n    version: 0.1.0\n    runtime: tool\n";
14150    const GPT: &str =
14151        "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: local/alt\n";
14152    const HAIKU: &str =
14153        "    - name: haiku\n      model: haiku\n      driver:\n        artifact: local/drv\n";
14154    const SWITCHABLE: &str = "control:\n  settings: [inference.driver]\n";
14155
14156    /// An `http` capsule on `drv` with model `m`, `artifacts` after `drv`, `alternates` as the
14157    /// `inference.alternates` list body (empty for no key), and `control` at the top level.
14158    fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
14159        let alternates = if alternates.is_empty() {
14160            String::new()
14161        } else {
14162            format!("  alternates:\n{alternates}")
14163        };
14164        format!(
14165            "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}"
14166        )
14167    }
14168
14169    fn refusal(yaml: &str) -> (String, String) {
14170        match RuntimeManifest::from_yaml_str(yaml) {
14171            Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
14172            | Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14173            other => panic!("expected an inference or control refusal, got {other:?}"),
14174        }
14175    }
14176
14177    #[test]
14178    fn driver_choice_alternates_parse_in_manifest_order() {
14179        let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
14180        for manifest in [
14181            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14182            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14183        ] {
14184            let inference = manifest.inference.unwrap();
14185            assert_eq!(
14186                inference.alternates,
14187                vec![
14188                    InferenceAlternate {
14189                        name: "gpt".to_string(),
14190                        model: "gpt-5".to_string(),
14191                        driver: "local/alt".to_string(),
14192                    },
14193                    InferenceAlternate {
14194                        name: "haiku".to_string(),
14195                        model: "haiku".to_string(),
14196                        driver: "local/drv".to_string(),
14197                    },
14198                ]
14199            );
14200            let control = manifest.control.unwrap();
14201            assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
14202            assert!(control.agent_settings.is_empty());
14203        }
14204    }
14205
14206    #[test]
14207    fn driver_choice_absent_alternates_are_empty() {
14208        let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
14209        assert!(manifest.inference.unwrap().alternates.is_empty());
14210    }
14211
14212    #[test]
14213    fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
14214        let manifest = RuntimeManifest::from_yaml_str(&capsule(
14215            ALT,
14216            GPT,
14217            "control:\n  agent_settings: [inference.driver]\n",
14218        ))
14219        .unwrap();
14220        let control = manifest.control.unwrap();
14221        assert!(control.settings.is_empty());
14222        assert_eq!(
14223            control.agent_settings,
14224            vec![ControllableSetting::InferenceDriver]
14225        );
14226        assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
14227        assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
14228        assert!(control.secrets.is_empty());
14229    }
14230
14231    #[test]
14232    fn driver_choice_wire_name_round_trips() {
14233        assert_eq!(
14234            ControllableSetting::from_wire_name("inference.driver"),
14235            Some(ControllableSetting::InferenceDriver)
14236        );
14237        assert_eq!(
14238            ControllableSetting::InferenceDriver.wire_name(),
14239            "inference.driver"
14240        );
14241        assert_eq!(
14242            ControllableSetting::AGENT_SETTABLE,
14243            &[ControllableSetting::InferenceDriver]
14244        );
14245    }
14246
14247    /// An alternate's driver may carry `gateway:`, which a driver entry nothing names may not.
14248    #[test]
14249    fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
14250        RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
14251            .expect("an alternate's driver carries its own gateway");
14252        match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
14253            Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
14254                assert!(message.contains("inference.alternates"), "{message}")
14255            }
14256            other => panic!("expected InvalidArtifact, got {other:?}"),
14257        }
14258    }
14259
14260    /// Two models of one provider: the alternate shares the primary's driver and gateway.
14261    #[test]
14262    fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
14263        let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
14264        assert_eq!(
14265            manifest.inference.unwrap().alternates[0].driver,
14266            "local/drv"
14267        );
14268    }
14269
14270    #[test]
14271    fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
14272        let manifest = RuntimeManifest::from_yaml_str(&capsule(
14273            ALT,
14274            GPT,
14275            "control:\n  settings: [inference.driver]\n  secrets: [ALT_KEY]\n",
14276        ))
14277        .unwrap();
14278        assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
14279        let (field, message) = refusal(&capsule(
14280            ALT,
14281            GPT,
14282            "control:\n  settings: [inference.driver]\n  secrets: [DRIVER_KEY]\n",
14283        ));
14284        assert_eq!(field, "control.secrets");
14285        assert!(
14286            message.contains("keys the configured inference driver's gateway"),
14287            "{message}"
14288        );
14289    }
14290
14291    #[test]
14292    fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
14293        let alternate = "    - name: gpt\n      model: gpt-5\n      temperature: 1\n      driver:\n        artifact: local/alt\n";
14294        let manifest =
14295            RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
14296        let keys: Vec<(&str, &str)> = manifest
14297            .unknown_keys
14298            .iter()
14299            .map(|key| (key.key.as_str(), key.block_path.as_str()))
14300            .collect();
14301        assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
14302    }
14303
14304    /// Every refusal the alternates and the driver setting can draw, each naming its field and
14305    /// rendering as the manifest error `E-MAN-003` maps from.
14306    #[test]
14307    fn driver_choice_manifest_refusals_name_the_field() {
14308        let alternate = |name: &str, model: &str, driver: &str| {
14309            format!("    - name: {name}\n      model: {model}\n      driver:\n        artifact: {driver}\n")
14310        };
14311        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";
14312        let cases: Vec<(String, &str, &str)> = vec![
14313            (
14314                format!("{process}  alternates:\n{GPT}"),
14315                "inference.alternates",
14316                "is not valid with transport: process",
14317            ),
14318            (
14319                format!("{process}control:\n  settings: [inference.driver]\n"),
14320                "control.settings",
14321                "'inference.driver' is controllable only under inference.transport: http",
14322            ),
14323            (
14324                capsule(ALT, &alternate("primary", "gpt-5", "local/alt"), SWITCHABLE),
14325                "inference.alternates[0].name",
14326                "'primary' is reserved",
14327            ),
14328            (
14329                capsule(
14330                    ALT,
14331                    &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt", "gpt-4", "local/alt")),
14332                    SWITCHABLE,
14333                ),
14334                "inference.alternates[1].name",
14335                "'gpt' names more than one alternate",
14336            ),
14337            (
14338                capsule(ALT, &alternate("GPT", "gpt-5", "local/alt"), SWITCHABLE),
14339                "inference.alternates[0].name",
14340                "'GPT' is not a driver choice name",
14341            ),
14342            (
14343                capsule(ALT, &alternate("5gpt", "gpt-5", "local/alt"), SWITCHABLE),
14344                "inference.alternates[0].name",
14345                "'5gpt' is not a driver choice name",
14346            ),
14347            (
14348                capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "local/alt"), SWITCHABLE),
14349                "inference.alternates[0].name",
14350                "is not a driver choice name",
14351            ),
14352            (
14353                capsule(ALT, "    - name: gpt\n      model: \"\"\n      driver:\n        artifact: local/alt\n", SWITCHABLE),
14354                "inference.alternates[0].model",
14355                "missing required field",
14356            ),
14357            (
14358                capsule(ALT, "    - name: gpt\n      model: gpt-5\n", SWITCHABLE),
14359                "inference.alternates[0].driver.artifact",
14360                "missing required field",
14361            ),
14362            (
14363                capsule("", &alternate("gpt", "gpt-5", "local/nowhere"), SWITCHABLE),
14364                "inference.alternates[0].driver.artifact",
14365                "artifact 'local/nowhere' is not declared in artifacts:",
14366            ),
14367            (
14368                capsule(TOOL, &alternate("gpt", "gpt-5", "local/card-api"), SWITCHABLE),
14369                "inference.alternates[0].driver.artifact",
14370                "artifact 'local/card-api' is declared with runtime: tool",
14371            ),
14372            (
14373                capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "local/alt"), SWITCHABLE),
14374                "inference.alternates[0].driver.artifact",
14375                "artifact 'local/alt' declares no gateway.endpoint",
14376            ),
14377            (
14378                capsule(
14379                    ALT,
14380                    "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: local/alt\n        config:\n          thinking: enabled\n",
14381                    SWITCHABLE,
14382                ),
14383                "inference.alternates[0].driver.config",
14384                "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",
14385            ),
14386            (
14387                capsule("", &alternate("same", "m", "local/drv"), SWITCHABLE),
14388                "inference.alternates[0]",
14389                "'same' is driver 'local/drv' with model 'm', which is the primary choice",
14390            ),
14391            (
14392                capsule(
14393                    ALT,
14394                    &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt-too", "gpt-5", "local/alt")),
14395                    SWITCHABLE,
14396                ),
14397                "inference.alternates[1]",
14398                "'gpt-too' is driver 'local/alt' with model 'gpt-5', which alternate 'gpt' already is",
14399            ),
14400            (
14401                capsule(ALT, GPT, ""),
14402                "inference.alternates",
14403                "declares alternates that nothing could ever select",
14404            ),
14405            (
14406                capsule(ALT, GPT, "control:\n  settings: [inference.max_tokens]\n"),
14407                "inference.alternates",
14408                "declares alternates that nothing could ever select",
14409            ),
14410            (
14411                capsule("", "", SWITCHABLE),
14412                "control.settings",
14413                "'inference.driver' selects among inference.alternates, and this capsule declares none",
14414            ),
14415            (
14416                capsule("", "", "control:\n  agent_settings: [inference.driver]\n"),
14417                "control.agent_settings",
14418                "'inference.driver' selects among inference.alternates, and this capsule declares none",
14419            ),
14420            (
14421                capsule(ALT, GPT, "control:\n  agent_settings: [inference.max_tokens]\n"),
14422                "control.agent_settings",
14423                "'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
14424            ),
14425            (
14426                capsule(
14427                    ALT,
14428                    GPT,
14429                    "control:\n  agent_settings: [inference.driver, inference.driver]\n",
14430                ),
14431                "control.agent_settings",
14432                "'inference.driver' is listed more than once",
14433            ),
14434            (
14435                capsule(ALT, GPT, "control:\n  agent_settings: inference.driver\n"),
14436                "control.agent_settings",
14437                "must be a list of names",
14438            ),
14439        ];
14440        for (yaml, field, message) in &cases {
14441            let (got_field, got_message) = refusal(yaml);
14442            assert_eq!(&got_field, field, "{yaml}");
14443            assert!(got_message.contains(message), "{yaml}\n{got_message}");
14444            let rendered = RuntimeManifest::from_yaml_str(yaml)
14445                .unwrap_err()
14446                .to_string();
14447            assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
14448        }
14449    }
14450}
14451
14452#[cfg(test)]
14453mod network_authentication_tests {
14454    use super::*;
14455
14456    fn network_authentication_of(
14457        block: &str,
14458    ) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
14459        RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
14460            .map(|manifest| manifest.network.and_then(|network| network.authentication))
14461    }
14462
14463    fn network_authentication_refusal(block: &str) -> (String, String) {
14464        match network_authentication_of(block) {
14465            Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
14466            other => panic!("expected InvalidNetworkConfig, got {other:?}"),
14467        }
14468    }
14469
14470    #[test]
14471    fn network_authentication_with_credentials_parses_sorted_by_name() {
14472        let auth = network_authentication_of(
14473            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
14474             watcher:\n        scopes: [GetTask, murmur.WatchSession]\n      \
14475             reader:\n        scopes: [resources/files]\n",
14476        )
14477        .unwrap()
14478        .expect("declared");
14479        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
14480        assert_eq!(
14481            auth.credentials,
14482            vec![
14483                DoorCredential {
14484                    name: "reader".to_string(),
14485                    scopes: vec!["resources/files".to_string()],
14486                },
14487                DoorCredential {
14488                    name: "watcher".to_string(),
14489                    // Declared order, not sorted.
14490                    scopes: vec!["GetTask".to_string(), "murmur.WatchSession".to_string()],
14491                },
14492            ]
14493        );
14494    }
14495
14496    #[test]
14497    fn network_authentication_scheme_alone_parses() {
14498        let auth = network_authentication_of("network:\n  authentication:\n    scheme: bearer\n")
14499            .unwrap()
14500            .expect("declared");
14501        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
14502        assert!(auth.credentials.is_empty());
14503    }
14504
14505    #[test]
14506    fn network_authentication_absent_is_a_public_door() {
14507        assert_eq!(network_authentication_of("").unwrap(), None);
14508        assert_eq!(
14509            network_authentication_of("network:\n  internal_port: 8080\n").unwrap(),
14510            None
14511        );
14512        let manifest = RuntimeManifest::from_yaml_str(
14513            "name: cap\nversion: 0.1.0\nnetwork:\n  internal_port: 8080\n",
14514        )
14515        .unwrap();
14516        assert_eq!(
14517            manifest.network,
14518            Some(NetworkConfig {
14519                internal_port: Some(8080),
14520                authentication: None,
14521            })
14522        );
14523    }
14524
14525    #[test]
14526    fn network_authentication_without_a_scheme_is_refused() {
14527        let (field, message) = network_authentication_refusal(
14528            "network:\n  authentication:\n    credentials:\n      w:\n        scopes: [GetTask]\n",
14529        );
14530        assert_eq!(field, "network.authentication.scheme");
14531        assert!(message.contains("required"), "{message}");
14532    }
14533
14534    #[test]
14535    fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
14536        for scheme in ["mtls", "Bearer", "basic"] {
14537            let (field, message) = network_authentication_refusal(&format!(
14538                "network:\n  authentication:\n    scheme: {scheme}\n"
14539            ));
14540            assert_eq!(field, "network.authentication.scheme");
14541            assert!(message.contains("bearer"), "{message}");
14542            assert!(message.contains(scheme), "{message}");
14543        }
14544    }
14545
14546    #[test]
14547    fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
14548        let long = format!("a{}", "b".repeat(32));
14549        for name in [
14550            "operator",
14551            "Watcher",
14552            "1st",
14553            "_x",
14554            "-x",
14555            "wat.cher",
14556            long.as_str(),
14557        ] {
14558            let (field, message) = network_authentication_refusal(&format!(
14559                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
14560                 '{name}':\n        scopes: [GetTask]\n"
14561            ));
14562            assert_eq!(field, "network.authentication.credentials", "{name}");
14563            assert!(message.contains(name), "{message}");
14564        }
14565        let longest = format!("a{}", "b".repeat(31));
14566        for name in ["w", "a-b_c9", longest.as_str()] {
14567            network_authentication_of(&format!(
14568                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
14569                 {name}:\n        scopes: [GetTask]\n"
14570            ))
14571            .unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
14572        }
14573    }
14574
14575    #[test]
14576    fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
14577        let credential = |body: &str| {
14578            format!(
14579                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
14580                 watcher:\n{body}"
14581            )
14582        };
14583        let field = "network.authentication.credentials.watcher.scopes";
14584
14585        let (got, message) = network_authentication_refusal(&credential("        {}\n"));
14586        assert_eq!(got, field);
14587        assert!(message.contains("required"), "{message}");
14588
14589        let (got, message) = network_authentication_refusal(&credential("        scopes: []\n"));
14590        assert_eq!(got, field);
14591        assert!(message.contains("no scope"), "{message}");
14592
14593        let (got, message) =
14594            network_authentication_refusal(&credential("        scopes: [tasks/list]\n"));
14595        assert_eq!(got, field);
14596        assert!(message.contains("tasks/list"), "{message}");
14597        assert!(message.contains("resources/files"), "{message}");
14598
14599        let (got, message) =
14600            network_authentication_refusal(&credential("        scopes: [GetExtendedAgentCard]\n"));
14601        assert_eq!(got, field);
14602        assert!(message.contains("GetExtendedAgentCard"), "{message}");
14603
14604        let (got, message) = network_authentication_refusal(&credential(
14605            "        scopes: [GetTask, murmur.WatchSession, GetTask]\n",
14606        ));
14607        assert_eq!(got, field);
14608        assert!(message.contains("more than once"), "{message}");
14609    }
14610
14611    #[test]
14612    fn network_authentication_refuses_a_retired_scope_naming_its_replacement() {
14613        let field = "network.authentication.credentials.watcher.scopes";
14614        for (retired, replacement) in [
14615            ("message/send", "SendMessage"),
14616            ("message/stream", "SendStreamingMessage"),
14617            ("tasks/get", "GetTask"),
14618            ("tasks/cancel", "CancelTask"),
14619            ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
14620            ("stream/watch", "murmur.WatchSession"),
14621            ("session/stop", "murmur.StopSession"),
14622        ] {
14623            let error = network_authentication_of(&format!(
14624                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
14625                 watcher:\n        scopes: [murmur.WatchSession, {retired}]\n"
14626            ))
14627            .unwrap_err();
14628            let rendered = error.to_string();
14629            match error {
14630                RuntimeManifestError::RetiredDoorScope {
14631                    field: got_field,
14632                    scope,
14633                    replacement: got_replacement,
14634                } => {
14635                    assert_eq!(got_field, field);
14636                    assert_eq!(scope, retired);
14637                    assert_eq!(got_replacement, replacement);
14638                }
14639                other => panic!("expected RetiredDoorScope for {retired}, got {other:?}"),
14640            }
14641            assert!(
14642                rendered.contains(&format!(
14643                    "'{retired}' is a retired name of a door method, and the door answers only \
14644                     the names its card lists"
14645                )),
14646                "{rendered}"
14647            );
14648            assert!(rendered.contains(field), "{rendered}");
14649        }
14650    }
14651
14652    #[test]
14653    fn network_authentication_accepts_the_v1_scopes() {
14654        let auth = network_authentication_of(
14655            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
14656             watcher:\n        scopes: [GetTask, murmur.WatchSession, murmur.StopSession]\n",
14657        )
14658        .unwrap()
14659        .unwrap();
14660        assert_eq!(
14661            auth.credentials[0].scopes,
14662            ["GetTask", "murmur.WatchSession", "murmur.StopSession"]
14663        );
14664    }
14665
14666    /// The two task methods beside `GetTask` are scopes a credential may list, in the method
14667    /// table's order.
14668    #[test]
14669    fn door_scope_list_tasks_and_subscribe_to_task_load_on_a_credential() {
14670        let auth = network_authentication_of(
14671            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
14672             alice:\n        scopes: [SendMessage, ListTasks, SubscribeToTask]\n",
14673        )
14674        .unwrap()
14675        .unwrap();
14676        assert_eq!(
14677            auth.credentials[0].scopes,
14678            ["SendMessage", "ListTasks", "SubscribeToTask"]
14679        );
14680        assert_eq!(
14681            DOOR_SCOPES,
14682            [
14683                "SendMessage",
14684                "SendStreamingMessage",
14685                "murmur.WatchSession",
14686                "GetTask",
14687                "ListTasks",
14688                "CancelTask",
14689                "SubscribeToTask",
14690                "murmur.StopSession",
14691                "resources/files",
14692            ]
14693        );
14694    }
14695
14696    #[test]
14697    fn network_authentication_accepts_every_door_scope() {
14698        let auth = network_authentication_of(&format!(
14699            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
14700             everything:\n        scopes: [{}]\n",
14701            DOOR_SCOPES.join(", ")
14702        ))
14703        .unwrap()
14704        .unwrap();
14705        assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
14706        assert_eq!(OPERATOR_CREDENTIAL, "operator");
14707    }
14708
14709    #[test]
14710    fn network_authentication_is_refused_beside_spawn_allow() {
14711        let (field, message) = network_authentication_refusal(
14712            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
14713             allow: [local/worker]\n",
14714        );
14715        assert_eq!(field, "network.authentication");
14716        assert!(message.contains("capabilities.spawn.allow"), "{message}");
14717        assert!(message.contains("token"), "{message}");
14718        let error = RuntimeManifest::from_yaml_str(
14719            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n\
14720             capabilities:\n  spawn:\n    allow: [local/worker]\n",
14721        )
14722        .unwrap_err()
14723        .to_string();
14724        assert!(error.contains("network.authentication"), "{error}");
14725        assert!(error.contains("capabilities.spawn.allow"), "{error}");
14726
14727        // An empty allow-list delegates nothing, so it does not conflict.
14728        network_authentication_of(
14729            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
14730             allow: []\n",
14731        )
14732        .unwrap();
14733    }
14734
14735    #[test]
14736    fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
14737        let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
14738            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n    \
14739             schemes: x\n    credentials:\n      watcher:\n        scopes: [GetTask]\n        \
14740             scope: [GetTask]\n",
14741        )
14742        .unwrap()
14743        .unknown_keys
14744        .into_iter()
14745        .map(|key| (key.key, key.block_path, key.nearest_known))
14746        .collect();
14747        assert_eq!(
14748            keys,
14749            vec![
14750                (
14751                    "schemes".to_string(),
14752                    "network.authentication".to_string(),
14753                    Some("scheme".to_string())
14754                ),
14755                (
14756                    "scope".to_string(),
14757                    "network.authentication.credentials.watcher".to_string(),
14758                    Some("scopes".to_string())
14759                ),
14760            ]
14761        );
14762    }
14763}
14764
14765#[cfg(test)]
14766mod registry_block_tests {
14767    use super::*;
14768
14769    const REGISTRY_BLOCK: &str = "registry:\n  default: local\n";
14770
14771    #[test]
14772    fn registry_block_is_refused_by_both_entry_points() {
14773        let input = format!("name: cap\nversion: 0.0.1\nartifacts: []\n{REGISTRY_BLOCK}");
14774        for result in [
14775            RuntimeManifest::from_yaml_str(&input),
14776            RuntimeManifest::from_yaml_str_without_secrets(&input),
14777        ] {
14778            let err = result.unwrap_err();
14779            assert!(
14780                matches!(err, RuntimeManifestError::RegistryBlock),
14781                "{err:?}"
14782            );
14783            assert_eq!(err.to_string(), REGISTRY_BLOCK_MESSAGE);
14784        }
14785    }
14786
14787    #[test]
14788    fn registry_block_is_refused_before_any_other_field() {
14789        // `name` is missing and `lifecycle.queue_depth: 0` under `queue` is refused on its own;
14790        // the registry refusal still wins.
14791        let input = format!(
14792            "version: 0.0.1\nlifecycle:\n  task_acceptance: queue\n  queue_depth: 0\n\
14793             {REGISTRY_BLOCK}"
14794        );
14795        let err = RuntimeManifest::from_yaml_str(&input).unwrap_err();
14796        assert!(
14797            matches!(err, RuntimeManifestError::RegistryBlock),
14798            "{err:?}"
14799        );
14800    }
14801
14802    #[test]
14803    fn registry_block_refusal_applies_whatever_the_block_holds() {
14804        for block in ["registry: local\n", "registry: {}\n", "registry: []\n"] {
14805            let input = format!("name: cap\nversion: 0.0.1\n{block}");
14806            assert!(
14807                matches!(
14808                    RuntimeManifest::from_yaml_str(&input),
14809                    Err(RuntimeManifestError::RegistryBlock)
14810                ),
14811                "{block}"
14812            );
14813        }
14814    }
14815
14816    #[test]
14817    fn a_manifest_without_a_registry_block_parses_unchanged() {
14818        let manifest =
14819            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
14820        assert_eq!(manifest.name, "cap");
14821        assert_eq!(manifest.version, "0.0.1");
14822    }
14823}
14824
14825#[cfg(test)]
14826mod scoped_name_tests {
14827    use super::*;
14828
14829    fn name_refusal(yaml: &str) -> (String, String, Option<String>) {
14830        match RuntimeManifest::from_yaml_str(yaml) {
14831            Err(RuntimeManifestError::InvalidArtifactName {
14832                field,
14833                message,
14834                hint,
14835            }) => (field, message, hint),
14836            other => panic!("expected InvalidArtifactName, got {other:?}"),
14837        }
14838    }
14839
14840    #[test]
14841    fn scoped_name_a_bare_artifact_entry_is_refused_with_the_scoped_forms() {
14842        let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: tool-editor\n    \
14843                    version: 0.1.0\n    runtime: tool\n";
14844        let (field, message, hint) = name_refusal(yaml);
14845        assert_eq!(field, "artifacts[0].name");
14846        assert_eq!(
14847            message,
14848            "'tool-editor' is not a full artifact name: an artifact is named <scope>/<name>"
14849        );
14850        assert_eq!(
14851            hint.as_deref(),
14852            Some(
14853                "name it with its scope: 'murmur/tool-editor' for a Murmur default artifact, \
14854                 '<owner>/tool-editor' for one published from github.com/<owner>, or \
14855                 'local/tool-editor' for one installed from a .mur.zip file"
14856            )
14857        );
14858        let rendered = RuntimeManifest::from_yaml_str(yaml)
14859            .unwrap_err()
14860            .to_string();
14861        assert!(
14862            rendered.starts_with("murmur.yaml: invalid artifact name in 'artifacts[0].name': "),
14863            "{rendered}"
14864        );
14865    }
14866
14867    #[test]
14868    fn scoped_name_a_source_entry_keeps_its_bare_name() {
14869        let bare = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: style\n    \
14870                    source: ./skills/style\n    runtime: skill\n";
14871        RuntimeManifest::from_yaml_str(bare).expect("a source: entry is named by its bare name");
14872
14873        let (field, message, hint) = name_refusal(&bare.replace("name: style", "name: ana/style"));
14874        assert_eq!(field, "artifacts[0].name");
14875        assert!(
14876            message
14877                .starts_with("'ana/style' declares 'source:', so it is named by its bare name; "),
14878            "{message}"
14879        );
14880        assert_eq!(hint.as_deref(), Some("drop the scope: name it 'style'"));
14881    }
14882
14883    #[test]
14884    fn scoped_name_spawn_allow_refuses_a_bare_entry_and_a_shared_bare_part() {
14885        let with_allow = |allow: &str| {
14886            format!("name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    allow: [{allow}]\n")
14887        };
14888        let manifest = RuntimeManifest::from_yaml_str(&with_allow("local/worker, ana/reviewer"))
14889            .expect("full names in two scopes with distinct bare parts parse");
14890        assert_eq!(
14891            manifest.capabilities.unwrap().spawn.unwrap().allow,
14892            vec!["local/worker".to_string(), "ana/reviewer".to_string()]
14893        );
14894
14895        let (field, message, hint) = name_refusal(&with_allow("worker"));
14896        assert_eq!(field, "capabilities.spawn.allow[0]");
14897        assert!(
14898            message.contains("'worker' is not a full artifact name"),
14899            "{message}"
14900        );
14901        assert!(hint.unwrap().contains("local/worker"));
14902
14903        let (field, message, hint) = name_refusal(&with_allow("local/worker, ana/worker"));
14904        assert_eq!(field, "capabilities.spawn.allow[1]");
14905        assert_eq!(
14906            message,
14907            "'local/worker' and 'ana/worker' share the name 'worker', which is how delegate-task \
14908             offers a capsule to the model"
14909        );
14910        assert_eq!(
14911            hint.as_deref(),
14912            Some("keep one of 'local/worker' and 'ana/worker' in capabilities.spawn.allow")
14913        );
14914    }
14915
14916    #[test]
14917    fn scoped_name_a_reference_by_bare_part_names_the_declared_entry() {
14918        let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    \
14919                    version: 0.1.0\n    runtime: driver\n    gateway:\n      \
14920                    endpoint: https://api.anthropic.com\n      api_key: k\ninference:\n  \
14921                    transport: http\n  model: m\n  driver:\n    artifact: driver-anthropic\n";
14922        let message = RuntimeManifest::from_yaml_str(yaml)
14923            .unwrap_err()
14924            .to_string();
14925        assert!(
14926            message.contains(
14927                "artifact 'driver-anthropic' is not declared in artifacts: (the declared \
14928                 'murmur/driver-anthropic' has that name; refer to it as \
14929                 'murmur/driver-anthropic')"
14930            ),
14931            "{message}"
14932        );
14933    }
14934}
14935
14936#[cfg(test)]
14937mod inference_retry_tests {
14938    use super::*;
14939
14940    const HTTP: &str = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/drv\n    \
14941                        version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: \
14942                        http://127.0.0.1:8080\n      api_key: sk-test\ninference:\n  transport: \
14943                        http\n  model: m\n  driver:\n    artifact: local/drv\n";
14944    const PROCESS: &str = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/drv\n    \
14945                           version: 0.1.0\n    runtime: driver\ninference:\n  transport: \
14946                           process\n  driver:\n    artifact: local/drv\n";
14947
14948    fn retry(yaml: &str) -> InferenceRetry {
14949        RuntimeManifest::from_yaml_str(yaml)
14950            .unwrap()
14951            .inference
14952            .unwrap()
14953            .retry
14954    }
14955
14956    fn refusal(yaml: &str) -> (String, String) {
14957        match RuntimeManifest::from_yaml_str(yaml) {
14958            Err(RuntimeManifestError::InvalidInferenceConfig { field, message }) => {
14959                (field, message)
14960            }
14961            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
14962        }
14963    }
14964
14965    #[test]
14966    fn inference_retry_defaults_when_absent_and_per_key_when_partial() {
14967        assert_eq!(retry(HTTP), InferenceRetry::default());
14968        assert_eq!(
14969            InferenceRetry::default(),
14970            InferenceRetry {
14971                max_retries: 6,
14972                initial_delay_ms: 1_000,
14973                max_delay_ms: 60_000,
14974            }
14975        );
14976        assert_eq!(
14977            retry(&format!("{HTTP}  retry:\n    max_retries: 2\n")),
14978            InferenceRetry {
14979                max_retries: 2,
14980                ..InferenceRetry::default()
14981            }
14982        );
14983        assert_eq!(
14984            retry(&format!(
14985                "{HTTP}  retry:\n    initial_delay_ms: 10\n    max_delay_ms: 2000\n"
14986            )),
14987            InferenceRetry {
14988                max_retries: 6,
14989                initial_delay_ms: 10,
14990                max_delay_ms: 2000,
14991            }
14992        );
14993        assert_eq!(
14994            retry(&format!(
14995                "{HTTP}  retry:\n    max_retries: 0\n    initial_delay_ms: 60000\n    \
14996                 max_delay_ms: 3600000\n"
14997            )),
14998            InferenceRetry {
14999                max_retries: 0,
15000                initial_delay_ms: 60_000,
15001                max_delay_ms: 3_600_000,
15002            }
15003        );
15004        assert_eq!(retry(PROCESS), InferenceRetry::default());
15005    }
15006
15007    #[test]
15008    fn inference_retry_bounds_are_refused_naming_the_field() {
15009        for (block, field) in [
15010            ("max_retries: 21", "inference.retry.max_retries"),
15011            ("initial_delay_ms: 0", "inference.retry.initial_delay_ms"),
15012            (
15013                "initial_delay_ms: 60001",
15014                "inference.retry.initial_delay_ms",
15015            ),
15016            ("max_delay_ms: 0", "inference.retry.max_delay_ms"),
15017            ("max_delay_ms: 3600001", "inference.retry.max_delay_ms"),
15018            (
15019                "initial_delay_ms: 5000\n    max_delay_ms: 1000",
15020                "inference.retry.max_delay_ms",
15021            ),
15022        ] {
15023            let (got, message) = refusal(&format!("{HTTP}  retry:\n    {block}\n"));
15024            assert_eq!(got, field, "{block}: {message}");
15025        }
15026        let (_, message) = refusal(&format!(
15027            "{HTTP}  retry:\n    initial_delay_ms: 5000\n    max_delay_ms: 1000\n"
15028        ));
15029        assert!(message.contains("initial_delay_ms"), "{message}");
15030    }
15031
15032    #[test]
15033    fn inference_retry_is_refused_under_transport_process() {
15034        let (field, message) = refusal(&format!("{PROCESS}  retry:\n    max_retries: 2\n"));
15035        assert_eq!(field, "inference.retry");
15036        assert_eq!(
15037            message,
15038            "is not valid with transport: process; the harness retries its own provider calls"
15039        );
15040    }
15041
15042    #[test]
15043    fn inference_retry_unknown_key_is_reported_at_its_block() {
15044        let manifest = RuntimeManifest::from_yaml_str(&format!(
15045            "{HTTP}  retry:\n    max_retries: 2\n    jitter: full\n"
15046        ))
15047        .unwrap();
15048        let keys: Vec<(&str, &str)> = manifest
15049            .unknown_keys
15050            .iter()
15051            .map(|key| (key.key.as_str(), key.block_path.as_str()))
15052            .collect();
15053        assert_eq!(keys, vec![("jitter", "inference.retry")]);
15054    }
15055}