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    trace_capture::{resolve_trace_capture, TraceCapture},
13    unknown_manifest_keys::{nearest_known_key, UnknownManifestKey},
14};
15
16// ── Lifecycle types ───────────────────────────────────────────────────────────
17
18#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum TaskAcceptance {
21    None,
22    #[default]
23    Single,
24    Queue,
25}
26
27#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum AfterTask {
30    #[default]
31    Exit,
32    Sleep,
33}
34
35#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum ConversationMode {
38    #[default]
39    Stateless,
40    Threaded,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct LifecycleConfig {
45    pub task_acceptance: TaskAcceptance,
46    pub after_task: AfterTask,
47    pub queue_depth: usize,
48    /// If set, request-input calls that receive no follow-up within this many seconds
49    /// transition the task to "failed" with reason "input-timeout". Absent = wait indefinitely.
50    pub input_timeout_secs: Option<u64>,
51    /// Whether tasks sharing a contextId accumulate conversation history across turns.
52    /// Stateless (default): each task is fully independent; Threaded: history is loaded
53    /// and persisted per contextId within a session.
54    pub conversation_mode: ConversationMode,
55    /// Maximum times an `on-task-end` hook may reopen a single task (re-run its agent
56    /// loop with injected feedback). Defaults to 1 when absent. `0` disables reopening
57    /// entirely. Unlike `inference.max_turns`, `0` is a valid explicit value. Reopening
58    /// never grants turns past `inference.max_turns` — the two budgets share one
59    /// cumulative turn count.
60    pub max_task_reopens: u32,
61    /// How long a shell command runs in the foreground before it is demoted to the background.
62    /// Defaults to 10 seconds. A command that exits inside this window returns its output to the
63    /// turn as it always has; one that outruns it hands the turn a handle, keeps running, and
64    /// reports back later as a `completion`-origin task. `0` demotes at the first poll after the
65    /// spawn, so effectively every command detaches.
66    pub shell_grace_secs: u64,
67    /// The single bound on a delegation, covering both waits one can have: how long a sub-capsule
68    /// started by `delegate-task` is watched, and how long a plan `capsule` step waits for its
69    /// answer. Defaults to 600 seconds. Absent is a ceiling rather than an absence: a capsule that
70    /// never declares this one is still bounded, because an unbounded delegation leaves a wedged
71    /// sub-capsule running for as long as the capsule that started it. `0` gives up at the first
72    /// poll after the task is delivered, the same reading `shell_grace_secs: 0` has.
73    ///
74    /// Reaching the deadline ends the sub-capsule. A `delegate-task` caller is told in a
75    /// `completion`-origin task carrying `status: terminated` and a detail naming the bound; a plan
76    /// step's call returns `timed_out`. The clock starts when the sub-capsule reports itself ready,
77    /// not when its process is spawned, so a slow host does not spend this window on staging.
78    /// `MURMUR_DELEGATION_TIMEOUT_SECS` overrides this value when it names a positive number of
79    /// seconds.
80    pub delegation_deadline_secs: u64,
81}
82
83impl LifecycleConfig {
84    /// Whether a session under this lifecycle is still there to receive work that arrives after
85    /// the task which started it ended — a demoted shell command's completion, a delegation's
86    /// outcome.
87    ///
88    /// `queue` + `sleep` is the only combination that qualifies, and the delegation lane and the
89    /// shell lane must agree on it: a capsule warned about one and not the other would be told its
90    /// lifecycle is both adequate and inadequate for the same reason.
91    pub fn can_receive_background_tasks(&self) -> bool {
92        self.task_acceptance == TaskAcceptance::Queue && self.after_task == AfterTask::Sleep
93    }
94
95    /// Whether this lifecycle's queue has room for a waiting task: `false` exactly for
96    /// `task_acceptance: queue` at `queue_depth: 0`, which the A2A door would turn into a door
97    /// refusing every task, with nowhere for background completions to wait.
98    ///
99    /// The manifest refuses such a lifecycle at load
100    /// ([`RuntimeManifestError::QueueWithoutRoom`]) and staging refuses one a
101    /// `--lifecycle-task-acceptance queue` override produces; both ask this. `queue_depth` means
102    /// nothing under `single` or `none`, so any value passes there.
103    pub fn queue_has_room(&self) -> bool {
104        !(self.task_acceptance == TaskAcceptance::Queue && self.queue_depth == 0)
105    }
106
107    /// How many tasks a capsule under this lifecycle holds at once: one running task plus
108    /// `queue_depth` waiting ones under `queue`, a single task under `single`, none under `none`.
109    ///
110    /// A `queue` lifecycle at `queue_depth: 0` is refused at load and at staging
111    /// ([`Self::queue_has_room`]), so no running capsule has one. The A2A door is the authority
112    /// this mirrors: the task registry in `capsule-runtime` (`TaskRegistry::can_accept`) decides
113    /// what is rejected, and a test there fails if the two drift apart.
114    pub fn tasks_held_at_once(&self) -> usize {
115        match self.task_acceptance {
116            TaskAcceptance::None => 0,
117            TaskAcceptance::Single => 1,
118            TaskAcceptance::Queue => self.queue_depth + 1,
119        }
120    }
121}
122
123impl Default for LifecycleConfig {
124    fn default() -> Self {
125        Self {
126            task_acceptance: TaskAcceptance::Single,
127            after_task: AfterTask::Exit,
128            queue_depth: 1,
129            input_timeout_secs: None,
130            conversation_mode: ConversationMode::Stateless,
131            max_task_reopens: 1,
132            shell_grace_secs: 10,
133            delegation_deadline_secs: 600,
134        }
135    }
136}
137
138/// Lifecycle override: allows a parent (mur-roost) or CLI flag to constrain
139/// the lifecycle down from what the manifest declares.
140#[derive(Debug, Clone, Default)]
141pub struct LifecycleOverride {
142    pub task_acceptance: Option<TaskAcceptance>,
143    pub after_task: Option<AfterTask>,
144}
145
146/// Artifact taxonomy for entries declared in a capsule manifest.
147///
148/// Variant meanings:
149///
150/// - `Tool`: agent-callable tool (WASM component or native binary). Visible in MURMUR.md and the
151///   LLM tool list. Implementation details (wasm vs native) are declared in the artifact's own
152///   murmur.yaml via `implementation:`.
153/// - `Driver`: inference driver implemented as a WASM component. The runtime calls it as a
154///   processor. Hidden from the LLM.
155/// - `Hook`: event-triggered artifact implemented as a WASM component. The runtime calls it at
156///   lifecycle points; behavioral contract (binding, execution_mode, commit_policy) is declared
157///   in the artifact's own murmur.yaml. Hidden from the LLM.
158/// - `Skill`: documentation artifact; no executable binary. Visible in the LLM tool inventory.
159///   Invoking a skill by name returns its `skill.md` content as the tool result (just-in-time
160///   injection). No WASM/native dispatch occurs. A skill bound via
161///   `inference.system_prompt_artifact` is excluded from the callable inventory because it is
162///   already part of the system prompt.
163#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
164#[serde(rename_all = "lowercase")]
165pub enum ArtifactRuntime {
166    Tool,
167    Driver,
168    Hook,
169    Skill,
170}
171
172impl ArtifactRuntime {
173    /// Whether artifacts of this role appear in the LLM's tool inventory.
174    ///
175    /// Deliberately an exhaustive match with one arm per variant and no wildcard: a new
176    /// `ArtifactRuntime` variant must state its own visibility here, so adding a role that
177    /// should be LLM-visible cannot silently default to hidden.
178    pub fn is_llm_visible(&self) -> bool {
179        match self {
180            ArtifactRuntime::Tool => true,
181            ArtifactRuntime::Skill => true,
182            ArtifactRuntime::Driver => false,
183            ArtifactRuntime::Hook => false,
184        }
185    }
186
187    pub fn as_str(&self) -> &'static str {
188        match self {
189            ArtifactRuntime::Tool => "tool",
190            ArtifactRuntime::Driver => "driver",
191            ArtifactRuntime::Hook => "hook",
192            ArtifactRuntime::Skill => "skill",
193        }
194    }
195}
196
197/// How a `runtime: tool` artifact is implemented.
198///
199/// Declared via `implementation:` in the artifact's own murmur.yaml.
200/// Defaults to `Wasm` when the field is absent.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub enum ArtifactImplementation {
203    Wasm,
204    Native,
205}
206
207/// Read the `implementation:` field from an artifact's murmur.yaml.
208/// Defaults to `Wasm` when the field is absent or unparseable.
209pub fn read_tool_implementation(
210    artifact_manifest_path: &Path,
211) -> Result<ArtifactImplementation, RuntimeManifestError> {
212    let content =
213        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
214            path: artifact_manifest_path.display().to_string(),
215            source,
216        })?;
217    Ok(parse_tool_implementation_from_yaml(&content))
218}
219
220/// Parse `implementation:` from an in-memory manifest YAML string.
221/// Defaults to `Wasm` when the field is absent or unparseable.
222pub fn parse_tool_implementation_from_yaml(yaml: &str) -> ArtifactImplementation {
223    serde_yaml::from_str::<serde_yaml::Value>(yaml)
224        .map(|v| {
225            v.as_mapping()
226                .and_then(|m| m.get(serde_yaml::Value::String("implementation".to_string())))
227                .and_then(serde_yaml::Value::as_str)
228                .map(|s| match s {
229                    "native" => ArtifactImplementation::Native,
230                    _ => ArtifactImplementation::Wasm,
231                })
232                .unwrap_or(ArtifactImplementation::Wasm)
233        })
234        .unwrap_or(ArtifactImplementation::Wasm)
235}
236
237// ── Hook configuration types ──────────────────────────────────────────────────
238
239/// Which lifecycle event(s) a hook binds to.
240///
241/// Declared via `binding:` in the artifact's own murmur.yaml.
242/// Defaults to `All` when the field is absent (receives all session events).
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum HookBinding {
245    /// `"on-stage"` — fires during `stage_session`; always blocking.
246    OnStage,
247    /// `"on-session-start"` — fires once before the first inference turn.
248    OnSessionStart,
249    /// `"on-task-start"` — fires once per task, before that task's first inference turn.
250    OnTaskStart,
251    /// `"on-inference"` — fires after each inference response is parsed.
252    OnInference,
253    /// `"on-tool-call"` — fires after each model-requested tool invocation.
254    OnToolCall,
255    /// `"on-shell"` — fires after each shell command returns.
256    OnShell,
257    /// `"on-compaction"` — fires when the session token threshold is reached.
258    OnCompaction,
259    /// `"on-task-end"` — fires once per task, after that task's agent loop returns.
260    OnTaskEnd,
261    /// `"on-session-end"` — fires at session teardown.
262    OnSessionEnd,
263    /// No `binding:` field — receives all session events (not on-stage).
264    All,
265}
266
267impl HookBinding {
268    /// The manifest spelling of this binding, which is also the WIT lifecycle function
269    /// name the runtime dispatches to — the key `capsule-runtime`'s honored-arm table is
270    /// written against. [`HookBinding::All`] has no spelling of its own (it is what an
271    /// omitted `binding:` means), so it renders as `"all"` for diagnostics only.
272    #[must_use]
273    pub fn as_str(&self) -> &'static str {
274        match self {
275            Self::OnStage => "on-stage",
276            Self::OnSessionStart => "on-session-start",
277            Self::OnTaskStart => "on-task-start",
278            Self::OnInference => "on-inference",
279            Self::OnToolCall => "on-tool-call",
280            Self::OnShell => "on-shell",
281            Self::OnCompaction => "on-compaction",
282            Self::OnTaskEnd => "on-task-end",
283            Self::OnSessionEnd => "on-session-end",
284            Self::All => "all",
285        }
286    }
287}
288
289/// Whether the runtime waits for the hook result.
290///
291/// Declared via `execution_mode:` in the artifact's own murmur.yaml.
292/// Defaults to `Blocking` when the field is absent.
293#[derive(Debug, Clone, PartialEq, Eq)]
294pub enum HookExecutionMode {
295    /// `"blocking"` — runtime waits for result before proceeding.
296    Blocking,
297    /// `"async"` — runtime proceeds immediately; result not committed.
298    Async,
299}
300
301/// What the runtime does with a successful hook output.
302///
303/// Declared via `commit_policy:` in the artifact's own murmur.yaml.
304/// Defaults to `None` when the field is absent.
305#[derive(Debug, Clone, PartialEq, Eq)]
306pub enum HookCommitPolicy {
307    /// `"none"` — output discarded.
308    None,
309    /// `"replace-context"` — runtime replaces conversation history. Valid only with on-compaction.
310    ReplaceContext,
311    /// `"write-manifests"` — runtime writes tool manifests to the runtime directory's tools/. Valid
312    /// only with on-stage.
313    WriteManifests,
314    /// `"reopen-task"` — runtime re-runs the task's agent loop with the hook's feedback.
315    /// Valid only with on-task-end.
316    ReopenTask,
317    /// `"seed-context"` — runtime places the hook's messages at the head of the task's
318    /// first message list, under the `context.seed_budget` ceiling. Valid only with
319    /// on-task-start.
320    SeedContext,
321    /// `"deny"` — the hook decides, immediately before the call is dispatched, whether the
322    /// call happens at all. Valid only with on-shell and on-tool-call, and only with an
323    /// explicit `binding:` naming one of them.
324    ///
325    /// The only policy that *subtracts*: every other value commits something the hook
326    /// produced, this one refuses something the capsule asked for. It narrows the manifest's
327    /// grant and can never widen it.
328    Deny,
329}
330
331impl HookCommitPolicy {
332    /// The manifest spelling of this policy, for diagnostics.
333    #[must_use]
334    pub fn as_str(&self) -> &'static str {
335        match self {
336            Self::None => "none",
337            Self::ReplaceContext => "replace-context",
338            Self::WriteManifests => "write-manifests",
339            Self::ReopenTask => "reopen-task",
340            Self::SeedContext => "seed-context",
341            Self::Deny => "deny",
342        }
343    }
344}
345
346/// What the runtime does with a lifecycle event for an `execution_mode: async` hook
347/// whose job queue is already full.
348///
349/// Declared via `on_overflow:` on the hook's entry in the **capsule operator's own**
350/// manifest — not in the hook artifact's bundled murmur.yaml, because keeping up with
351/// the agent loop is the operator's trade-off, not the hook author's. Inert on a hook
352/// that turns out to be `execution_mode: blocking` (a blocking hook is never queued);
353/// the operator-manifest parser cannot tell the two apart, since the mode lives in the
354/// artifact's own manifest and is only known at staging time.
355#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
356#[serde(rename_all = "snake_case")]
357pub enum HookOverflowPolicy {
358    /// `"drop"` (the default) — discard the event and count it. The agent loop never
359    /// waits on a slow hook; telemetry is lossy under sustained overload, and the loss
360    /// is reported rather than silent.
361    #[default]
362    Drop,
363    /// `"block"` — the agent loop waits for the hook's worker to make room. No event is
364    /// lost, at the cost of putting a slow hook back on the critical path.
365    Block,
366}
367
368impl HookOverflowPolicy {
369    /// The manifest spelling of this policy, for diagnostics.
370    #[must_use]
371    pub fn as_str(&self) -> &'static str {
372        match self {
373            Self::Drop => "drop",
374            Self::Block => "block",
375        }
376    }
377}
378
379/// Behavioral contract for a hook artifact, read from its own murmur.yaml.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct HookConfig {
382    pub binding: HookBinding,
383    pub execution_mode: HookExecutionMode,
384    pub commit_policy: HookCommitPolicy,
385}
386
387impl Default for HookConfig {
388    fn default() -> Self {
389        Self {
390            binding: HookBinding::All,
391            execution_mode: HookExecutionMode::Blocking,
392            commit_policy: HookCommitPolicy::None,
393        }
394    }
395}
396
397/// The one `commit_policy` a hook bound to `binding` can actually have committed, or
398/// `None` when no `commit_policy` value other than `none` is ever honored for it.
399///
400/// This is the manifest-side mirror of `capsule-runtime`'s `HONORED_OUTPUT_ARM` table,
401/// which decides — keyed on the same lifecycle function names [`HookBinding::as_str`]
402/// produces — which `hook-output` arm the runtime commits for each event. Two bindings
403/// return `None` for different reasons, and the distinction matters:
404///
405/// - `on-session-start` and `on-session-end` honor no arm at all; every output from them
406///   is discarded.
407/// - `on-tool-call` and `on-shell` honor `deny`, and only at their decision-point dispatch
408///   — the one that runs before the call. Their post-call observation dispatch honors
409///   nothing, which is why the runtime's table is keyed on the dispatch phase as well as
410///   the event name.
411/// - `on-inference` *does* honor an arm (`artifact`), but that arm has no
412///   `commit_policy` spelling — [`HookCommitPolicy`] has no `Artifact` variant — so no
413///   non-`none` policy is declarable for it either.
414///
415/// [`HookBinding::All`] (an omitted `binding:`) also returns `None`, and that is a third
416/// reason again: an `All`-bound hook is dispatched to *every* event, so there is no single
417/// honored policy to name. It is deliberately **not** validated against this function —
418/// [`parse_hook_config_from_yaml`] accepts every `commit_policy` for it, with the single
419/// exception of [`HookCommitPolicy::Deny`], which that function rejects outright for `All`.
420#[must_use]
421pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
422    match binding {
423        HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
424        HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
425        HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
426        HookBinding::OnTaskStart => Some(HookCommitPolicy::SeedContext),
427        HookBinding::OnToolCall | HookBinding::OnShell => Some(HookCommitPolicy::Deny),
428        HookBinding::OnSessionStart
429        | HookBinding::OnInference
430        | HookBinding::OnSessionEnd
431        | HookBinding::All => None,
432    }
433}
434
435/// Read a hook's behavioral contract from its murmur.yaml at the given path.
436pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
437    let content =
438        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
439            path: artifact_manifest_path.display().to_string(),
440            source,
441        })?;
442    parse_hook_config_from_yaml(&content).map_err(|msg| {
443        RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
444    })
445}
446
447/// Parse hook behavioral contract from an in-memory manifest YAML string.
448///
449/// `commit_policy: deny` is the one policy an omitted `binding:` cannot carry. Every other
450/// policy is accepted for [`HookBinding::All`] because an `All`-bound hook reaches every
451/// event and the runtime simply discards what a given event does not honor. `deny` is not
452/// discardable in that way: it is answered at a *decision point* standing in front of a call,
453/// and an `All`-bound hook would be asked to decide on shell and tool calls it was never
454/// written to judge — with every failure to answer, including a trap or a timeout, refusing
455/// the call. The binding is therefore required to name which of the two events the hook gates.
456pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
457    let v: serde_yaml::Value =
458        serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
459
460    let binding = v
461        .as_mapping()
462        .and_then(|m| m.get(serde_yaml::Value::String("binding".to_string())))
463        .and_then(serde_yaml::Value::as_str)
464        .map(|s| match s {
465            "on-stage" => Ok(HookBinding::OnStage),
466            "on-session-start" => Ok(HookBinding::OnSessionStart),
467            "on-task-start" => Ok(HookBinding::OnTaskStart),
468            "on-inference" => Ok(HookBinding::OnInference),
469            "on-tool-call" => Ok(HookBinding::OnToolCall),
470            "on-shell" => Ok(HookBinding::OnShell),
471            "on-compaction" => Ok(HookBinding::OnCompaction),
472            "on-task-end" => Ok(HookBinding::OnTaskEnd),
473            "on-session-end" => Ok(HookBinding::OnSessionEnd),
474            other => Err(format!(
475                "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
476                 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
477            )),
478        })
479        .transpose()?
480        .unwrap_or(HookBinding::All);
481
482    let execution_mode = v
483        .as_mapping()
484        .and_then(|m| m.get(serde_yaml::Value::String("execution_mode".to_string())))
485        .and_then(serde_yaml::Value::as_str)
486        .map(|s| match s {
487            "blocking" => Ok(HookExecutionMode::Blocking),
488            "async" => Ok(HookExecutionMode::Async),
489            other => Err(format!(
490                "unknown execution_mode '{other}'; expected: blocking, async"
491            )),
492        })
493        .transpose()?
494        .unwrap_or(HookExecutionMode::Blocking);
495
496    let commit_policy = v
497        .as_mapping()
498        .and_then(|m| m.get(serde_yaml::Value::String("commit_policy".to_string())))
499        .and_then(serde_yaml::Value::as_str)
500        .map(|s| match s {
501            "none" => Ok(HookCommitPolicy::None),
502            "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
503            "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
504            "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
505            "seed-context" => Ok(HookCommitPolicy::SeedContext),
506            "deny" => Ok(HookCommitPolicy::Deny),
507            other => Err(format!(
508                "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
509                 reopen-task, seed-context, deny"
510            )),
511        })
512        .transpose()?
513        .unwrap_or(HookCommitPolicy::None);
514
515    // Validation
516    if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
517        return Err(format!(
518            "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
519             (got '{}')",
520            commit_policy.as_str()
521        ));
522    }
523    if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
524        return Err(
525            "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
526             'on-stage'"
527                .to_string(),
528        );
529    }
530    // The one policy `All` cannot carry: a hook that does not name which of the two gated
531    // events it decides on would be dispatched at decision points it was never written for,
532    // and every non-`none` answer there refuses a call.
533    if binding == HookBinding::All && commit_policy == HookCommitPolicy::Deny {
534        return Err(
535            "commit_policy 'deny' requires an explicit binding: 'on-shell' or 'on-tool-call'; \
536             a hook with no binding: is dispatched at every event, including decision points \
537             it was not written to decide"
538                .to_string(),
539        );
540    }
541    // `binding` is the single source of truth for what the runtime commits; a declared
542    // `commit_policy` the binding can never honor is a mistake that is fully knowable here,
543    // at staging time, rather than mid-session as a `hook_dispatch_error`. An omitted
544    // `binding:` (`All`) reaches every event, so every other policy stays valid for it.
545    if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
546        let honored = commit_policy_for_binding(&binding);
547        if honored.as_ref() != Some(&commit_policy) {
548            let note = if binding == HookBinding::OnInference {
549                " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
550            } else {
551                ""
552            };
553            return Err(format!(
554                "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
555                 commit_policy '{}'{note}",
556                commit_policy.as_str(),
557                binding.as_str(),
558                binding.as_str(),
559                honored.as_ref().map_or("none", HookCommitPolicy::as_str)
560            ));
561        }
562    }
563
564    Ok(HookConfig {
565        binding,
566        execution_mode,
567        commit_policy,
568    })
569}
570
571#[derive(Debug, Clone, PartialEq, Eq)]
572pub struct RuntimeManifest {
573    pub name: String,
574    pub version: String,
575    pub artifacts: Vec<RuntimeArtifact>,
576    pub capabilities: Option<Capabilities>,
577    pub inference: Option<InferenceConfig>,
578    pub context: Option<ContextConfig>,
579    pub observability: Option<ObservabilityConfig>,
580    pub trace: Option<TraceConfig>,
581    pub network: Option<NetworkConfig>,
582    pub lifecycle: Option<LifecycleConfig>,
583    /// Read-only views onto the workdir that the operator opens to processes outside the capsule.
584    /// `None` means nothing is exported and every request to the resource plane is denied.
585    pub exports: Option<Exports>,
586    /// What a controller holding this session's control token may change while it runs. `None`
587    /// — the block absent, `control: {}`, or both lists empty — means the capsule has no control
588    /// surface and mints no token.
589    pub control: Option<ControlConfig>,
590    /// The `deploy:` block. `None` when the manifest declares none.
591    pub deploy: Option<DeployConfig>,
592    /// Every key the manifest declared that this build does not recognize, captured by each
593    /// `Raw*` block's `#[serde(flatten)]` overflow map instead of dropped.
594    ///
595    /// Ordered by the walk rather than by the manifest's own line order: a containing block's own
596    /// keys precede the blocks nested inside it, and one block's keys are alphabetical.
597    ///
598    /// Empty for a manifest written entirely in keys this build knows. Never a reason to refuse a
599    /// manifest — it is reported as `W-SEC-019` by
600    /// [`crate::unknown_manifest_keys::warn_on_unknown_manifest_keys`] and nothing else reads it.
601    pub unknown_keys: Vec<UnknownManifestKey>,
602}
603
604impl RuntimeManifest {
605    pub fn effective_lifecycle(&self) -> LifecycleConfig {
606        self.lifecycle.clone().unwrap_or_default()
607    }
608
609    /// Whether this capsule's door serves tasks a peer capsule's runtime sends it: `true` only
610    /// when the manifest declares `exports.peer_tasks.accept: true`. See
611    /// [`Exports::accepts_peer_tasks`].
612    ///
613    /// The capsule's own manifest is the only input. Nothing a launcher, an operator or a
614    /// formation passes can change the answer.
615    pub fn accepts_peer_tasks(&self) -> bool {
616        self.exports
617            .as_ref()
618            .is_some_and(Exports::accepts_peer_tasks)
619    }
620
621    /// The `mur` release `mur deploy` installs on the target: `deploy.mur_version`, or `None`
622    /// when the block or the key is absent or the value is blank.
623    pub fn deploy_mur_version(&self) -> Option<&str> {
624        self.deploy.as_ref()?.mur_version.as_deref()
625    }
626}
627
628/// The top-level `deploy:` block, read by `mur deploy` only. `mur run` takes no meaning from it.
629#[derive(Debug, Clone, Default, PartialEq, Eq)]
630pub struct DeployConfig {
631    /// The `mur` release `mur deploy` downloads, caches at `~/.murmur/bin/mur-{version}-{platform}`
632    /// and installs on the target. `None` for an absent or blank value, in which case the
633    /// deploying `mur`'s own version is installed.
634    pub mur_version: Option<String>,
635}
636
637/// A setting of a running session that a controller may change over the control surface, when
638/// `control.settings` names it.
639///
640/// Each variant is one manifest key, spelled by [`Self::wire_name`] in `control.settings`, in the
641/// control surface's paths and bodies, and in the trace. What a value must be, and where the agent
642/// loop reads it, is the runtime's to decide per variant.
643#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
644pub enum ControllableSetting {
645    /// `inference.max_tokens`: the per-turn output cap the agent loop sends as `max_tokens`.
646    /// `transport: http` only, on the same terms as the manifest key.
647    InferenceMaxTokens,
648    /// `inference.driver`: which driver choice serves the agent loop's inference calls, by name —
649    /// [`PRIMARY_DRIVER_CHOICE`] or an `inference.alternates` entry's `name`. `transport: http`
650    /// only, and only on a capsule that declares at least one alternate.
651    InferenceDriver,
652}
653
654impl ControllableSetting {
655    /// Every setting a manifest may list, in the order refusals name them.
656    pub const ALL: &'static [ControllableSetting] =
657        &[Self::InferenceMaxTokens, Self::InferenceDriver];
658
659    /// Every setting `control.agent_settings` may list: the ones the agent may change about
660    /// itself through a runtime-provided tool.
661    pub const AGENT_SETTABLE: &'static [ControllableSetting] = &[Self::InferenceDriver];
662
663    /// The manifest key this setting is, as written in `control.settings`.
664    pub fn wire_name(self) -> &'static str {
665        match self {
666            Self::InferenceMaxTokens => "inference.max_tokens",
667            Self::InferenceDriver => "inference.driver",
668        }
669    }
670
671    /// The setting `name` spells, or `None` for anything outside [`Self::ALL`].
672    pub fn from_wire_name(name: &str) -> Option<Self> {
673        Self::ALL
674            .iter()
675            .copied()
676            .find(|setting| setting.wire_name() == name)
677    }
678}
679
680/// The top-level `control:` block: what a controller may change on a running session.
681///
682/// Parsed only to a non-empty declaration; `control: {}` and two empty lists parse as no block.
683/// Nothing a controller sets is persisted, so this is also everything a restart starts from.
684#[derive(Debug, Clone, PartialEq, Eq, Default)]
685pub struct ControlConfig {
686    /// Settings a controller may change, in manifest order, each listed once.
687    pub settings: Vec<ControllableSetting>,
688    /// Credential names a controller supplies at run time, in manifest order, each listed once.
689    /// Every name backs some artifact's `gateway.api_key: ${NAME}`, and none backs the configured
690    /// inference driver's. Such a name is never looked up in the config or the environment.
691    pub secrets: Vec<String>,
692    /// Settings the agent may change about itself, in manifest order, each listed once. Only
693    /// [`ControllableSetting::AGENT_SETTABLE`] members. The grant is the existence of the
694    /// runtime-provided tool that changes the setting, and never a route to the control surface.
695    pub agent_settings: Vec<ControllableSetting>,
696}
697
698impl ControlConfig {
699    /// Whether `name` is a credential a controller supplies.
700    pub fn declares_secret(&self, name: &str) -> bool {
701        self.secrets.iter().any(|secret| secret == name)
702    }
703
704    /// Whether a controller may change `setting`.
705    pub fn controller_may_set(&self, setting: ControllableSetting) -> bool {
706        self.settings.contains(&setting)
707    }
708
709    /// Whether the agent may change `setting`.
710    pub fn agent_may_set(&self, setting: ControllableSetting) -> bool {
711        self.agent_settings.contains(&setting)
712    }
713}
714
715/// The top-level `network:` block, which configures the A2A door. Unrelated to
716/// `capabilities.network`, which is the guest's egress.
717#[derive(Debug, Clone, PartialEq, Eq)]
718pub struct NetworkConfig {
719    /// Internal port the capsule expects to listen on. Default 14159 when absent.
720    pub internal_port: Option<u16>,
721    /// `network.authentication`: how a caller of the door proves it may call. `None` is a public
722    /// door, which answers every caller that reaches the port.
723    pub authentication: Option<NetworkAuthentication>,
724}
725
726/// Every scope a door token can carry: the door's JSON-RPC methods that a token gates, by wire
727/// name, and `resources/files`, the operator resource plane. The runtime's method table is held
728/// to this list by a test, in both directions.
729pub const DOOR_SCOPES: &[&str] = &[
730    "SendMessage",
731    "SendStreamingMessage",
732    "murmur.WatchSession",
733    "GetTask",
734    "ListTasks",
735    "CancelTask",
736    "SubscribeToTask",
737    "murmur.StopSession",
738    "resources/files",
739];
740
741/// The retired door method names a credential's `scopes` may not list, each with the method that
742/// replaces it: the A2A 0.3 names of the A2A methods, and the 0.3-style names murmur's own methods
743/// had. A manifest naming one is refused with [`RuntimeManifestError::RetiredDoorScope`]: the door
744/// serves no retired name, and no token carries one.
745///
746/// A replacement outside [`DOOR_SCOPES`] is a method every authenticated caller may call, which a
747/// credential lists nothing for. A test holds every replacement to the runtime's method table and
748/// every retired name outside it.
749pub const RETIRED_DOOR_SCOPES: &[(&str, &str)] = &[
750    ("message/send", "SendMessage"),
751    ("message/stream", "SendStreamingMessage"),
752    ("tasks/get", "GetTask"),
753    ("tasks/cancel", "CancelTask"),
754    ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
755    ("stream/watch", "murmur.WatchSession"),
756    ("session/stop", "murmur.StopSession"),
757];
758
759/// The credential the runtime always mints for an authenticated door, holding every scope in
760/// [`DOOR_SCOPES`]. Reserved: no declared credential may take the name.
761pub const OPERATOR_CREDENTIAL: &str = "operator";
762
763/// Longest credential name `network.authentication.credentials` accepts.
764const MAX_CREDENTIAL_NAME_LEN: usize = 32;
765
766/// How a caller of the door authenticates. A required field with one value, so that an
767/// authenticated door is an explicit statement and a second scheme is an addition, not a default
768/// change.
769#[derive(Debug, Clone, Copy, PartialEq, Eq)]
770pub enum AuthenticationScheme {
771    /// `Authorization: Bearer <token>`, with a token the capsule's runtime mints at launch.
772    Bearer,
773}
774
775impl AuthenticationScheme {
776    /// Every scheme a manifest may name, in the order refusals list them.
777    pub const ALL: &'static [AuthenticationScheme] = &[Self::Bearer];
778
779    /// The scheme as written in `network.authentication.scheme`.
780    pub fn wire_name(self) -> &'static str {
781        match self {
782            Self::Bearer => "bearer",
783        }
784    }
785}
786
787/// One declared credential: a token the runtime mints beside the operator token, reaching only
788/// `scopes`.
789#[derive(Debug, Clone, PartialEq, Eq)]
790pub struct DoorCredential {
791    /// Matches `^[a-z][a-z0-9_-]{0,31}$` and is never [`OPERATOR_CREDENTIAL`].
792    pub name: String,
793    /// Non-empty, each entry in [`DOOR_SCOPES`] and listed once, in declared order.
794    pub scopes: Vec<String>,
795}
796
797/// `network.authentication`: the door refuses every caller that presents no token this session
798/// minted.
799#[derive(Debug, Clone, PartialEq, Eq)]
800pub struct NetworkAuthentication {
801    pub scheme: AuthenticationScheme,
802    /// The declared credentials, sorted by name. Empty when only the operator token is minted.
803    pub credentials: Vec<DoorCredential>,
804}
805
806// ── Exports ───────────────────────────────────────────────────────────────────
807
808/// Per-file read ceiling applied when `exports.files.max_bytes` is absent: 10Mi.
809pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
810
811/// What the operator discloses to processes *outside* the capsule, declared under the top-level
812/// `exports:` key.
813///
814/// A sibling of `capabilities:` rather than a member of it: a capability is something the guest
815/// holds and an export is a disclosure the operator makes. Declaring one
816/// gives the agent nothing it did not already have — it widens only the operator's reach inward,
817/// which is why it never enters the achieved-containment computation.
818#[derive(Debug, Clone, PartialEq, Eq)]
819pub struct Exports {
820    /// The declared read-only file surface. `None` — an `exports:` block with no `files:` —
821    /// means the resource plane is not declared and every request to it is denied.
822    pub files: Option<FileExport>,
823    /// The declared peer-handoff surface. A separate authoriser from [`Self::files`], over a
824    /// separate subtree: declaring one grants nothing about the other, and a capsule may declare
825    /// either, both or neither. `None` means the capsule mints no handles and its peer plane
826    /// answers `no_peer_plane`.
827    pub peer_files: Option<PeerFilesExport>,
828    /// The declared willingness to take tasks from other capsules. `None` — no `peer_tasks:` —
829    /// refuses them exactly as `accept: false` does; read it through
830    /// [`Self::accepts_peer_tasks`] rather than matching on it.
831    pub peer_tasks: Option<PeerTasksExport>,
832}
833
834impl Exports {
835    /// Whether this capsule consents to tasks from peer capsules: `true` only for an explicit
836    /// `exports.peer_tasks.accept: true`. Absent and `false` are the same answer.
837    ///
838    /// The one reading rule. [`RuntimeManifest::accepts_peer_tasks`] delegates here, so a caller
839    /// holding only the lowered `exports:` block decides exactly as one holding the manifest.
840    pub fn accepts_peer_tasks(&self) -> bool {
841        self.peer_tasks.is_some_and(|peer_tasks| peer_tasks.accept)
842    }
843}
844
845/// The `exports.peer_tasks` block: whether the capsule's A2A door serves a task another capsule's
846/// runtime sends it.
847///
848/// An export rather than a capability: tasks already arrive through the door, so consenting gives
849/// the agent nothing it did not have and opens the door to one more class of caller. It names no
850/// peer, and has no field to name one — it is a posture of this capsule, not a grant to anyone.
851/// It is independent of `network.authentication`: consenting neither requires nor implies a
852/// token.
853///
854/// A block rather than a bare boolean so that it can gain fields without a breaking change.
855#[derive(Debug, Clone, Copy, PartialEq, Eq)]
856pub struct PeerTasksExport {
857    /// Required, never inferred. `false` is the same posture as declaring no block at all.
858    pub accept: bool,
859}
860
861/// The `exports.files` block: one subtree of the workdir, readable and nothing else.
862#[derive(Debug, Clone, PartialEq, Eq)]
863pub struct FileExport {
864    /// Subtree of the capsule's accessible workdir this export opens, verbatim as declared
865    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
866    /// directory belongs to the runtime, which is the only component that may decide a path is
867    /// inside the root.
868    ///
869    /// Not required to exist when the capsule launches — the agent may create it during a task.
870    pub root: String,
871    /// Required, with exactly one legal value. A required field with one value is what makes the
872    /// read-only posture an explicit operator statement rather than an unstated default, and
873    /// leaves a future write mode somewhere to be declared rather than somewhere to leak in.
874    pub mode: ExportMode,
875    /// Per-file read ceiling in bytes, defaulting to [`DEFAULT_EXPORT_MAX_BYTES`]. Never an
876    /// aggregate budget: each file is judged on its own size, and a subtree whose total exceeds
877    /// this is still listed in full.
878    pub max_bytes: u64,
879}
880
881/// Handle lifetime applied when `exports.peer_files.max_ttl` is absent and the capsule is
882/// ephemeral (`lifecycle.after_task: exit`): 1h.
883pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
884
885/// The largest `exports.peer_files.max_ttl` a *persistent* capsule
886/// (`lifecycle.after_task: sleep`) may declare: 15m.
887///
888/// An ephemeral capsule needs no ceiling because teardown destroys the minting key and with it
889/// every outstanding handle. A persistent one has withdrawn that bound, so the declared lifetime
890/// becomes the only one — and a handle is not a durability mechanism. Enforced by the runtime,
891/// not here, because the rule reads `lifecycle.after_task` and this parser sees one block at a
892/// time.
893pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
894
895/// Per-file read ceiling applied when `exports.peer_files.max_bytes` is absent: 10Mi.
896pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
897
898/// The `exports.peer_files` block: the one subtree a `share-file` handle may name.
899///
900/// Deliberately not a mode of [`FileExport`]. The operator plane addresses files by path and
901/// enumerates them; this plane has no `list` verb and no path addressing at all, and its audience
902/// is another capsule rather than the operator. Two authorisers over two subtrees, so opening one
903/// never widens the other.
904#[derive(Debug, Clone, PartialEq, Eq)]
905pub struct PeerFilesExport {
906    /// Subtree of the capsule's accessible workdir a handle may name, verbatim as declared
907    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
908    /// directory belongs to the runtime. Not required to exist when the capsule launches.
909    pub root: String,
910    /// The declared `max_ttl`, in seconds, or `None` when the manifest declared none.
911    ///
912    /// Kept undefaulted because the two ephemerality cases answer an absent value differently:
913    /// an ephemeral capsule falls back to [`DEFAULT_PEER_HANDLE_TTL_SECS`], and a persistent one
914    /// refuses to launch. Substituting the default here would erase the difference before
915    /// anything could act on it. See [`Self::effective_max_ttl_secs`].
916    pub max_ttl_secs: Option<u64>,
917    /// Per-file ceiling on a redeemed read, defaulting to [`DEFAULT_PEER_FILES_MAX_BYTES`].
918    /// Per file, never an aggregate budget — the same terms as [`FileExport::max_bytes`], and a
919    /// separate value from it.
920    pub max_bytes: u64,
921}
922
923impl PeerFilesExport {
924    /// The handle lifetime ceiling this export actually applies: the declared `max_ttl`, or the
925    /// ephemeral default when none was declared.
926    ///
927    /// Correct for a persistent capsule too, because one that declared no `max_ttl` never
928    /// launches — the runtime refuses it before a plane is built.
929    pub fn effective_max_ttl_secs(&self) -> u64 {
930        self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
931    }
932}
933
934/// The `capabilities.peer_fetch` block: which peers this capsule may redeem a handle against.
935///
936/// A sibling of [`NetworkCapabilities`] rather than a member of it. Fetching a peer's bytes lands
937/// a file in this capsule's own workdir, so it is an ingestion path and a prompt-injection
938/// surface, and it gets its own operator control. Declaring a destination here does not widen
939/// `capabilities.network.allow`, and a destination allowed there is not redeemable unless it is
940/// also named here.
941#[derive(Debug, Clone, PartialEq, Eq)]
942pub struct PeerFetchCapabilities {
943    /// Network destinations a handle may be redeemed against. Required and non-empty when the
944    /// block is present: an empty list is a parse error, never a silent deny. Same syntax as
945    /// `capabilities.network.allow`, and matched by the same runtime rule matcher.
946    pub allow: Vec<String>,
947}
948
949/// The accepted spelling of every duration in the manifest, stated once so the parser and the
950/// error it produces cannot drift apart.
951pub const DURATION_ACCEPTED_FORM: &str =
952    "must be a duration: an integer, optionally suffixed s/m/h/d";
953
954/// Parses `90`, `30s`, `15m`, `1h`, `14d` into a whole number of seconds.
955///
956/// A bare integer is seconds. Suffixes are lowercase and single-character; `5 minutes` and `30S`
957/// are rejected rather than guessed at, on the same terms as [`parse_byte_size`]. Returns the
958/// accepted-form sentence as its error so every call site reports the same rule.
959pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
960    let trimmed = input.trim();
961    let (digits, multiplier) = match trimmed.strip_suffix('s') {
962        Some(digits) => (digits, 1u64),
963        None => match trimmed.strip_suffix('m') {
964            Some(digits) => (digits, 60),
965            None => match trimmed.strip_suffix('h') {
966                Some(digits) => (digits, 3600),
967                None => match trimmed.strip_suffix('d') {
968                    Some(digits) => (digits, 86_400),
969                    None => (trimmed, 1),
970                },
971            },
972        },
973    };
974    // Not trimmed again: the whole input was trimmed above, so a space surviving here is *inside*
975    // the value (`5 minutes`, `30 m`), which is not a spelling this accepts.
976    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
977        return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
978    }
979    digits
980        .parse::<u64>()
981        .ok()
982        .and_then(|value| value.checked_mul(multiplier))
983        .ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
984}
985
986#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
987pub enum ExportMode {
988    #[serde(rename = "read-only")]
989    ReadOnly,
990}
991
992impl ExportMode {
993    pub fn as_str(self) -> &'static str {
994        match self {
995            ExportMode::ReadOnly => "read-only",
996        }
997    }
998}
999
1000impl std::fmt::Display for ExportMode {
1001    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1002        f.write_str(self.as_str())
1003    }
1004}
1005
1006/// The accepted spelling of every byte count in the manifest, stated once so the parser and the
1007/// error it produces cannot drift apart.
1008pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
1009
1010/// Parses `4096`, `1Ki`, `10Mi`, `2Gi` into a byte count.
1011///
1012/// Binary suffixes only, and case-sensitively: `10MB` is rejected rather than guessed at, because
1013/// a manifest that means 10 000 000 and a manifest that means 10 485 760 must not both parse.
1014/// Returns the accepted-form sentence as its error so every call site reports the same rule.
1015pub fn parse_byte_size(input: &str) -> Result<u64, String> {
1016    let trimmed = input.trim();
1017    let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
1018        Some(digits) => (digits, 1024u64),
1019        None => match trimmed.strip_suffix("Mi") {
1020            Some(digits) => (digits, 1024 * 1024),
1021            None => match trimmed.strip_suffix("Gi") {
1022                Some(digits) => (digits, 1024 * 1024 * 1024),
1023                None => (trimmed, 1),
1024            },
1025        },
1026    };
1027    // Deliberately not trimmed again: the whole input was trimmed above, so any space left here
1028    // is *inside* the value (`10 Mi`), which is not a spelling this accepts.
1029    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
1030        return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
1031    }
1032    digits
1033        .parse::<u64>()
1034        .ok()
1035        .and_then(|value| value.checked_mul(multiplier))
1036        .ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
1037}
1038
1039#[derive(Debug, Clone, PartialEq, Eq)]
1040pub struct RuntimeArtifact {
1041    pub name: String,
1042    pub version: String,
1043    pub runtime: ArtifactRuntime,
1044    /// Optional local source path. When set, the runtime resolves the artifact directly from
1045    /// this path instead of from the registry — no `.mur.zip`, no publish. For a skill the path
1046    /// points at a `skill.md` file or at a directory containing one (case-insensitive).
1047    /// Relative paths resolve against the directory containing `murmur.yaml`.
1048    /// Only permitted when `local_source` is true.
1049    pub source: Option<String>,
1050    /// Whether this artifact may be resolved from a local `source:` path. Declared via
1051    /// `local_source:` in the project manifest's artifact entry; when absent it defaults to
1052    /// true for `runtime: skill` and false for every other role, which is exactly the role-based
1053    /// gate this field replaced.
1054    pub local_source: bool,
1055    /// Whether this artifact's payload may be bound as the system prompt via
1056    /// `inference.system_prompt_artifact`. Declared via `prompt_payload:` in the project
1057    /// manifest's artifact entry; when absent it defaults to true for `runtime: skill` and
1058    /// false for every other role.
1059    pub prompt_payload: bool,
1060    /// Per-artifact capability grant, declared via `capabilities:` on this entry in the
1061    /// **capsule operator's own** manifest. Recognized on `runtime: hook`, `runtime: tool`,
1062    /// and `runtime: driver` entries; a `runtime: skill` entry carrying the key is rejected
1063    /// at parse time, because nothing would enforce it and a silently-ignored grant reads
1064    /// like a scoped artifact.
1065    ///
1066    /// What `None` (the key absent) means depends on the role, deliberately:
1067    /// - `hook`: full default-deny — no network and no preopened directory.
1068    /// - `tool`/`driver`: the unchanged capsule-wide ceiling. A declared block *narrows*
1069    ///   from that ceiling and can never widen past it.
1070    ///
1071    /// Deliberately never sourced from the artifact's own bundled `murmur.yaml` (see
1072    /// [`parse_hook_config_from_yaml`], which parses that file and knows nothing about
1073    /// capabilities) — an artifact pulled from a registry cannot self-grant.
1074    ///
1075    /// Reuses the whole [`Capabilities`] type for vocabulary consistency with the
1076    /// capsule-wide block, but only `network` and `filesystem` are consumed by the runtime
1077    /// per-artifact; the other sub-blocks are inert here.
1078    pub capabilities: Option<Capabilities>,
1079    /// What the runtime does when this hook's async job queue is full, declared via
1080    /// `on_overflow:` on this entry. Defaults to [`HookOverflowPolicy::Drop`] when absent.
1081    /// Accepted only on `runtime: hook` entries — every other role is rejected at parse
1082    /// time, since nothing would ever consult it there.
1083    pub on_overflow: HookOverflowPolicy,
1084    /// Operator-authored configuration for this artifact alone, declared via `config:` on this
1085    /// entry. Held as the raw YAML node: this crate accepts any mapping, and the runtime is what
1086    /// lowers it to the JSON delivered as `MURMUR_ARTIFACT_CONFIG` and refuses a shape that
1087    /// cannot travel that way.
1088    ///
1089    /// `None` is the key being absent, which means the variable is absent from the guest
1090    /// environment. `Some(Value::Null)` is `config:` written with nothing under it — a
1091    /// declaration that carries nothing, refused by the runtime rather than treated as absent.
1092    ///
1093    /// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
1094    /// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
1095    /// [`Self::capabilities`]. Operator-sourced only, and never read from the artifact's own
1096    /// bundled `murmur.yaml` — an artifact pulled from a registry cannot configure itself.
1097    ///
1098    /// Plaintext in a file that is also an audit record: secrets belong in `${VAR}` references
1099    /// and the credential-stripping path, not here.
1100    pub config: Option<serde_yaml::Value>,
1101    /// The third-party upstream this artifact reaches through the credential gateway, and the
1102    /// key the runtime presents there on its behalf, declared via `gateway:` on this entry.
1103    ///
1104    /// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
1105    /// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
1106    /// [`Self::capabilities`]. A `runtime: driver` entry may carry it only when it is the
1107    /// configured `transport: http` driver, whose gateway is the metered inference gateway.
1108    ///
1109    /// Operator-sourced only, and never read from the artifact's own bundled `murmur.yaml`: the
1110    /// artifact says how the key is presented (`upstream_auth:`), never where it goes.
1111    pub gateway: Option<ArtifactGateway>,
1112}
1113
1114impl RuntimeArtifact {
1115    /// The name a staged session knows this artifact by: the bare part of its full name, or a
1116    /// `source:` entry's own name. It is the model-facing tool name and the directory under
1117    /// `<runtime dir>/tools/`.
1118    #[must_use]
1119    pub fn tool_name(&self) -> &str {
1120        tool_name_of(&self.name)
1121    }
1122
1123    /// The full name this entry resolves by. A `source:` entry, named by its bare name, has none.
1124    pub fn artifact_name(&self) -> Result<ArtifactName, ArtifactNameError> {
1125        ArtifactName::parse(&self.name)
1126    }
1127}
1128
1129/// The bare part of an artifact reference as a manifest writes it: the text after the `/` of a
1130/// full name, or the whole of a bare one.
1131#[must_use]
1132pub fn tool_name_of(name: &str) -> &str {
1133    name.split_once('/').map_or(name, |(_, bare)| bare)
1134}
1135
1136/// One artifact's credential gateway, as the operator declared it on the artifact's entry.
1137///
1138/// Debug never prints a literal key: [`ApiKeyReference`]'s own `Debug` redacts it.
1139#[derive(Debug, Clone, PartialEq, Eq)]
1140pub struct ArtifactGateway {
1141    /// Absolute `http`/`https` upstream URL with a host and no query or fragment. Plain `http`
1142    /// only to `localhost` or a loopback IP literal.
1143    pub endpoint: String,
1144    /// The key presented upstream. `None` for a keyless gateway, or when the manifest was parsed
1145    /// with [`RuntimeManifest::from_yaml_str_without_secrets`].
1146    pub api_key: Option<ApiKeyReference>,
1147    /// `keyless: true` as the operator wrote it: this upstream takes no key, and the gateway
1148    /// forwards with no credential header. Exclusive with `api_key`; kept as written in both
1149    /// secret modes. Never derived from the endpoint's address.
1150    pub keyless: bool,
1151}
1152
1153#[derive(Debug, Clone, PartialEq, Eq)]
1154pub struct InferenceDriver {
1155    pub artifact: String,
1156    pub config: Option<String>,
1157}
1158
1159/// The name of the driver choice `inference.driver.artifact` with `inference.model` forms. Reserved:
1160/// no `inference.alternates` entry may take it.
1161pub const PRIMARY_DRIVER_CHOICE: &str = "primary";
1162
1163/// One `inference.alternates` entry: a named pair of a declared driver and a model that the
1164/// agent loop may be switched to while the capsule runs.
1165///
1166/// Takes no `driver.config` of its own: `inference.driver.config` reaches every declared driver,
1167/// and per-driver settings are that driver's `artifacts[].config`.
1168#[derive(Debug, Clone, PartialEq, Eq)]
1169pub struct InferenceAlternate {
1170    /// `[a-z][a-z0-9-]{0,62}`, unique among the alternates, and never [`PRIMARY_DRIVER_CHOICE`].
1171    pub name: String,
1172    /// The model string the driver sends, never empty.
1173    pub model: String,
1174    /// `driver.artifact`: an `artifacts:` entry with `runtime: driver`, which carries its own
1175    /// `gateway:` unless it is the primary's own driver.
1176    pub driver: String,
1177}
1178
1179/// What a `gateway.api_key` names, as the manifest wrote it. Parsing never resolves it: the
1180/// runtime looks the value up at staging, where it knows where credentials are kept.
1181#[derive(Clone, PartialEq, Eq)]
1182pub enum ApiKeyReference {
1183    /// `${NAME}`: a credential name, looked up in the global config's `credentials:` map and
1184    /// then in the launching environment.
1185    Environment(String),
1186    /// Any other non-empty value, used verbatim as the key.
1187    Literal(String),
1188}
1189
1190impl std::fmt::Debug for ApiKeyReference {
1191    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1192        match self {
1193            Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
1194            Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
1195        }
1196    }
1197}
1198
1199#[derive(Debug, Clone, PartialEq, Eq)]
1200pub struct InferenceConfig {
1201    pub transport: String,
1202    pub model: String,
1203    /// WASM driver artifact. Always present: the inference driver under `transport: http`, the
1204    /// process driver under `transport: process`.
1205    pub driver: Option<InferenceDriver>,
1206    /// The executable to run instead of the one the process driver's `describe()` names. Absent
1207    /// for `transport: http`, and optional under `transport: process`, where the driver names its
1208    /// own binary.
1209    pub command: Option<String>,
1210    pub compaction: Option<CompactionConfig>,
1211    pub system_prompt: Option<String>,
1212    pub system_prompt_file: Option<String>,
1213    /// Name of a skill artifact declared in `artifacts:` whose `skill.md` is used as the system
1214    /// prompt. Mutually exclusive with `system_prompt` and `system_prompt_file`. The skill is
1215    /// excluded from the callable tool inventory (it's already in the system prompt).
1216    pub system_prompt_artifact: Option<String>,
1217    /// Maximum LLM turns per capsule task. Defaults to 10 when absent in the manifest.
1218    /// Enforced as a hard ceiling by the runtime.
1219    pub max_turns: u32,
1220    /// Maximum output tokens the model may generate per turn (`max_tokens` in the driver wire
1221    /// payload). None means the manifest didn't set it; the runtime applies its own default.
1222    /// `transport: http` only — rejected at parse time under `transport: process`.
1223    ///
1224    /// Unrelated to [`ContextConfig::max_tokens`], which is the session-wide token budget that
1225    /// drives compaction. This one is a per-turn *output* cap.
1226    pub max_tokens: Option<u32>,
1227    /// `inference.max_session_tokens`: the most input plus output tokens this session may spend.
1228    /// `None` sets no session ceiling.
1229    ///
1230    /// Valid on both transports, measured differently on each: under `transport: http` it counts
1231    /// the runtime's own measurement of every driver call, and under `transport: process` the
1232    /// counts the harness's driver reports for each turn. Setting it against a process driver
1233    /// that reports no usage is refused at load.
1234    pub max_session_tokens: Option<u64>,
1235    /// `inference.tool_refresh`: when an artifact installed mid-session reaches the tool array
1236    /// the agent loop sends the model. Always [`ToolRefresh::Compaction`] under
1237    /// `transport: process`, where the key is refused at parse time and nothing reads it.
1238    pub tool_refresh: ToolRefresh,
1239    /// `inference.alternates`: the other driver choices the agent loop may be switched to, in
1240    /// manifest order. Empty under `transport: process`, where the key is refused at parse time,
1241    /// and for every capsule that declares none.
1242    pub alternates: Vec<InferenceAlternate>,
1243}
1244
1245/// `inference.tool_refresh`: the turn boundary at which the http agent loop rebuilds the tool
1246/// array it sends the model, once the session's installed set has changed.
1247///
1248/// The array comes first in every provider's cached prompt prefix, so rebuilding it is a full
1249/// prompt-cache miss on the call that carries it. The start of the next task rebuilds it under
1250/// either value.
1251#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1252pub enum ToolRefresh {
1253    /// Rebuild before the first inference call after a compaction commits a replacement
1254    /// context, whose message history is already a full cache miss.
1255    #[default]
1256    Compaction,
1257    /// Rebuild before the next inference call after the install.
1258    Immediate,
1259}
1260
1261impl ToolRefresh {
1262    /// Every value, in the order refusals name them.
1263    pub const ALL: [ToolRefresh; 2] = [Self::Compaction, Self::Immediate];
1264
1265    /// The value as written in the manifest and in `trace.jsonl`.
1266    #[must_use]
1267    pub fn wire_name(self) -> &'static str {
1268        match self {
1269            Self::Compaction => "compaction",
1270            Self::Immediate => "immediate",
1271        }
1272    }
1273}
1274
1275#[derive(Debug, Clone, PartialEq)]
1276pub struct CompactionConfig {
1277    /// Fraction of context_window at which compaction fires (0.0–1.0]. Default: 0.98.
1278    pub threshold: Option<f32>,
1279    /// Model for compaction. None means use the primary inference model.
1280    pub model: Option<String>,
1281    /// System prompt for compaction. None means the compaction hook picks its own default.
1282    /// Mutually exclusive with `system_prompt_file`.
1283    pub system_prompt: Option<String>,
1284    /// Path to a local file whose contents are used as the compaction system prompt,
1285    /// resolved relative to the manifest directory when the session launches. Mutually
1286    /// exclusive with `system_prompt`.
1287    pub system_prompt_file: Option<String>,
1288    /// When true, every committed compaction appends one JSON line recording the
1289    /// replacement summary to `out/compaction-summaries.jsonl` in the session workdir.
1290    /// None (the key absent) is equivalent to false: nothing is written.
1291    pub dump_summaries: Option<bool>,
1292}
1293
1294// Threshold values are validated to (0.0, 1.0] at parse time so NaN is impossible.
1295impl Eq for CompactionConfig {}
1296
1297/// `context.seed_budget` when the manifest leaves it out: a tenth of the window.
1298/// Small enough that a seed cannot crowd out the task it precedes, large enough that a
1299/// memory hook has room to say something.
1300pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
1301
1302/// `context.seed_overflow_margin` when the manifest leaves it out. Slack above
1303/// `seed_budget` within which an over-budget seed is trimmed rather than summarized —
1304/// a few tokens over must not buy an inference call.
1305pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
1306
1307#[derive(Debug, Clone, PartialEq)]
1308pub struct ContextConfig {
1309    /// Token budget for this session; None disables compaction.
1310    pub max_tokens: Option<u32>,
1311    /// Whether the runtime keeps a durable conversation record for this capsule. `true` unless
1312    /// the manifest says `record: off`; `false` turns the mechanism off entirely, creating
1313    /// nothing under `~/.murmur/conversations/`.
1314    pub record: bool,
1315    /// Directory segment under `~/.murmur/conversations/` this capsule's records live in.
1316    /// `None` means the capsule name. Validated as one path segment by the runtime, on the same
1317    /// terms as `capabilities.state.store`. Inert when [`Self::record`] is `false`.
1318    pub record_store: Option<String>,
1319    /// Fraction of [`Self::max_tokens`] an `on-task-start` hook's `seed-context` may
1320    /// occupy, in `0.0..=1.0`. Always populated; [`DEFAULT_SEED_BUDGET`] when the key is
1321    /// absent. Inert without `max_tokens`: a fraction of no ceiling is no ceiling, and a
1322    /// seed with no ceiling is rejected rather than committed unbounded.
1323    pub seed_budget: f32,
1324    /// Slack above the seed budget, as a fraction of it, within which an over-budget seed
1325    /// is trimmed from its front rather than handed to the compaction hook, in
1326    /// `0.0..=1.0`. Always populated; [`DEFAULT_SEED_OVERFLOW_MARGIN`] when the key is
1327    /// absent.
1328    pub seed_overflow_margin: f32,
1329    /// What bounds this capsule's conversation records. `None` — the `retain:` block was absent
1330    /// — keeps every record whole and forever. Inert when [`Self::record`] is `false`.
1331    pub retain: Option<ContextRetainConfig>,
1332}
1333
1334// Both fractions are validated to 0.0..=1.0 at parse time, so NaN is impossible.
1335impl Eq for ContextConfig {}
1336
1337#[derive(Debug, Clone, PartialEq, Eq)]
1338pub struct ObservabilityConfig {
1339    pub otel_endpoint: Option<String>,
1340    pub eval: Option<EvalConfig>,
1341}
1342
1343#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1344pub struct TraceConfig {
1345    /// How much of each turn's driver request this session's trace keeps — resolved from
1346    /// `trace.capture` by [`crate::trace_capture::resolve_trace_capture`].
1347    pub capture: TraceCapture,
1348    /// What bounds the session directories under the workdir. `None` — the `retain:` block was
1349    /// absent — keeps every session forever; there is no default policy, because a mechanism
1350    /// that deletes an operator's traces unless they opt out is the one default-allow in a
1351    /// runtime that is default-deny everywhere else.
1352    pub retain: Option<TraceRetainConfig>,
1353}
1354
1355/// The `trace.retain` block: what bounds the set of session directories beside the running one.
1356///
1357/// Both keys are optional and ANDed — a session survives only if it is inside every limit
1358/// declared. An empty block is a parse error, not a no-op: omitting `retain:` is how a capsule
1359/// says "keep everything".
1360#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1361pub struct TraceRetainConfig {
1362    /// Newest session directories to keep, counting the running session itself. `None` means
1363    /// the count is unbounded. Never `Some(0)`: truncating the set to nothing would delete the
1364    /// running session's own trace, so it is refused at parse time.
1365    pub max_sessions: Option<u32>,
1366    /// Age beyond which a session directory is removed, in seconds, measured from the
1367    /// millisecond timestamp inside its own uuid-v7 `ses_` id. `None` means no age limit.
1368    pub max_age_secs: Option<u64>,
1369}
1370
1371/// The `context.retain` block: what bounds this capsule's conversation records.
1372///
1373/// Both keys are optional and ANDed. `max_messages` truncates the front of the record the
1374/// launch opens; `max_age_secs` removes a context directory this capsule owns and has not
1375/// written to inside the window.
1376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1377pub struct ContextRetainConfig {
1378    /// Messages to keep at the tail of a record. `None` means the record grows without bound.
1379    /// Never `Some(0)`: truncating a record to nothing is `mur conversation rm`, not retention.
1380    pub max_messages: Option<u32>,
1381    /// Age beyond which a record this capsule owns is removed, in seconds, measured from the
1382    /// last write to its `conversation.jsonl`. `None` means no age limit.
1383    pub max_age_secs: Option<u64>,
1384}
1385
1386#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1387pub struct EvalConfig {
1388    #[serde(skip_serializing_if = "Option::is_none")]
1389    pub dataset_id: Option<String>,
1390    pub scorers: Vec<ScorerConfig>,
1391}
1392
1393#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1394#[serde(tag = "type", rename_all = "snake_case")]
1395pub enum ScorerConfig {
1396    ExitOk { name: String },
1397    MaxTurns { name: String, max: u32 },
1398    MaxTokens { name: String, max: u64 },
1399    ToolSequence { name: String, expected: Vec<String> },
1400    LlmJudge { name: String },
1401}
1402
1403#[derive(Debug, Clone, PartialEq, Eq)]
1404pub struct NetworkCapabilities {
1405    pub allow: Vec<String>,
1406    /// Whether the capsule's shell subprocess tree may create `AF_UNIX` sockets. `false` unless
1407    /// the manifest says otherwise: a local daemon socket — `/var/run/docker.sock` above all —
1408    /// is an unmediated path to host root, and the `allow` list above only governs IP
1409    /// destinations, so nothing else in this block constrains it. Declaring `true` is the
1410    /// deliberate, auditable widening for a capsule that genuinely needs a local daemon socket;
1411    /// it is capsule-wide and coarse (a domain, not a per-path allowlist). `AF_NETLINK` and
1412    /// `AF_PACKET` have no corresponding key and are always denied.
1413    pub unix_sockets: bool,
1414}
1415
1416#[derive(Debug, Clone, PartialEq, Eq)]
1417pub struct FilesystemCapabilities {
1418    pub scope: Option<String>,
1419    /// Whether anything written *into* the session workdir may be executed. `false` unless the
1420    /// manifest says otherwise, and that default is the whole point: with it, the workdir's own
1421    /// Landlock `PathBeneath` rule withholds the `Execute` right, so the kernel — evaluating the
1422    /// resolved path itself, with no userspace round trip — refuses to exec a binary the capsule
1423    /// produced, whatever it is named. That is what makes `capabilities.shell.allow` a complete
1424    /// and sound statement rather than a name-matching convention.
1425    ///
1426    /// `true` takes the `Execute` right back for compile-and-run workflows (a capsule that
1427    /// `gcc`/`cargo build`s inside its workdir and then runs the result). The cost is not
1428    /// negotiable and is not hidden: anything the capsule writes into the workdir can then run
1429    /// regardless of `shell.allow`, so the allowlist stops being an enforceable property of that
1430    /// capsule. A capsule declaring it can therefore never achieve
1431    /// [`ContainmentClass::Scoped`] — see `capsule_runtime::containment` — and pairing it with
1432    /// `capabilities.containment: scoped` is refused at launch.
1433    pub workdir_exec: bool,
1434    /// Workdir-relative subtrees the capsule may read but must not write, in the same vocabulary
1435    /// [`Self::scope`] uses. Empty when the key is absent, which is the whole workdir writable —
1436    /// the behaviour of every capsule that declares nothing here.
1437    ///
1438    /// Entries are trimmed and empty ones dropped at parse; path *shape* is judged by the
1439    /// runtime (`capsule_runtime::protected_paths`), exactly as `scope`'s shape is.
1440    pub read_only: Vec<String>,
1441    /// Whether a `runtime: hook` entry's `"."` is the session's records directory's `hooks/`,
1442    /// read-write, in place of a workdir [`Self::scope`], so what the hook writes is outside the
1443    /// workdir the capsule's commands write. `false` when the key is absent. Refused at parse on any other role, in the capsule-wide
1444    /// block, and beside a `scope` on the same entry.
1445    pub records: bool,
1446}
1447
1448/// The `capabilities.task_io` block on a `runtime: hook` artifact entry — the operator's
1449/// grant of the `murmur:task-io/read` host import to that one hook. Recognized nowhere else:
1450/// on any other role, and in the capsule-wide block, it is rejected at parse time rather than
1451/// accepted and silently inert.
1452#[derive(Debug, Clone, PartialEq, Eq)]
1453pub struct TaskIoCapabilities {
1454    /// Whether this hook may read the in-scope task's input and result text. Never inferred:
1455    /// a `task_io:` block that omits `read:` is rejected, on the same terms as
1456    /// `capabilities.shell.interpreter_runtime[].dirs[].list_dir`.
1457    pub read: bool,
1458}
1459
1460/// The `capabilities.conversation` block on a `runtime: hook` artifact entry — the operator's
1461/// grant of the `murmur:conversation/read` host import to that one hook. Rejected at parse time
1462/// on any other artifact role; accepted but inert in the capsule-wide block, which warns
1463/// `W-SEC-016` at staging.
1464#[derive(Debug, Clone, PartialEq, Eq)]
1465pub struct ConversationCapabilities {
1466    /// Whether this hook may read the capsule's durable conversation record. Never inferred: a
1467    /// `conversation:` block that omits `read:` is rejected, exactly as `task_io:` is.
1468    pub read: bool,
1469}
1470
1471/// The `capabilities.plan` block — the operator's grant of the runtime-provided `submit-plan`
1472/// tool to the capsule's agent loop. Read from the capsule-wide block only: an artifact entry
1473/// declaring it grants nothing, because a plan is submitted by the agent and not by an artifact.
1474#[derive(Debug, Clone, PartialEq, Eq)]
1475pub struct PlanCapabilities {
1476    /// Whether this capsule's model may submit a plan for the runtime to schedule. Never
1477    /// inferred: a `plan:` block that omits `submit:` is rejected, exactly as `task_io:` is.
1478    pub submit: bool,
1479}
1480
1481/// The `capabilities.install` block — the operator's grant to pull artifacts into a running
1482/// session through `murmur:artifact-manager/manage.pull`. Read from the capsule-wide block only.
1483/// Each entry is an exact artifact name, a `<prefix>*` or `*`, as
1484/// [`crate::install_pattern::validate`] accepts it, kept verbatim in declaration order. At least
1485/// one list is non-empty: omitting the block, not declaring an empty one, is how a capsule grants
1486/// nothing.
1487#[derive(Debug, Clone, PartialEq, Eq)]
1488pub struct InstallCapabilities {
1489    /// Entries admitting skill artifacts (`skill.md`, no executable payload).
1490    pub skill: Vec<String>,
1491    /// Entries admitting tool artifacts, WASM or native alike.
1492    pub tool: Vec<String>,
1493    /// `capabilities.install.from`: where a pull may find what it installs.
1494    pub from: InstallFrom,
1495}
1496
1497/// `capabilities.install.from`: the places a pull is answered from. Absent is
1498/// [`InstallFrom::Stores`].
1499#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1500pub enum InstallFrom {
1501    /// `[stores]`: the project store, then the global store. A pull fetches nothing.
1502    #[default]
1503    Stores,
1504    /// `[stores, sources]`: the stores first, then the GitHub source of the artifact's scope,
1505    /// fetched by the runtime itself and written to no store.
1506    StoresAndSources,
1507}
1508
1509impl InstallFrom {
1510    /// Whether a pull may fetch from a scope's source.
1511    #[must_use]
1512    pub fn sources(self) -> bool {
1513        self == Self::StoresAndSources
1514    }
1515
1516    /// The list as a manifest writes it, without brackets: `stores` or `stores, sources`.
1517    #[must_use]
1518    pub fn as_list(self) -> &'static str {
1519        match self {
1520            Self::Stores => "stores",
1521            Self::StoresAndSources => "stores, sources",
1522        }
1523    }
1524}
1525
1526#[derive(Debug, Clone, PartialEq, Eq)]
1527pub struct ShellCapabilities {
1528    pub allow: Vec<String>,
1529    pub strip_env: Option<Vec<String>>,
1530    pub baseline_env: Option<Vec<String>>,
1531    /// Typed grants that widen an already-allowlisted `allow` binary's Landlock filesystem
1532    /// scope to the exact host directories its import machinery needs *outside* the workdir
1533    /// (a path-based interpreter like CPython, whose stdlib the `DT_NEEDED` closure cannot
1534    /// reach). Empty unless the manifest declares `capabilities.shell.interpreter_runtime`.
1535    /// This can only ever name specific directories with an explicit per-directory
1536    /// enumerability flag — it has no field that expands a whole install prefix.
1537    pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1538    /// Typed staged-runtime grants that name a pinned host runtime tree to bind-mount read-only
1539    /// into a `sealed` capsule's composed root. Empty unless the manifest declares
1540    /// `capabilities.shell.staged_runtime`. Mutually exclusive with [`Self::interpreter_runtime`]
1541    /// per binary: staging the tree *into* the root is what makes widening Landlock *out to* the
1542    /// host unnecessary, so declaring both for one binary is a contradiction, not a layering.
1543    pub staged_runtime: Vec<StagedRuntimeGrant>,
1544}
1545
1546/// One `capabilities.shell.interpreter_runtime` entry: an already-allowlisted binary plus the
1547/// exact host directories outside the workdir its import machinery must reach. Declaring one
1548/// couples the capsule to a specific host interpreter-version layout, so it fires `W-SEC-009`
1549/// at staging; it exists only to bridge until the staged runtime bind-mount ships.
1550#[derive(Debug, Clone, PartialEq, Eq)]
1551pub struct InterpreterRuntimeGrant {
1552    /// A binary that MUST already appear in this same block's `allow`. This mechanism can only
1553    /// narrow filesystem access alongside an exec grant that already exists — it never itself
1554    /// grants exec.
1555    pub binary: String,
1556    /// The host directories to grant. Never empty (a grant with no directories is rejected at
1557    /// parse time).
1558    pub dirs: Vec<InterpreterRuntimeDir>,
1559}
1560
1561/// One directory inside an [`InterpreterRuntimeGrant`], with its author-declared enumerability.
1562#[derive(Debug, Clone, PartialEq, Eq)]
1563pub struct InterpreterRuntimeDir {
1564    /// An absolute host path (must start with `/`) outside the workdir.
1565    pub path: String,
1566    /// `true` grants `Execute + ReadFile + ReadDir` — the directory's own entries can be
1567    /// enumerated (what CPython's `FileFinder` needs for a `sys.path` entry). `false` grants
1568    /// `Execute + ReadFile` only — files inside can still be opened by exact name, but the
1569    /// directory itself cannot be listed. Never inferred: the author must write it explicitly.
1570    pub list_dir: bool,
1571}
1572
1573/// One `capabilities.shell.staged_runtime` entry: an already-allowlisted binary, the absolute host
1574/// path of a pinned runtime tree that already exists on the launch host, and the `pin` string
1575/// identifying which build that tree is.
1576///
1577/// The mechanism this describes is the inverse of [`InterpreterRuntimeGrant`]. That one widens a
1578/// capsule's Landlock scope *outwards* so an interpreter can reach its stdlib where the host
1579/// happens to keep it — which couples the capsule to one host's directory layout and is why it
1580/// fires `W-SEC-009`. This one moves the tree *inwards*: the composed root of a `sealed` capsule
1581/// carries the runtime at the same absolute path, bind-mounted read-only, so nothing outside the
1582/// root has to stay reachable. Declaring it therefore requires an effective `sealed` floor — there
1583/// is no composed root to stage into below that — and it is refused alongside an
1584/// `interpreter_runtime` grant for the same binary.
1585///
1586/// `source_path` is deliberately a path to an *already-pinned tree* (a vendored toolchain
1587/// directory, a baked-in conda env), never a bare install prefix guessed at random: the runtime
1588/// does not resolve, discover or version-sniff it.
1589#[derive(Debug, Clone, PartialEq, Eq)]
1590pub struct StagedRuntimeGrant {
1591    /// A binary that MUST already appear in this same block's `allow`. Like
1592    /// [`InterpreterRuntimeGrant::binary`], this mechanism never itself grants exec — it only says
1593    /// where the runtime behind an existing exec grant comes from.
1594    pub binary: String,
1595    /// Absolute host path of the runtime tree to stage. Bind-mounted read-only at this same
1596    /// absolute path inside the composed root, so `sys.prefix`, shebangs and anything a previous
1597    /// turn recorded keep resolving.
1598    pub source_path: String,
1599    /// Non-empty, explicit identifier of *which build* the tree at `source_path` is. Never
1600    /// inferred from the tree's contents: it exists so a human can compare the declared pin across
1601    /// two hosts and confirm the same interpreter build shipped to both. The runtime treats it as
1602    /// an opaque string and does not parse it.
1603    pub pin: String,
1604}
1605
1606#[derive(Debug, Clone, PartialEq, Eq)]
1607pub struct SpawnCapabilities {
1608    pub allow: Vec<String>,
1609}
1610
1611/// Host environment variables a WASM guest may observe, from `capabilities.env`.
1612///
1613/// An empty `allow` is legitimate (a declared-but-empty block is a no-op), so unlike
1614/// `capabilities.shell.allow` this is not rejected at parse time.
1615#[derive(Debug, Clone, PartialEq, Eq, Default)]
1616pub struct EnvCapabilities {
1617    pub allow: Vec<String>,
1618}
1619
1620/// Per-guest execution limits from `capabilities.limits`.
1621///
1622/// Every field is `Option` because this type carries only what the manifest actually
1623/// declared — an omitted field stays `None` here and the runtime substitutes its own
1624/// default (see `capsule_runtime::limits::ExecutionLimits`). Keeping the "absent" state
1625/// distinct from "explicitly set" is what lets the runtime own the defaults in one place
1626/// instead of duplicating them into the manifest parser.
1627#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1628pub struct ResourceLimits {
1629    /// Cap on a guest's linear-memory growth, in bytes.
1630    pub memory_bytes: Option<usize>,
1631    /// Cap on a guest's table growth, in elements.
1632    pub table_elements: Option<usize>,
1633    /// Cap on the number of instances a single store may create.
1634    pub instances: Option<usize>,
1635    /// Wall-clock budget for a single guest invocation, in seconds.
1636    pub deadline_seconds: Option<u64>,
1637}
1638
1639/// Host-process (OS-level) resource bounds from `capabilities.resources`.
1640///
1641/// Distinct from [`ResourceLimits`] (`capabilities.limits`) in both mechanism and subject:
1642/// that block bounds a WASM *guest* inside its wasmtime store, this one bounds every *native
1643/// subprocess* the runtime spawns — `rlimit(2)` ceilings applied before `execve`, a Linux
1644/// cgroup v2 scope around the whole process tree, and a periodic workdir-size check. A capsule
1645/// that cannot escape containment can still wedge its host by forking, allocating, or writing
1646/// without bound; this is the block that stops that.
1647///
1648/// Every field is `Option` for the same reason [`ResourceLimits`]'s are: this type carries only
1649/// what the manifest actually declared, and the runtime substitutes its own default for
1650/// anything omitted (see `capsule_runtime::resources::HostResourceLimits`). An omitted field —
1651/// or an omitted block — means defaults, never "unlimited".
1652#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1653pub struct ResourceCapabilities {
1654    /// `RLIMIT_NPROC` headroom for a spawned subprocess that has no cgroup scope: the uid's task
1655    /// count at that spawn plus this value, counted in threads on Linux and processes on macOS.
1656    /// No effect on Linux when the subprocess tree has a cgroup scope, which `cgroup_pids_max`
1657    /// bounds instead.
1658    pub max_processes: Option<u64>,
1659    /// `RLIMIT_NOFILE` hard ceiling on each spawned subprocess.
1660    pub max_open_files: Option<u64>,
1661    /// `RLIMIT_FSIZE` hard ceiling, in bytes, on each spawned subprocess.
1662    pub max_file_size_bytes: Option<u64>,
1663    /// `RLIMIT_CPU` hard ceiling, in CPU-seconds, on each spawned subprocess.
1664    pub cpu_seconds: Option<u64>,
1665    /// `RLIMIT_AS` (Linux) / `RLIMIT_DATA` (macOS) hard ceiling, in bytes, on each spawned
1666    /// subprocess.
1667    pub memory_bytes: Option<u64>,
1668    /// cgroup v2 `memory.max`, in bytes — aggregate across the whole subprocess tree. Linux only.
1669    pub cgroup_memory_bytes: Option<u64>,
1670    /// cgroup v2 `pids.max` — aggregate across the whole subprocess tree. Linux only.
1671    pub cgroup_pids_max: Option<u64>,
1672    /// cgroup v2 `cpu.max` quota as a percentage of one core (200 = two cores). Linux only.
1673    pub cgroup_cpu_percent: Option<u32>,
1674    /// cgroup v2 `io.max` read+write bytes/sec on the workdir's backing device. Linux only,
1675    /// best-effort (the backing device cannot always be resolved).
1676    pub cgroup_io_bytes_per_sec: Option<u64>,
1677    /// Ceiling on total workdir size, in bytes, enforced by a periodic check on every platform.
1678    pub workdir_max_bytes: Option<u64>,
1679}
1680
1681/// Durable, capsule-scoped state for one tool, driver or hook: a host directory outside every
1682/// session workdir, preopened into the guest as `state/`.
1683///
1684/// Absent is deny — no declaration means no second preopen and no directory anywhere. The store
1685/// is keyed by capsule rather than by workdir precisely so it survives a launch that gets a fresh
1686/// session workdir, and so two capsules launched in the same directory never share one.
1687#[derive(Debug, Clone, PartialEq, Eq, Default)]
1688pub struct StateCapabilities {
1689    /// Directory name under `~/.murmur/state/`. `None` means "use the capsule name", which is
1690    /// what the overwhelming majority of declarations want.
1691    ///
1692    /// Read from the capsule operator's own manifest entry and never from the artifact's bundled
1693    /// manifest, so a registry-pulled tool cannot claim a store that already exists. The *shape*
1694    /// of the name is not checked here — a store name is a runtime concern, refused as
1695    /// `E-CAP-009`, exactly as `capabilities.filesystem.scope`'s shape is refused as `E-CAP-002`.
1696    pub store: Option<String>,
1697}
1698
1699// ── Containment class ─────────────────────────────────────────────────────────
1700
1701/// How strongly the host must contain a capsule's *subprocess* tree, declared as a floor
1702/// by the operator and satisfied (or not) by the host's kernel.
1703///
1704/// Declaration order is enforcement order — the `Ord` derive is load-bearing, so variants
1705/// must stay listed weakest-first. Combining floors from several sources is `max`, never
1706/// `min` (see [`effective_containment_floor`]): no source can lower what another raised.
1707///
1708/// The class an operator *declares* is a requirement. The class a host actually *achieves*
1709/// is derived only from a live kernel probe (`capsule_runtime::containment`), never from
1710/// this declaration.
1711#[derive(
1712    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1713)]
1714#[serde(rename_all = "snake_case")]
1715pub enum ContainmentClass {
1716    /// No kernel mechanism required. Capability declarations are honored by the runtime's
1717    /// own dispatch layer and by convention; anything the host cannot mediate is warned
1718    /// about, not refused. Every host satisfies this, including macOS. The default.
1719    #[default]
1720    Advisory,
1721    /// Landlock filesystem mediation + seccomp exec/network allowlisting over the *host*
1722    /// filesystem: every path outside the workdir must be an explicit manifest grant.
1723    /// Requires Linux 5.13+ with a usable Landlock ABI.
1724    Scoped,
1725    /// A private filesystem root: mount-namespace + `pivot_root` isolation onto a composed
1726    /// root, so the host filesystem is not merely mediated but absent. Achievable on an
1727    /// uncontainerised Linux host with a usable Landlock ABI, unprivileged user namespaces,
1728    /// and — where AppArmor's `restrict_unprivileged_userns` is active — the shipped
1729    /// `mur-sealed` profile loaded. A host that cannot back it refuses the launch with a
1730    /// mechanism-specific reason rather than silently degrading to `Scoped` or `Advisory`.
1731    /// See `docs/content/reference/sealed-containment-manual-verification.md`.
1732    Sealed,
1733}
1734
1735impl ContainmentClass {
1736    /// The lowercase wire name, identical in the manifest, the workspace config, the
1737    /// `--containment` flag, `trace.jsonl`, and `--explain-scope` output.
1738    pub fn as_str(self) -> &'static str {
1739        match self {
1740            Self::Advisory => "advisory",
1741            Self::Scoped => "scoped",
1742            Self::Sealed => "sealed",
1743        }
1744    }
1745
1746    /// Every value, weakest first — the single source for the "must be one of: …" text.
1747    pub const ALL: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1748}
1749
1750impl std::fmt::Display for ContainmentClass {
1751    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1752        f.write_str(self.as_str())
1753    }
1754}
1755
1756/// A string that is not one of the three containment class names.
1757#[derive(Debug, Clone, PartialEq, Eq)]
1758pub struct ParseContainmentClassError {
1759    pub value: String,
1760}
1761
1762impl std::fmt::Display for ParseContainmentClassError {
1763    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1764        write!(
1765            f,
1766            "must be one of: advisory, scoped, sealed; got '{}'",
1767            self.value
1768        )
1769    }
1770}
1771
1772impl std::error::Error for ParseContainmentClassError {}
1773
1774impl std::str::FromStr for ContainmentClass {
1775    type Err = ParseContainmentClassError;
1776
1777    fn from_str(value: &str) -> Result<Self, Self::Err> {
1778        match value.trim() {
1779            "advisory" => Ok(Self::Advisory),
1780            "scoped" => Ok(Self::Scoped),
1781            "sealed" => Ok(Self::Sealed),
1782            other => Err(ParseContainmentClassError {
1783                value: other.to_string(),
1784            }),
1785        }
1786    }
1787}
1788
1789/// The one floor a single `mur run` invocation must clear: the *strongest* class any source
1790/// asked for, defaulting to [`ContainmentClass::Advisory`] when no source declares one.
1791///
1792/// Monotonic by construction — an absent source does not participate, and no present source
1793/// can lower what another raised. Argument order carries no precedence.
1794pub fn effective_containment_floor(
1795    workspace_default: Option<ContainmentClass>,
1796    manifest_declared: Option<ContainmentClass>,
1797    cli_flag: Option<ContainmentClass>,
1798) -> ContainmentClass {
1799    [workspace_default, manifest_declared, cli_flag]
1800        .into_iter()
1801        .flatten()
1802        .max()
1803        .unwrap_or_default()
1804}
1805
1806#[derive(Debug, Clone, PartialEq, Eq)]
1807pub struct Capabilities {
1808    pub network: Option<NetworkCapabilities>,
1809    /// Which peers this capsule may redeem a peer-file handle against. `None` means no
1810    /// `fetch-peer-file` tool exists and nothing can be fetched — absent is deny.
1811    pub peer_fetch: Option<PeerFetchCapabilities>,
1812    pub filesystem: Option<FilesystemCapabilities>,
1813    pub shell: Option<ShellCapabilities>,
1814    pub spawn: Option<SpawnCapabilities>,
1815    pub env: Option<EnvCapabilities>,
1816    pub limits: Option<ResourceLimits>,
1817    pub resources: Option<ResourceCapabilities>,
1818    /// Durable state store for this artifact. `None` — the overwhelmingly common case — is deny:
1819    /// no `state/` preopen, and no directory created anywhere. See [`StateCapabilities`].
1820    pub state: Option<StateCapabilities>,
1821    /// Per-hook grant of the `murmur:task-io/read` host import. Only ever `Some` on a
1822    /// `runtime: hook` artifact entry — see [`TaskIoCapabilities`].
1823    pub task_io: Option<TaskIoCapabilities>,
1824    /// Per-hook grant of the `murmur:conversation/read` host import — see
1825    /// [`ConversationCapabilities`].
1826    pub conversation: Option<ConversationCapabilities>,
1827    /// Capsule-wide grant of the runtime-provided `submit-plan` tool. `None` is deny: the tool's
1828    /// synthetic manifest is not written, so it is absent from the model's inventory entirely.
1829    pub plan: Option<PlanCapabilities>,
1830    /// Capsule-wide grant to pull skills and tools at runtime. `None` is deny: every
1831    /// `manage.pull` is refused. See [`InstallCapabilities`].
1832    pub install: Option<InstallCapabilities>,
1833    /// Minimum containment class this capsule declares. `None` (the overwhelmingly common
1834    /// case) means the capsule states no requirement and inherits whatever the workspace
1835    /// config or `--containment` asks for, defaulting to `advisory`.
1836    pub containment: Option<ContainmentClass>,
1837}
1838
1839#[derive(Debug, Error)]
1840pub enum RuntimeManifestError {
1841    #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
1842    NotFound(String),
1843    #[error("{0}")]
1844    YamlSyntax(String),
1845    #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
1846    MissingField { field: String },
1847    #[error(
1848        "{}: invalid artifact declaration at index {index}: {message}",
1849        MANIFEST_FILENAME
1850    )]
1851    InvalidArtifact { index: usize, message: String },
1852    #[error(
1853        "{}: invalid inference config for '{field}': {message}",
1854        MANIFEST_FILENAME
1855    )]
1856    InvalidInferenceConfig { field: String, message: String },
1857    #[error(
1858        "{}: invalid capability config for '{field}': {message}",
1859        MANIFEST_FILENAME
1860    )]
1861    InvalidCapabilities { field: String, message: String },
1862    /// A `capabilities.shell.allow` entry that is not one bare binary name. `reason` completes
1863    /// "'<entry>' …".
1864    #[error(
1865        "{}: invalid capability config for 'capabilities.shell.allow[{index}]': '{}' {reason}; \
1866         an entry is a bare binary name",
1867        MANIFEST_FILENAME,
1868        entry.escape_debug()
1869    )]
1870    ShellAllowEntry {
1871        index: usize,
1872        entry: String,
1873        reason: &'static str,
1874    },
1875    #[error(
1876        "{}: invalid exports config for '{field}': {message}",
1877        MANIFEST_FILENAME
1878    )]
1879    InvalidExports { field: String, message: String },
1880    #[error(
1881        "{}: invalid control config for '{field}': {message}",
1882        MANIFEST_FILENAME
1883    )]
1884    InvalidControl { field: String, message: String },
1885    #[error(
1886        "{}: invalid network config for '{field}': {message}",
1887        MANIFEST_FILENAME
1888    )]
1889    InvalidNetworkConfig { field: String, message: String },
1890    /// A credential's `scopes` lists a retired door method name, one of [`RETIRED_DOOR_SCOPES`].
1891    /// `replacement` is the method name that replaces it.
1892    #[error(
1893        "{}: invalid network config for '{field}': '{scope}' is a retired name of a door \
1894         method, and the door answers only the names its card lists",
1895        MANIFEST_FILENAME
1896    )]
1897    RetiredDoorScope {
1898        field: String,
1899        scope: String,
1900        replacement: &'static str,
1901    },
1902    /// `lifecycle.task_acceptance: queue` with `lifecycle.queue_depth: 0`; see
1903    /// [`LifecycleConfig::queue_has_room`].
1904    #[error(
1905        "{}: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves task_acceptance: \
1906         queue no room for a waiting task",
1907        MANIFEST_FILENAME
1908    )]
1909    QueueWithoutRoom,
1910    /// A top-level `mur_version` key, whatever its value. The message names the replacement so a
1911    /// surface that prints only the message still points at it.
1912    #[error(
1913        "{}: 'mur_version' is not a top-level key — the mur release a deploy installs is pinned \
1914         as 'deploy.mur_version'",
1915        MANIFEST_FILENAME
1916    )]
1917    TopLevelMurVersion,
1918    #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
1919    InvalidTraceConfig { field: String, message: String },
1920    /// A field naming an artifact to resolve does not hold a full `<scope>/<name>`, or a
1921    /// `source:` entry's name is not a bare name. `hint` names the form to write instead.
1922    #[error("{}: invalid artifact name in '{field}': {message}", MANIFEST_FILENAME)]
1923    InvalidArtifactName {
1924        field: String,
1925        message: String,
1926        hint: Option<String>,
1927    },
1928    /// The manifest declares a top-level [`REGISTRY_BLOCK_KEY`].
1929    #[error("{}", REGISTRY_BLOCK_MESSAGE)]
1930    RegistryBlock,
1931    /// A `gateway:` names an upstream but neither binds a non-blank `api_key` nor declares
1932    /// `keyless: true`. Decided on what the manifest wrote, so it fires identically with and
1933    /// without secret resolution.
1934    #[error(
1935        "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
1936         gateway.endpoint '{endpoint}' but binds no credential",
1937        MANIFEST_FILENAME
1938    )]
1939    GatewayWithoutCredential {
1940        index: usize,
1941        name: String,
1942        endpoint: String,
1943    },
1944    #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
1945    Io {
1946        path: String,
1947        #[source]
1948        source: std::io::Error,
1949    },
1950}
1951
1952#[derive(Debug, Deserialize)]
1953struct RawRuntimeManifest {
1954    name: Option<String>,
1955    version: Option<String>,
1956    #[serde(default)]
1957    artifacts: Vec<RawArtifact>,
1958    #[serde(default)]
1959    capabilities: Option<RawCapabilities>,
1960    #[serde(default)]
1961    inference: Option<RawInferenceConfig>,
1962    #[serde(default)]
1963    context: Option<RawContextConfig>,
1964    #[serde(default)]
1965    observability: Option<RawObservabilityConfig>,
1966    #[serde(default)]
1967    trace: Option<RawTraceConfig>,
1968    #[serde(default)]
1969    network: Option<RawNetworkConfig>,
1970    #[serde(default)]
1971    lifecycle: Option<RawLifecycleConfig>,
1972    #[serde(default)]
1973    exports: Option<RawExports>,
1974    #[serde(default)]
1975    control: Option<RawControl>,
1976    #[serde(default)]
1977    deploy: Option<RawDeploy>,
1978    /// Captured only to refuse it, as [`RuntimeManifestError::TopLevelMurVersion`]: the pin is
1979    /// `deploy.mur_version`. Present-detection rather than a typed value, so a number, a blank
1980    /// string and a bare `mur_version:` are all refused.
1981    #[serde(default, deserialize_with = "deserialize_present")]
1982    mur_version: Option<serde_yaml::Value>,
1983    /// Captured only to refuse it. `config:` is delivered to one artifact through that artifact's
1984    /// own grant, so a capsule-wide block reaches nothing; without this field it would be one of
1985    /// the unrecognized top-level keys this manifest silently ignores.
1986    #[serde(default, deserialize_with = "deserialize_present")]
1987    config: Option<serde_yaml::Value>,
1988    /// Read by `mur build` and `mur publish` off this same file to select the packaging type.
1989    /// Declared here so a manifest that is both runnable and publishable does not report its own
1990    /// build-side keys as unrecognized; the run path takes no meaning from it and never inspects
1991    /// the value, so any shape parses.
1992    #[serde(default)]
1993    #[allow(dead_code)]
1994    runtime: Option<serde_yaml::Value>,
1995    /// Read by `mur build` and `mur publish`: it separates a native tool from a wasm one, both in
1996    /// the derived packaging type and in `mur publish`'s platform tagging. Ignored on the run
1997    /// path, and untyped for the same reason as `runtime:` above.
1998    #[serde(default)]
1999    #[allow(dead_code)]
2000    implementation: Option<serde_yaml::Value>,
2001    /// Read by `mur build` and `mur publish` as the declared packaging type, which overrides the
2002    /// one derived from `runtime:` and `implementation:`. Ignored on the run path, and untyped for
2003    /// the same reason as `runtime:` above.
2004    #[serde(default)]
2005    #[allow(dead_code)]
2006    execution: Option<serde_yaml::Value>,
2007    /// Read by `mur build` as the list of companion files to pack beside the manifest. Ignored on
2008    /// the run path, and untyped for the same reason as `runtime:` above.
2009    #[serde(default)]
2010    #[allow(dead_code)]
2011    requires_files: Option<serde_yaml::Value>,
2012    #[serde(flatten)]
2013    unknown: UnknownKeys,
2014}
2015
2016#[derive(Debug, Deserialize)]
2017struct RawDeploy {
2018    #[serde(default)]
2019    mur_version: Option<String>,
2020    #[serde(flatten)]
2021    unknown: UnknownKeys,
2022}
2023
2024#[derive(Debug, Deserialize)]
2025struct RawControl {
2026    /// Untyped, so a value that is not a list of strings is refused as an
2027    /// [`RuntimeManifestError::InvalidControl`] naming the field rather than as a serde type error
2028    /// naming a line number.
2029    #[serde(default)]
2030    settings: Option<serde_yaml::Value>,
2031    #[serde(default)]
2032    secrets: Option<serde_yaml::Value>,
2033    #[serde(default)]
2034    agent_settings: Option<serde_yaml::Value>,
2035    #[serde(flatten)]
2036    unknown: UnknownKeys,
2037}
2038
2039#[derive(Debug, Deserialize)]
2040struct RawExports {
2041    #[serde(default)]
2042    files: Option<RawFileExport>,
2043    #[serde(default)]
2044    peer_files: Option<RawPeerFilesExport>,
2045    #[serde(default)]
2046    peer_tasks: Option<RawPeerTasksExport>,
2047    #[serde(flatten)]
2048    unknown: UnknownKeys,
2049}
2050
2051#[derive(Debug, Deserialize)]
2052struct RawPeerTasksExport {
2053    // `Option` so an omitted `accept` is distinguishable from an explicit `false`: the omission
2054    // is refused rather than defaulted, because consent is never inferred.
2055    #[serde(default)]
2056    accept: Option<bool>,
2057    #[serde(flatten)]
2058    unknown: UnknownKeys,
2059}
2060
2061#[derive(Debug, Deserialize)]
2062struct RawPeerFilesExport {
2063    #[serde(default)]
2064    root: Option<String>,
2065    /// Untyped for the same reason as [`RawFileExport::max_bytes`]: `30m` is a YAML string and a
2066    /// bare `1800` is a YAML integer, and a wrong-shaped value has to reach the parser to be
2067    /// reported as an [`RuntimeManifestError::InvalidExports`] naming the field.
2068    #[serde(default)]
2069    max_ttl: Option<serde_yaml::Value>,
2070    #[serde(default)]
2071    max_bytes: Option<serde_yaml::Value>,
2072    #[serde(flatten)]
2073    unknown: UnknownKeys,
2074}
2075
2076#[derive(Debug, Deserialize)]
2077struct RawFileExport {
2078    #[serde(default)]
2079    root: Option<String>,
2080    #[serde(default)]
2081    mode: Option<String>,
2082    /// Deliberately untyped: `max_bytes` accepts both a bare integer and a suffixed string, and
2083    /// a wrong-shaped value has to reach [`parse_exports`] to be reported as an
2084    /// [`RuntimeManifestError::InvalidExports`] naming the field rather than as a serde type
2085    /// error naming a line number.
2086    #[serde(default)]
2087    max_bytes: Option<serde_yaml::Value>,
2088    #[serde(flatten)]
2089    unknown: UnknownKeys,
2090}
2091
2092#[derive(Debug, Deserialize)]
2093struct RawLifecycleConfig {
2094    #[serde(default)]
2095    task_acceptance: Option<TaskAcceptance>,
2096    #[serde(default)]
2097    after_task: Option<AfterTask>,
2098    #[serde(default)]
2099    queue_depth: Option<usize>,
2100    #[serde(default)]
2101    input_timeout_secs: Option<u64>,
2102    #[serde(default)]
2103    conversation: Option<ConversationMode>,
2104    #[serde(default)]
2105    max_task_reopens: Option<u32>,
2106    #[serde(default)]
2107    shell_grace_secs: Option<u64>,
2108    #[serde(default)]
2109    delegation_deadline_secs: Option<u64>,
2110    #[serde(flatten)]
2111    unknown: UnknownKeys,
2112}
2113
2114#[derive(Debug, Deserialize)]
2115struct RawNetworkConfig {
2116    internal_port: Option<u16>,
2117    #[serde(default)]
2118    authentication: Option<RawNetworkAuthentication>,
2119    #[serde(flatten)]
2120    unknown: UnknownKeys,
2121}
2122
2123#[derive(Debug, Deserialize)]
2124struct RawNetworkAuthentication {
2125    #[serde(default)]
2126    scheme: Option<String>,
2127    /// Keyed by credential name. A `BTreeMap`, so the lowered credentials come out sorted by name
2128    /// whatever order the manifest wrote them in.
2129    #[serde(default)]
2130    credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
2131    #[serde(flatten)]
2132    unknown: UnknownKeys,
2133}
2134
2135#[derive(Debug, Deserialize)]
2136struct RawDoorCredential {
2137    /// Untyped, so a value that is not a list of strings is refused as an
2138    /// [`RuntimeManifestError::InvalidNetworkConfig`] naming the field rather than as a serde type
2139    /// error naming a line number.
2140    #[serde(default)]
2141    scopes: Option<serde_yaml::Value>,
2142    #[serde(flatten)]
2143    unknown: UnknownKeys,
2144}
2145
2146#[derive(Debug, Deserialize)]
2147struct RawContextConfig {
2148    max_tokens: Option<u32>,
2149    /// `on` | `off`. Kept as a raw `String` so an unrecognized value reports as an invalid
2150    /// value for this key rather than as a serde variant error against the whole block.
2151    #[serde(default)]
2152    record: Option<String>,
2153    #[serde(default)]
2154    record_store: Option<String>,
2155    #[serde(default)]
2156    seed_budget: Option<f32>,
2157    #[serde(default)]
2158    seed_overflow_margin: Option<f32>,
2159    /// Untyped so an empty block, a null block and an unknown key inside it are each reported
2160    /// as an invalid `context.retain` rather than as a serde message about a struct field.
2161    #[serde(default, deserialize_with = "present_yaml_value")]
2162    retain: Option<serde_yaml::Value>,
2163    #[serde(flatten)]
2164    unknown: UnknownKeys,
2165}
2166
2167#[derive(Debug, Deserialize)]
2168struct RawObservabilityConfig {
2169    #[serde(default)]
2170    otel_endpoint: Option<String>,
2171    #[serde(default)]
2172    eval: Option<RawEvalConfig>,
2173    #[serde(flatten)]
2174    unknown: UnknownKeys,
2175}
2176
2177#[derive(Debug, Deserialize)]
2178struct RawTraceConfig {
2179    /// Untyped so an unparseable mode reaches [`resolve_trace_capture`] and is reported as a
2180    /// [`RuntimeManifestError::InvalidTraceConfig`] naming the field and the accepted values,
2181    /// rather than as a serde message about an unknown variant.
2182    #[serde(default)]
2183    capture: Option<String>,
2184    /// Untyped for the same reason as `context.retain` — see [`RawContextConfig::retain`].
2185    #[serde(default, deserialize_with = "present_yaml_value")]
2186    retain: Option<serde_yaml::Value>,
2187    #[serde(flatten)]
2188    unknown: UnknownKeys,
2189}
2190
2191#[derive(Debug, Deserialize)]
2192struct RawEvalConfig {
2193    #[serde(default)]
2194    dataset_id: Option<String>,
2195    #[serde(default)]
2196    scorers: Vec<RawScorerConfig>,
2197    #[serde(flatten)]
2198    unknown: UnknownKeys,
2199}
2200
2201#[derive(Debug, Deserialize)]
2202struct RawScorerConfig {
2203    #[serde(rename = "type")]
2204    scorer_type: String,
2205    name: Option<String>,
2206    max: Option<u64>,
2207    expected: Option<Vec<String>>,
2208    #[serde(flatten)]
2209    unknown: UnknownKeys,
2210}
2211
2212#[derive(Debug, Deserialize)]
2213struct RawArtifact {
2214    name: Option<String>,
2215    version: Option<String>,
2216    runtime: Option<String>,
2217    #[serde(default)]
2218    source: Option<String>,
2219    #[serde(default)]
2220    local_source: Option<bool>,
2221    #[serde(default)]
2222    prompt_payload: Option<bool>,
2223    #[serde(default)]
2224    capabilities: Option<RawCapabilities>,
2225    #[serde(default)]
2226    on_overflow: Option<String>,
2227    /// Untyped, and deserialized through [`deserialize_present`] rather than plain `Option`, so
2228    /// `config:` written with nothing under it arrives as `Some(Value::Null)` instead of
2229    /// collapsing into the absent case. The two mean different things: absent grants no variable,
2230    /// while an empty declaration is a written statement that carries nothing and is refused.
2231    #[serde(default, deserialize_with = "deserialize_present")]
2232    config: Option<serde_yaml::Value>,
2233    /// `Some(None)` is `gateway:` written with nothing under it, refused as a block with no
2234    /// endpoint rather than collapsed into the absent case.
2235    #[serde(default, deserialize_with = "deserialize_present")]
2236    gateway: Option<Option<RawArtifactGateway>>,
2237    #[serde(flatten)]
2238    unknown: UnknownKeys,
2239}
2240
2241#[derive(Debug, Deserialize)]
2242struct RawArtifactGateway {
2243    #[serde(default)]
2244    endpoint: Option<String>,
2245    /// `Some(None)` is `api_key:` written as YAML null: present for the contradiction with
2246    /// `keyless: true`, and no credential for the missing-credential refusal.
2247    #[serde(default, deserialize_with = "deserialize_present")]
2248    api_key: Option<Option<String>>,
2249    /// Untyped, so a non-boolean is refused as an invalid declaration naming the field.
2250    #[serde(default)]
2251    keyless: Option<serde_yaml::Value>,
2252    #[serde(flatten)]
2253    unknown: UnknownKeys,
2254}
2255
2256/// Deserialize a present key into `Some(value)` even when its value is YAML null, leaving `None`
2257/// to mean the key was absent and `#[serde(default)]` supplied it.
2258///
2259/// serde's own `Option` impl maps a null value to `None`, which erases exactly the distinction
2260/// `config:` rests on.
2261fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
2262where
2263    T: Deserialize<'de>,
2264    D: serde::Deserializer<'de>,
2265{
2266    T::deserialize(deserializer).map(Some)
2267}
2268
2269#[derive(Debug, Deserialize)]
2270struct RawCapabilities {
2271    #[serde(default)]
2272    network: Option<RawNetworkCapabilities>,
2273    #[serde(default)]
2274    peer_fetch: Option<RawPeerFetchCapabilities>,
2275    #[serde(default)]
2276    filesystem: Option<RawFilesystemCapabilities>,
2277    #[serde(default)]
2278    shell: Option<RawShellCapabilities>,
2279    #[serde(default)]
2280    spawn: Option<RawSpawnCapabilities>,
2281    #[serde(default)]
2282    env: Option<RawEnvCapabilities>,
2283    #[serde(default)]
2284    limits: Option<RawResourceLimits>,
2285    #[serde(default)]
2286    resources: Option<RawResourceCapabilities>,
2287    #[serde(default)]
2288    state: Option<RawStateCapabilities>,
2289    #[serde(default)]
2290    task_io: Option<RawTaskIoCapabilities>,
2291    #[serde(default)]
2292    conversation: Option<RawConversationCapabilities>,
2293    #[serde(default)]
2294    plan: Option<RawPlanCapabilities>,
2295    #[serde(default)]
2296    install: Option<RawInstallCapabilities>,
2297    /// Kept as a raw `String` rather than a `ContainmentClass` so a typo reports through
2298    /// `InvalidCapabilities` like every other bad capability value, instead of a bare serde
2299    /// "unknown variant" error attributed to the whole `capabilities:` block.
2300    #[serde(default)]
2301    containment: Option<String>,
2302    #[serde(flatten)]
2303    unknown: UnknownKeys,
2304}
2305
2306#[derive(Debug, Deserialize)]
2307struct RawTaskIoCapabilities {
2308    // `Option` so an omitted `read` is distinguishable from an explicit `false`: the omission
2309    // is rejected outright rather than defaulted, because a capability is never inferred.
2310    #[serde(default)]
2311    read: Option<bool>,
2312    #[serde(flatten)]
2313    unknown: UnknownKeys,
2314}
2315
2316#[derive(Debug, Deserialize)]
2317struct RawConversationCapabilities {
2318    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2319    // than defaulted.
2320    #[serde(default)]
2321    read: Option<bool>,
2322    #[serde(flatten)]
2323    unknown: UnknownKeys,
2324}
2325
2326#[derive(Debug, Deserialize)]
2327struct RawPlanCapabilities {
2328    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2329    // than defaulted.
2330    #[serde(default)]
2331    submit: Option<bool>,
2332    #[serde(flatten)]
2333    unknown: UnknownKeys,
2334}
2335
2336#[derive(Debug, Deserialize)]
2337struct RawInstallCapabilities {
2338    #[serde(default)]
2339    skill: Vec<String>,
2340    #[serde(default)]
2341    tool: Vec<String>,
2342    #[serde(default)]
2343    from: Option<Vec<String>>,
2344    #[serde(flatten)]
2345    unknown: UnknownKeys,
2346}
2347
2348#[derive(Debug, Deserialize)]
2349struct RawSpawnCapabilities {
2350    #[serde(default)]
2351    allow: Vec<String>,
2352    #[serde(flatten)]
2353    unknown: UnknownKeys,
2354}
2355
2356#[derive(Debug, Deserialize)]
2357struct RawEnvCapabilities {
2358    #[serde(default)]
2359    allow: Vec<String>,
2360    #[serde(flatten)]
2361    unknown: UnknownKeys,
2362}
2363
2364#[derive(Debug, Deserialize)]
2365struct RawResourceLimits {
2366    #[serde(default)]
2367    memory_bytes: Option<usize>,
2368    #[serde(default)]
2369    table_elements: Option<usize>,
2370    #[serde(default)]
2371    instances: Option<usize>,
2372    #[serde(default)]
2373    deadline_seconds: Option<u64>,
2374    #[serde(flatten)]
2375    unknown: UnknownKeys,
2376}
2377
2378#[derive(Debug, Deserialize)]
2379struct RawStateCapabilities {
2380    #[serde(default)]
2381    store: Option<String>,
2382    #[serde(flatten)]
2383    unknown: UnknownKeys,
2384}
2385
2386#[derive(Debug, Deserialize)]
2387struct RawResourceCapabilities {
2388    #[serde(default)]
2389    max_processes: Option<u64>,
2390    #[serde(default)]
2391    max_open_files: Option<u64>,
2392    #[serde(default)]
2393    max_file_size_bytes: Option<u64>,
2394    #[serde(default)]
2395    cpu_seconds: Option<u64>,
2396    #[serde(default)]
2397    memory_bytes: Option<u64>,
2398    #[serde(default)]
2399    cgroup_memory_bytes: Option<u64>,
2400    #[serde(default)]
2401    cgroup_pids_max: Option<u64>,
2402    #[serde(default)]
2403    cgroup_cpu_percent: Option<u32>,
2404    #[serde(default)]
2405    cgroup_io_bytes_per_sec: Option<u64>,
2406    #[serde(default)]
2407    workdir_max_bytes: Option<u64>,
2408    #[serde(flatten)]
2409    unknown: UnknownKeys,
2410}
2411
2412#[derive(Debug, Deserialize)]
2413struct RawNetworkCapabilities {
2414    #[serde(default)]
2415    allow: Vec<String>,
2416    #[serde(default)]
2417    unix_sockets: bool,
2418    #[serde(flatten)]
2419    unknown: UnknownKeys,
2420}
2421
2422#[derive(Debug, Deserialize)]
2423struct RawPeerFetchCapabilities {
2424    #[serde(default)]
2425    allow: Vec<String>,
2426    #[serde(flatten)]
2427    unknown: UnknownKeys,
2428}
2429
2430#[derive(Debug, Deserialize)]
2431struct RawFilesystemCapabilities {
2432    #[serde(default)]
2433    scope: Option<String>,
2434    /// Same shape and defaulting convention as `RawNetworkCapabilities::unix_sockets`: a plain
2435    /// `bool` that is always present after parsing and defaults to `false`, never an `Option`.
2436    /// "The key is absent" and "the key says `false`" are the same declaration.
2437    #[serde(default)]
2438    workdir_exec: bool,
2439    /// Always a list after parsing, never an `Option`: an absent key and an empty list are the
2440    /// same declaration — nothing is protected.
2441    #[serde(default)]
2442    read_only: Vec<String>,
2443    /// Same defaulting convention as `workdir_exec`.
2444    #[serde(default)]
2445    records: bool,
2446    #[serde(flatten)]
2447    unknown: UnknownKeys,
2448}
2449
2450#[derive(Debug, Deserialize)]
2451struct RawShellCapabilities {
2452    #[serde(default)]
2453    allow: Vec<String>,
2454    #[serde(default)]
2455    strip_env: Option<Vec<String>>,
2456    #[serde(default)]
2457    baseline_env: Option<Vec<String>>,
2458    #[serde(default)]
2459    interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
2460    #[serde(default)]
2461    staged_runtime: Vec<RawStagedRuntimeGrant>,
2462    #[serde(flatten)]
2463    unknown: UnknownKeys,
2464}
2465
2466#[derive(Debug, Deserialize)]
2467struct RawInterpreterRuntimeGrant {
2468    #[serde(default)]
2469    binary: Option<String>,
2470    #[serde(default)]
2471    dirs: Vec<RawInterpreterRuntimeDir>,
2472    #[serde(flatten)]
2473    unknown: UnknownKeys,
2474}
2475
2476#[derive(Debug, Deserialize)]
2477struct RawInterpreterRuntimeDir {
2478    #[serde(default)]
2479    path: Option<String>,
2480    // `Option` so an omitted `list_dir` is distinguishable from an explicit `false`: the
2481    // omission is rejected outright rather than defaulted, because enumerability is never
2482    // inferred.
2483    #[serde(default)]
2484    list_dir: Option<bool>,
2485    #[serde(flatten)]
2486    unknown: UnknownKeys,
2487}
2488
2489#[derive(Debug, Deserialize)]
2490struct RawStagedRuntimeGrant {
2491    #[serde(default)]
2492    binary: Option<String>,
2493    #[serde(default)]
2494    source_path: Option<String>,
2495    // `Option` so an omitted `pin` reaches `parse_staged_runtime` and is rejected there with a
2496    // field-naming message, rather than defaulting to an empty string that would silently mean
2497    // "unpinned" — the one thing this field exists to make impossible.
2498    #[serde(default)]
2499    pin: Option<String>,
2500    #[serde(flatten)]
2501    unknown: UnknownKeys,
2502}
2503
2504#[derive(Debug, Deserialize)]
2505struct RawInferenceConfig {
2506    transport: Option<String>,
2507    /// Removed: the driver's upstream is `gateway.endpoint` on its artifact entry. Deserialized
2508    /// only so `parse_inference` can refuse it by name, naming where it moved.
2509    #[serde(default, deserialize_with = "deserialize_present")]
2510    endpoint: Option<serde_yaml::Value>,
2511    model: Option<String>,
2512    /// Removed: the driver's key is `gateway.api_key` on its artifact entry. Deserialized only so
2513    /// `parse_inference` can refuse it by name, naming where it moved.
2514    #[serde(default, deserialize_with = "deserialize_present")]
2515    api_key: Option<serde_yaml::Value>,
2516    #[serde(default)]
2517    command: Option<String>,
2518    #[serde(default)]
2519    system_prompt: Option<String>,
2520    #[serde(default)]
2521    system_prompt_file: Option<String>,
2522    #[serde(default)]
2523    system_prompt_artifact: Option<String>,
2524    #[serde(default)]
2525    driver: Option<RawInferenceDriver>,
2526    #[serde(default)]
2527    compaction: Option<RawCompactionConfig>,
2528    #[serde(default)]
2529    max_turns: Option<u32>,
2530    /// The `inference` spelling of the reopen budget is not a live field. It is deserialized
2531    /// only so `parse_inference` can refuse it; without it serde would ignore the key.
2532    #[serde(default)]
2533    max_task_reopens: Option<u32>,
2534    #[serde(default)]
2535    max_tokens: Option<u32>,
2536    #[serde(default)]
2537    max_session_tokens: Option<u64>,
2538    #[serde(default)]
2539    tool_refresh: Option<String>,
2540    #[serde(default)]
2541    alternates: Option<Vec<RawInferenceAlternate>>,
2542    #[serde(flatten)]
2543    unknown: UnknownKeys,
2544}
2545
2546#[derive(Debug, Deserialize)]
2547struct RawInferenceAlternate {
2548    #[serde(default)]
2549    name: Option<String>,
2550    #[serde(default)]
2551    model: Option<String>,
2552    #[serde(default)]
2553    driver: Option<RawInferenceDriver>,
2554    #[serde(flatten)]
2555    unknown: UnknownKeys,
2556}
2557
2558#[derive(Debug, Deserialize)]
2559struct RawCompactionConfig {
2560    threshold: Option<f32>,
2561    model: Option<String>,
2562    system_prompt: Option<String>,
2563    system_prompt_file: Option<String>,
2564    dump_summaries: Option<bool>,
2565    #[serde(flatten)]
2566    unknown: UnknownKeys,
2567}
2568
2569#[derive(Debug, Deserialize)]
2570struct RawInferenceDriver {
2571    artifact: Option<String>,
2572    #[serde(default)]
2573    config: Option<serde_yaml::Value>,
2574    #[serde(flatten)]
2575    unknown: UnknownKeys,
2576}
2577
2578/// The keys one `Raw*` block carried that no field of it claims.
2579///
2580/// A `BTreeMap` so the capture order is the manifest's key order sorted, which keeps the
2581/// `W-SEC-019` lines for one manifest identical on every run. The values are discarded: a key this
2582/// build does not recognize is reported by name and by containing block, and printing whatever an
2583/// operator wrote under it would put manifest contents into a diagnostic that has no use for them.
2584type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2585
2586/// A `Raw*` deserialization block that captures the keys this build does not recognize.
2587///
2588/// Implemented for every `Raw*` struct in this file, which is asserted structurally by
2589/// `every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names` rather than left to
2590/// review: a manifest key added without its block learning about it is exactly the defect
2591/// `W-SEC-019` exists to report.
2592trait RawBlock {
2593    /// Every key serde accepts on this block, in declaration order and under the name serde
2594    /// matches (so a `#[serde(rename)]` field appears under its renamed spelling). This is the
2595    /// candidate set [`crate::unknown_manifest_keys::nearest_known_key`] suggests from.
2596    const KNOWN_KEYS: &'static [&'static str];
2597
2598    fn unknown_keys(&self) -> &UnknownKeys;
2599
2600    /// Descends into the blocks this one owns, in declaration order, so the reported paths read
2601    /// down the manifest the way the document does.
2602    ///
2603    /// Defaulted to a no-op, which is correct for a block that owns no other block. A block that
2604    /// does own one must override this: `every_raw_struct_descends_into_the_blocks_it_owns` reads
2605    /// this file's own text and fails, naming the struct and the field, if it does not — an
2606    /// unwalked block reports none of its keys, silently.
2607    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2608        let _ = (path, out);
2609    }
2610}
2611
2612impl RawBlock for RawRuntimeManifest {
2613    const KNOWN_KEYS: &'static [&'static str] = &[
2614        "name",
2615        "version",
2616        "artifacts",
2617        "capabilities",
2618        "inference",
2619        "context",
2620        "observability",
2621        "trace",
2622        "network",
2623        "lifecycle",
2624        "exports",
2625        "control",
2626        "deploy",
2627        "mur_version",
2628        "config",
2629        "runtime",
2630        "implementation",
2631        "execution",
2632        "requires_files",
2633    ];
2634    fn unknown_keys(&self) -> &UnknownKeys {
2635        &self.unknown
2636    }
2637
2638    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2639        let artifacts = child_path(path, "artifacts");
2640        for (index, artifact) in self.artifacts.iter().enumerate() {
2641            collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2642        }
2643        if let Some(capabilities) = &self.capabilities {
2644            collect_block(capabilities, &child_path(path, "capabilities"), out);
2645        }
2646        if let Some(inference) = &self.inference {
2647            collect_block(inference, &child_path(path, "inference"), out);
2648        }
2649        if let Some(context) = &self.context {
2650            collect_block(context, &child_path(path, "context"), out);
2651        }
2652        if let Some(observability) = &self.observability {
2653            collect_block(observability, &child_path(path, "observability"), out);
2654        }
2655        if let Some(trace) = &self.trace {
2656            collect_block(trace, &child_path(path, "trace"), out);
2657        }
2658        if let Some(network) = &self.network {
2659            collect_block(network, &child_path(path, "network"), out);
2660        }
2661        if let Some(lifecycle) = &self.lifecycle {
2662            collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2663        }
2664        if let Some(exports) = &self.exports {
2665            collect_block(exports, &child_path(path, "exports"), out);
2666        }
2667        if let Some(control) = &self.control {
2668            collect_block(control, &child_path(path, "control"), out);
2669        }
2670        if let Some(deploy) = &self.deploy {
2671            collect_block(deploy, &child_path(path, "deploy"), out);
2672        }
2673    }
2674}
2675
2676impl RawBlock for RawDeploy {
2677    const KNOWN_KEYS: &'static [&'static str] = &["mur_version"];
2678    fn unknown_keys(&self) -> &UnknownKeys {
2679        &self.unknown
2680    }
2681}
2682
2683impl RawBlock for RawControl {
2684    const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
2685    fn unknown_keys(&self) -> &UnknownKeys {
2686        &self.unknown
2687    }
2688}
2689
2690impl RawBlock for RawExports {
2691    const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files", "peer_tasks"];
2692    fn unknown_keys(&self) -> &UnknownKeys {
2693        &self.unknown
2694    }
2695
2696    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2697        if let Some(files) = &self.files {
2698            collect_block(files, &child_path(path, "files"), out);
2699        }
2700        if let Some(peer_files) = &self.peer_files {
2701            collect_block(peer_files, &child_path(path, "peer_files"), out);
2702        }
2703        if let Some(peer_tasks) = &self.peer_tasks {
2704            collect_block(peer_tasks, &child_path(path, "peer_tasks"), out);
2705        }
2706    }
2707}
2708
2709impl RawBlock for RawPeerTasksExport {
2710    const KNOWN_KEYS: &'static [&'static str] = &["accept"];
2711    fn unknown_keys(&self) -> &UnknownKeys {
2712        &self.unknown
2713    }
2714}
2715
2716impl RawBlock for RawPeerFilesExport {
2717    const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2718    fn unknown_keys(&self) -> &UnknownKeys {
2719        &self.unknown
2720    }
2721}
2722
2723impl RawBlock for RawFileExport {
2724    const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2725    fn unknown_keys(&self) -> &UnknownKeys {
2726        &self.unknown
2727    }
2728}
2729
2730impl RawBlock for RawLifecycleConfig {
2731    const KNOWN_KEYS: &'static [&'static str] = &[
2732        "task_acceptance",
2733        "after_task",
2734        "queue_depth",
2735        "input_timeout_secs",
2736        "conversation",
2737        "max_task_reopens",
2738        "shell_grace_secs",
2739        "delegation_deadline_secs",
2740    ];
2741    fn unknown_keys(&self) -> &UnknownKeys {
2742        &self.unknown
2743    }
2744}
2745
2746impl RawBlock for RawNetworkConfig {
2747    const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
2748    fn unknown_keys(&self) -> &UnknownKeys {
2749        &self.unknown
2750    }
2751
2752    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2753        if let Some(authentication) = &self.authentication {
2754            collect_block(authentication, &child_path(path, "authentication"), out);
2755        }
2756    }
2757}
2758
2759impl RawBlock for RawNetworkAuthentication {
2760    const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
2761    fn unknown_keys(&self) -> &UnknownKeys {
2762        &self.unknown
2763    }
2764
2765    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2766        let credentials_path = child_path(path, "credentials");
2767        for (name, credential) in self.credentials.iter().flatten() {
2768            collect_block(credential, &child_path(&credentials_path, name), out);
2769        }
2770    }
2771}
2772
2773impl RawBlock for RawDoorCredential {
2774    const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
2775    fn unknown_keys(&self) -> &UnknownKeys {
2776        &self.unknown
2777    }
2778}
2779
2780impl RawBlock for RawContextConfig {
2781    const KNOWN_KEYS: &'static [&'static str] = &[
2782        "max_tokens",
2783        "record",
2784        "record_store",
2785        "seed_budget",
2786        "seed_overflow_margin",
2787        "retain",
2788    ];
2789    fn unknown_keys(&self) -> &UnknownKeys {
2790        &self.unknown
2791    }
2792}
2793
2794impl RawBlock for RawObservabilityConfig {
2795    const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
2796    fn unknown_keys(&self) -> &UnknownKeys {
2797        &self.unknown
2798    }
2799
2800    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2801        if let Some(eval) = &self.eval {
2802            collect_block(eval, &child_path(path, "eval"), out);
2803        }
2804    }
2805}
2806
2807impl RawBlock for RawTraceConfig {
2808    const KNOWN_KEYS: &'static [&'static str] = &["capture", "retain"];
2809    fn unknown_keys(&self) -> &UnknownKeys {
2810        &self.unknown
2811    }
2812}
2813
2814impl RawBlock for RawEvalConfig {
2815    const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
2816    fn unknown_keys(&self) -> &UnknownKeys {
2817        &self.unknown
2818    }
2819
2820    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2821        let scorers = child_path(path, "scorers");
2822        for (index, scorer) in self.scorers.iter().enumerate() {
2823            collect_block(scorer, &format!("{scorers}[{index}]"), out);
2824        }
2825    }
2826}
2827
2828impl RawBlock for RawScorerConfig {
2829    const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
2830    fn unknown_keys(&self) -> &UnknownKeys {
2831        &self.unknown
2832    }
2833}
2834
2835impl RawBlock for RawArtifact {
2836    const KNOWN_KEYS: &'static [&'static str] = &[
2837        "name",
2838        "version",
2839        "runtime",
2840        "source",
2841        "local_source",
2842        "prompt_payload",
2843        "capabilities",
2844        "on_overflow",
2845        "config",
2846        "gateway",
2847    ];
2848    fn unknown_keys(&self) -> &UnknownKeys {
2849        &self.unknown
2850    }
2851
2852    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2853        if let Some(capabilities) = &self.capabilities {
2854            collect_block(capabilities, &child_path(path, "capabilities"), out);
2855        }
2856        if let Some(Some(gateway)) = &self.gateway {
2857            collect_block(gateway, &child_path(path, "gateway"), out);
2858        }
2859    }
2860}
2861
2862impl RawBlock for RawArtifactGateway {
2863    const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
2864    fn unknown_keys(&self) -> &UnknownKeys {
2865        &self.unknown
2866    }
2867}
2868
2869impl RawBlock for RawCapabilities {
2870    const KNOWN_KEYS: &'static [&'static str] = &[
2871        "network",
2872        "peer_fetch",
2873        "filesystem",
2874        "shell",
2875        "spawn",
2876        "env",
2877        "limits",
2878        "resources",
2879        "state",
2880        "task_io",
2881        "conversation",
2882        "plan",
2883        "install",
2884        "containment",
2885    ];
2886    fn unknown_keys(&self) -> &UnknownKeys {
2887        &self.unknown
2888    }
2889
2890    /// Reached from both [`RawRuntimeManifest`] and [`RawArtifact`], so the capsule-wide block and
2891    /// a per-artifact one report through identical code and differ only in the `path` they are
2892    /// handed — `capabilities.shell` against `artifacts[0].capabilities.shell`.
2893    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2894        if let Some(network) = &self.network {
2895            collect_block(network, &child_path(path, "network"), out);
2896        }
2897        if let Some(peer_fetch) = &self.peer_fetch {
2898            collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
2899        }
2900        if let Some(filesystem) = &self.filesystem {
2901            collect_block(filesystem, &child_path(path, "filesystem"), out);
2902        }
2903        if let Some(shell) = &self.shell {
2904            collect_block(shell, &child_path(path, "shell"), out);
2905        }
2906        if let Some(spawn) = &self.spawn {
2907            collect_block(spawn, &child_path(path, "spawn"), out);
2908        }
2909        if let Some(env) = &self.env {
2910            collect_block(env, &child_path(path, "env"), out);
2911        }
2912        if let Some(limits) = &self.limits {
2913            collect_block(limits, &child_path(path, "limits"), out);
2914        }
2915        if let Some(resources) = &self.resources {
2916            collect_block(resources, &child_path(path, "resources"), out);
2917        }
2918        if let Some(state) = &self.state {
2919            collect_block(state, &child_path(path, "state"), out);
2920        }
2921        if let Some(task_io) = &self.task_io {
2922            collect_block(task_io, &child_path(path, "task_io"), out);
2923        }
2924        if let Some(conversation) = &self.conversation {
2925            collect_block(conversation, &child_path(path, "conversation"), out);
2926        }
2927        if let Some(plan) = &self.plan {
2928            collect_block(plan, &child_path(path, "plan"), out);
2929        }
2930        if let Some(install) = &self.install {
2931            collect_block(install, &child_path(path, "install"), out);
2932        }
2933    }
2934}
2935
2936impl RawBlock for RawTaskIoCapabilities {
2937    const KNOWN_KEYS: &'static [&'static str] = &["read"];
2938    fn unknown_keys(&self) -> &UnknownKeys {
2939        &self.unknown
2940    }
2941}
2942
2943impl RawBlock for RawConversationCapabilities {
2944    const KNOWN_KEYS: &'static [&'static str] = &["read"];
2945    fn unknown_keys(&self) -> &UnknownKeys {
2946        &self.unknown
2947    }
2948}
2949
2950impl RawBlock for RawPlanCapabilities {
2951    const KNOWN_KEYS: &'static [&'static str] = &["submit"];
2952    fn unknown_keys(&self) -> &UnknownKeys {
2953        &self.unknown
2954    }
2955}
2956
2957impl RawBlock for RawInstallCapabilities {
2958    const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool", "from"];
2959    fn unknown_keys(&self) -> &UnknownKeys {
2960        &self.unknown
2961    }
2962}
2963
2964impl RawBlock for RawSpawnCapabilities {
2965    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2966    fn unknown_keys(&self) -> &UnknownKeys {
2967        &self.unknown
2968    }
2969}
2970
2971impl RawBlock for RawEnvCapabilities {
2972    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2973    fn unknown_keys(&self) -> &UnknownKeys {
2974        &self.unknown
2975    }
2976}
2977
2978impl RawBlock for RawResourceLimits {
2979    const KNOWN_KEYS: &'static [&'static str] = &[
2980        "memory_bytes",
2981        "table_elements",
2982        "instances",
2983        "deadline_seconds",
2984    ];
2985    fn unknown_keys(&self) -> &UnknownKeys {
2986        &self.unknown
2987    }
2988}
2989
2990impl RawBlock for RawStateCapabilities {
2991    const KNOWN_KEYS: &'static [&'static str] = &["store"];
2992    fn unknown_keys(&self) -> &UnknownKeys {
2993        &self.unknown
2994    }
2995}
2996
2997impl RawBlock for RawResourceCapabilities {
2998    const KNOWN_KEYS: &'static [&'static str] = &[
2999        "max_processes",
3000        "max_open_files",
3001        "max_file_size_bytes",
3002        "cpu_seconds",
3003        "memory_bytes",
3004        "cgroup_memory_bytes",
3005        "cgroup_pids_max",
3006        "cgroup_cpu_percent",
3007        "cgroup_io_bytes_per_sec",
3008        "workdir_max_bytes",
3009    ];
3010    fn unknown_keys(&self) -> &UnknownKeys {
3011        &self.unknown
3012    }
3013}
3014
3015impl RawBlock for RawNetworkCapabilities {
3016    const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
3017    fn unknown_keys(&self) -> &UnknownKeys {
3018        &self.unknown
3019    }
3020}
3021
3022impl RawBlock for RawPeerFetchCapabilities {
3023    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3024    fn unknown_keys(&self) -> &UnknownKeys {
3025        &self.unknown
3026    }
3027}
3028
3029impl RawBlock for RawFilesystemCapabilities {
3030    const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only", "records"];
3031    fn unknown_keys(&self) -> &UnknownKeys {
3032        &self.unknown
3033    }
3034}
3035
3036impl RawBlock for RawShellCapabilities {
3037    const KNOWN_KEYS: &'static [&'static str] = &[
3038        "allow",
3039        "strip_env",
3040        "baseline_env",
3041        "interpreter_runtime",
3042        "staged_runtime",
3043    ];
3044    fn unknown_keys(&self) -> &UnknownKeys {
3045        &self.unknown
3046    }
3047
3048    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3049        let interpreter_runtime = child_path(path, "interpreter_runtime");
3050        for (index, grant) in self.interpreter_runtime.iter().enumerate() {
3051            collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
3052        }
3053        let staged_runtime = child_path(path, "staged_runtime");
3054        for (index, grant) in self.staged_runtime.iter().enumerate() {
3055            collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
3056        }
3057    }
3058}
3059
3060impl RawBlock for RawInterpreterRuntimeGrant {
3061    const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
3062    fn unknown_keys(&self) -> &UnknownKeys {
3063        &self.unknown
3064    }
3065
3066    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3067        let dirs = child_path(path, "dirs");
3068        for (index, dir) in self.dirs.iter().enumerate() {
3069            collect_block(dir, &format!("{dirs}[{index}]"), out);
3070        }
3071    }
3072}
3073
3074impl RawBlock for RawInterpreterRuntimeDir {
3075    const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
3076    fn unknown_keys(&self) -> &UnknownKeys {
3077        &self.unknown
3078    }
3079}
3080
3081impl RawBlock for RawStagedRuntimeGrant {
3082    const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
3083    fn unknown_keys(&self) -> &UnknownKeys {
3084        &self.unknown
3085    }
3086}
3087
3088impl RawBlock for RawInferenceConfig {
3089    const KNOWN_KEYS: &'static [&'static str] = &[
3090        "transport",
3091        "endpoint",
3092        "model",
3093        "api_key",
3094        "command",
3095        "system_prompt",
3096        "system_prompt_file",
3097        "system_prompt_artifact",
3098        "driver",
3099        "compaction",
3100        "max_turns",
3101        "max_task_reopens",
3102        "max_tokens",
3103        "max_session_tokens",
3104        "tool_refresh",
3105        "alternates",
3106    ];
3107    fn unknown_keys(&self) -> &UnknownKeys {
3108        &self.unknown
3109    }
3110
3111    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3112        if let Some(driver) = &self.driver {
3113            collect_block(driver, &child_path(path, "driver"), out);
3114        }
3115        if let Some(compaction) = &self.compaction {
3116            collect_block(compaction, &child_path(path, "compaction"), out);
3117        }
3118        if let Some(alternates) = &self.alternates {
3119            let alternates_path = child_path(path, "alternates");
3120            for (index, alternate) in alternates.iter().enumerate() {
3121                collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
3122            }
3123        }
3124    }
3125}
3126
3127impl RawBlock for RawInferenceAlternate {
3128    const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
3129    fn unknown_keys(&self) -> &UnknownKeys {
3130        &self.unknown
3131    }
3132
3133    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3134        if let Some(driver) = &self.driver {
3135            collect_block(driver, &child_path(path, "driver"), out);
3136        }
3137    }
3138}
3139
3140impl RawBlock for RawCompactionConfig {
3141    const KNOWN_KEYS: &'static [&'static str] = &[
3142        "threshold",
3143        "model",
3144        "system_prompt",
3145        "system_prompt_file",
3146        "dump_summaries",
3147    ];
3148    fn unknown_keys(&self) -> &UnknownKeys {
3149        &self.unknown
3150    }
3151}
3152
3153impl RawBlock for RawInferenceDriver {
3154    const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
3155    fn unknown_keys(&self) -> &UnknownKeys {
3156        &self.unknown
3157    }
3158}
3159
3160/// Walks a parsed raw manifest and reports every key no block of it claimed, each block's own
3161/// keys before the blocks it owns, in the order the blocks appear in the manifest's own type.
3162///
3163/// Called once, immediately after `serde_yaml` returns and before any field is moved out, so the
3164/// result covers the whole document rather than the parts validation happens to reach. Reporting
3165/// only; nothing here can refuse a manifest.
3166fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
3167    let mut out = Vec::new();
3168    collect_block(raw, "", &mut out);
3169    out
3170}
3171
3172/// One block's captured keys, each paired with the nearest key that block does recognize, then
3173/// the same for every block it owns.
3174///
3175/// `path` is empty for the manifest root, which is what
3176/// [`crate::unknown_manifest_keys::UnknownManifestKey`] renders as "at the top level".
3177fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
3178    for key in block.unknown_keys().keys() {
3179        out.push(UnknownManifestKey {
3180            key: key.clone(),
3181            block_path: path.to_string(),
3182            nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
3183        });
3184    }
3185    block.walk_children(path, out);
3186}
3187
3188/// The dotted path of `field` inside the block at `parent`, built in one place so no
3189/// [`RawBlock::walk_children`] has to reproduce the rule that the manifest root has no prefix.
3190/// An indexed child appends its own `[index]` to the result.
3191fn child_path(parent: &str, field: &str) -> String {
3192    if parent.is_empty() {
3193        return field.to_string();
3194    }
3195    format!("{parent}.{field}")
3196}
3197
3198/// Read a manifest file's text, mapping a missing file to
3199/// [`RuntimeManifestError::NotFound`] and any other read failure to
3200/// [`RuntimeManifestError::Io`].
3201///
3202/// For a caller that needs the manifest text as well as the parsed manifest — `mur doctor` reads
3203/// the references a manifest makes out of the same bytes it parses, so the file is opened once.
3204pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
3205    fs::read_to_string(path).map_err(|source| {
3206        if source.kind() == std::io::ErrorKind::NotFound {
3207            return RuntimeManifestError::NotFound(path.display().to_string());
3208        }
3209
3210        RuntimeManifestError::Io {
3211            path: path.display().to_string(),
3212            source,
3213        }
3214    })
3215}
3216
3217#[must_use = "validated runtime manifest is required before run"]
3218pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
3219    let content = read_runtime_manifest_text(path)?;
3220
3221    RuntimeManifest::from_yaml_str(&content)
3222}
3223
3224/// One `${VAR}` reference a manifest makes, and the field that makes it.
3225#[derive(Debug, Clone, PartialEq, Eq)]
3226pub struct ReferencedEnvVariable {
3227    /// The manifest field holding the reference, e.g. `artifacts.murmur-driver-anthropic.gateway.api_key`.
3228    pub field: String,
3229    /// The variable the reference names.
3230    pub variable: String,
3231}
3232
3233impl ReferencedEnvVariable {
3234    /// Whether the reference is a gateway's `api_key` — a credential the runtime also answers
3235    /// from the global config's `credentials:` map, not only from the environment.
3236    pub fn is_gateway_credential(&self) -> bool {
3237        self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
3238    }
3239}
3240
3241/// The narrow view of a manifest this scan needs: the fields whose value may be an `${ENV_REF}`,
3242/// and nothing else. Every other key is ignored, so a manifest this build would refuse for an
3243/// unrelated reason still yields its references.
3244///
3245/// Deliberately not one of the `Raw*` structs: those carry the unknown-key overflow map that
3246/// makes `warn_on_unknown_manifest_keys` complete, and this one is a second, partial reader of a
3247/// document that reader has already covered.
3248#[derive(Deserialize)]
3249struct ReferencedManifestFields {
3250    #[serde(default)]
3251    artifacts: Vec<ReferencedArtifactFields>,
3252}
3253
3254#[derive(Deserialize)]
3255struct ReferencedArtifactFields {
3256    #[serde(default)]
3257    name: Option<String>,
3258    #[serde(default)]
3259    gateway: Option<ReferencedGatewayFields>,
3260}
3261
3262#[derive(Deserialize)]
3263struct ReferencedGatewayFields {
3264    #[serde(default)]
3265    api_key: Option<String>,
3266}
3267
3268/// Every environment variable `manifest_yaml` references through an `${ENV_REF}`, sorted by
3269/// variable name and deduplicated.
3270///
3271/// Never fails and never reads the environment: unparseable YAML, an absent field and a literal
3272/// (non-reference) value all yield an empty list. What counts as a reference is decided by the
3273/// same `parse_env_reference` the resolving parse applies, so a name reported here is exactly a
3274/// name [`RuntimeManifest::from_yaml_str`] would have looked up.
3275pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
3276    let Ok(raw) = serde_yaml::from_str::<ReferencedManifestFields>(manifest_yaml) else {
3277        return Vec::new();
3278    };
3279
3280    let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
3281    for artifact in raw.artifacts {
3282        let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
3283            continue;
3284        };
3285        if let Some(variable) = parse_env_reference(api_key.trim()) {
3286            let name = artifact.name.unwrap_or_default();
3287            referenced.push(ReferencedEnvVariable {
3288                field: format!("artifacts.{}.gateway.api_key", name.trim()),
3289                variable: variable.to_string(),
3290            });
3291        }
3292    }
3293
3294    referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
3295    referenced.dedup();
3296    referenced
3297}
3298
3299/// Whether a parse keeps what the manifest says about a secret it references.
3300///
3301/// The two [`RuntimeManifest`] constructors differ by this value and nothing else, so validation
3302/// cannot drift between a manifest `mur run` accepts and one a referee accepts. Neither reads the
3303/// process environment.
3304#[derive(Clone, Copy, PartialEq, Eq)]
3305enum SecretResolution {
3306    /// Every `gateway.api_key` is kept as an [`ApiKeyReference`], unresolved.
3307    Resolve,
3308    /// Every `gateway.api_key` is left unread. Each parsed [`ArtifactGateway::api_key`] is `None`
3309    /// whatever the manifest wrote.
3310    Skip,
3311}
3312
3313impl RuntimeManifest {
3314    pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
3315        Self::parse(input, SecretResolution::Resolve)
3316    }
3317
3318    /// Parse a manifest without resolving any secret it references.
3319    ///
3320    /// Validation is identical to [`RuntimeManifest::from_yaml_str`] on every block, `artifacts`
3321    /// included, with one difference: every resulting [`ArtifactGateway::api_key`] is `None`,
3322    /// whether the manifest wrote an `${ENV_REF}` or a literal key.
3323    ///
3324    /// For a caller that reads a foreign capsule's manifest for its capability policy alone —
3325    /// `mur-roost` refereeing a delegation — and is entitled to none of the credentials that
3326    /// manifest names.
3327    pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
3328        Self::parse(input, SecretResolution::Skip)
3329    }
3330
3331    fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
3332        let raw: RawRuntimeManifest = serde_yaml::from_str(input).map_err(|err| {
3333            if let Some(location) = err.location() {
3334                RuntimeManifestError::YamlSyntax(format!(
3335                    "{}: YAML syntax error at line {}, column {}: {}",
3336                    MANIFEST_FILENAME,
3337                    location.line(),
3338                    location.column(),
3339                    err
3340                ))
3341            } else {
3342                RuntimeManifestError::YamlSyntax(format!(
3343                    "{MANIFEST_FILENAME}: YAML syntax error: {err}"
3344                ))
3345            }
3346        })?;
3347
3348        // The key is not a field of `RawRuntimeManifest`, so it lands in the unknown-key map.
3349        if raw.unknown.contains_key(REGISTRY_BLOCK_KEY) {
3350            return Err(RuntimeManifestError::RegistryBlock);
3351        }
3352
3353        // Collected before any field is moved out of `raw`, so the walk sees the whole document
3354        // rather than the part validation happens to reach before its first refusal.
3355        let unknown_keys = collect_unknown_keys(&raw);
3356
3357        if raw.mur_version.is_some() {
3358            return Err(RuntimeManifestError::TopLevelMurVersion);
3359        }
3360
3361        let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
3362            RuntimeManifestError::MissingField {
3363                field: "name".to_string(),
3364            }
3365        })?;
3366
3367        let version = raw
3368            .version
3369            .filter(|s| !s.trim().is_empty())
3370            .ok_or_else(|| RuntimeManifestError::MissingField {
3371                field: "version".to_string(),
3372            })?;
3373
3374        // Read off the raw entries because a parse that skips secrets keeps no `ApiKeyReference`,
3375        // and `control.secrets` is checked against what the manifest wrote either way.
3376        let gateway_references = raw_gateway_references(&raw.artifacts);
3377
3378        let artifacts = raw
3379            .artifacts
3380            .into_iter()
3381            .enumerate()
3382            .map(|(index, artifact)| {
3383                let name = artifact
3384                    .name
3385                    .filter(|s| !s.trim().is_empty())
3386                    .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3387                        index,
3388                        message: "missing required field 'name'".to_string(),
3389                    })?;
3390
3391                let source = artifact.source.filter(|s| !s.trim().is_empty());
3392                check_entry_name(index, &name, source.is_some())?;
3393
3394                let runtime = match artifact.runtime.as_deref() {
3395                    None | Some("tool") => ArtifactRuntime::Tool,
3396                    Some("wasm") | Some("native") => {
3397                        return Err(RuntimeManifestError::InvalidArtifact {
3398                            index,
3399                            message: format!(
3400                                "use 'runtime: tool'; implementation is declared in the \
3401                                 artifact's own manifest (got '{}')",
3402                                artifact.runtime.as_deref().unwrap_or("")
3403                            ),
3404                        });
3405                    }
3406                    Some("driver") => ArtifactRuntime::Driver,
3407                    Some("hook") => ArtifactRuntime::Hook,
3408                    Some("skill") => ArtifactRuntime::Skill,
3409                    Some(other) => {
3410                        return Err(RuntimeManifestError::InvalidArtifact {
3411                            index,
3412                            message: format!(
3413                                "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
3414                            ),
3415                        });
3416                    }
3417                };
3418
3419                // Capabilities are keyed on declared properties, not on the role. When the
3420                // property is absent we derive it from the role, which reproduces the previous
3421                // skill-only gate exactly and leaves every existing manifest unchanged.
3422                let local_source = artifact
3423                    .local_source
3424                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3425                let prompt_payload = artifact
3426                    .prompt_payload
3427                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3428
3429                // `source:` requires the artifact to declare `local_source: true`.
3430                if source.is_some() && !local_source {
3431                    return Err(RuntimeManifestError::InvalidArtifact {
3432                        index,
3433                        message: format!(
3434                            "artifact '{name}' declares 'source:' but does not declare \
3435                             'local_source: true' (runtime: {})",
3436                            runtime.as_str()
3437                        ),
3438                    });
3439                }
3440
3441                // When `source:` is present, `version:` is optional — substitute "local".
3442                // If both are set, `version:` is ignored and a warning is printed to stderr.
3443                let version = if source.is_some() {
3444                    if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
3445                        crate::warn_line(format_args!(
3446                            "warning: artifact '{name}' declares both 'source:' and \
3447                             'version: {explicit}'; version is ignored for local-source skills \
3448                             (using 'local')"
3449                        ));
3450                    }
3451                    "local".to_string()
3452                } else {
3453                    artifact
3454                        .version
3455                        .filter(|s| !s.trim().is_empty())
3456                        .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3457                            index,
3458                            message: "missing required field 'version'".to_string(),
3459                        })?
3460                };
3461
3462                // Per-artifact `capabilities:` is enforced on every role that actually
3463                // executes — `hook` (default-deny grant) and `tool`/`driver` (narrowing
3464                // below the capsule ceiling). A `skill` has no execution surface, so the
3465                // block would be silently ignored there; that reads like a scoped artifact
3466                // when it is not, hence a parse error rather than a no-op.
3467                let capabilities = match artifact.capabilities {
3468                    None => None,
3469                    Some(raw_caps) => {
3470                        // The install grant is held by the session, not by one artifact: a
3471                        // per-artifact block would reach no pull at all.
3472                        if raw_caps.install.is_some() {
3473                            return Err(RuntimeManifestError::InvalidArtifact {
3474                                index,
3475                                message: format!(
3476                                    "artifact '{name}' declares 'capabilities.install:'; the \
3477                                     grant is capsule-wide and is only recognized in the \
3478                                     top-level capabilities block"
3479                                ),
3480                            });
3481                        }
3482                        if runtime == ArtifactRuntime::Skill {
3483                            return Err(RuntimeManifestError::InvalidArtifact {
3484                                index,
3485                                message: format!(
3486                                    "artifact '{name}' declares per-artifact 'capabilities:' but \
3487                                     has 'runtime: {}'; per-artifact capabilities are only \
3488                                     recognized on 'runtime: hook', 'runtime: tool', and \
3489                                     'runtime: driver' entries — use the capsule-wide top-level \
3490                                     'capabilities:' block instead",
3491                                    runtime.as_str()
3492                                ),
3493                            });
3494                        }
3495                        // `task_io` grants the `murmur:task-io/read` host import, which only
3496                        // hook components can be handed. On a tool or driver entry nothing
3497                        // would enforce it, and a silently-inert grant reads like a scoped
3498                        // artifact — the same reason `on_overflow:` below is a parse error off
3499                        // a hook.
3500                        if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
3501                            return Err(RuntimeManifestError::InvalidArtifact {
3502                                index,
3503                                message: format!(
3504                                    "artifact '{name}' declares 'capabilities.task_io:' but has \
3505                                     'runtime: {}'; the key grants the murmur:task-io/read host \
3506                                     import and is only recognized on 'runtime: hook' entries",
3507                                    runtime.as_str()
3508                                ),
3509                            });
3510                        }
3511                        // `filesystem.records` replaces a hook's workdir scope with the records
3512                        // directory's `hooks/`. Only a hook is preopened that way, and a hook has
3513                        // one `"."`, so the two declarations cannot both hold.
3514                        if let Some(filesystem) = raw_caps
3515                            .filesystem
3516                            .as_ref()
3517                            .filter(|filesystem| filesystem.records)
3518                        {
3519                            if runtime != ArtifactRuntime::Hook {
3520                                return Err(RuntimeManifestError::InvalidArtifact {
3521                                    index,
3522                                    message: format!(
3523                                        "artifact '{name}' declares \
3524                                         'capabilities.filesystem.records: true' but has \
3525                                         'runtime: {}'; the key gives a hook the records \
3526                                         directory's hooks/ and is only recognized on \
3527                                         'runtime: hook' entries",
3528                                        runtime.as_str()
3529                                    ),
3530                                });
3531                            }
3532                            if filesystem
3533                                .scope
3534                                .as_deref()
3535                                .is_some_and(|scope| !scope.trim().is_empty())
3536                            {
3537                                return Err(RuntimeManifestError::InvalidArtifact {
3538                                    index,
3539                                    message: format!(
3540                                        "artifact '{name}' declares both \
3541                                         'capabilities.filesystem.records: true' and \
3542                                         'capabilities.filesystem.scope'; a hook's \".\" is \
3543                                         either the records directory's hooks/ or a workdir \
3544                                         scope — declare one"
3545                                    ),
3546                                });
3547                            }
3548                        }
3549                        // `conversation` grants the `murmur:conversation/read` host import, on the
3550                        // same terms as `task_io` above: only a hook component's world can import
3551                        // it, so on any other role the grant would be silently inert.
3552                        if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
3553                            return Err(RuntimeManifestError::InvalidArtifact {
3554                                index,
3555                                message: format!(
3556                                    "artifact '{name}' declares 'capabilities.conversation:' but \
3557                                     has 'runtime: {}'; the key grants the \
3558                                     murmur:conversation/read host import and is only recognized \
3559                                     on 'runtime: hook' entries",
3560                                    runtime.as_str()
3561                                ),
3562                            });
3563                        }
3564                        parse_capabilities(Some(raw_caps))?
3565                    }
3566                };
3567
3568                // `on_overflow:` governs an async hook's job queue, which only exists for
3569                // `runtime: hook`. On any other role it would be silently ignored, so it is
3570                // a parse error for the same reason per-artifact `capabilities:` is on a
3571                // skill. It stays legal on a *blocking* hook entry (simply inert): the
3572                // execution mode lives in the artifact's own manifest and is unknown here.
3573                let on_overflow = match artifact.on_overflow.as_deref() {
3574                    None => HookOverflowPolicy::default(),
3575                    Some(raw) => {
3576                        if runtime != ArtifactRuntime::Hook {
3577                            return Err(RuntimeManifestError::InvalidArtifact {
3578                                index,
3579                                message: format!(
3580                                    "artifact '{name}' declares 'on_overflow:' but has \
3581                                     'runtime: {}'; the key governs an async hook's job queue \
3582                                     and is only recognized on 'runtime: hook' entries",
3583                                    runtime.as_str()
3584                                ),
3585                            });
3586                        }
3587                        match raw {
3588                            "drop" => HookOverflowPolicy::Drop,
3589                            "block" => HookOverflowPolicy::Block,
3590                            other => {
3591                                return Err(RuntimeManifestError::InvalidArtifact {
3592                                    index,
3593                                    message: format!(
3594                                        "artifact '{name}' declares unknown on_overflow \
3595                                         '{other}'; expected: drop, block"
3596                                    ),
3597                                });
3598                            }
3599                        }
3600                    }
3601                };
3602
3603                // `config:` reaches an artifact through that artifact's own grant, and a skill
3604                // holds no grant and runs no component — nothing would ever deliver it. Refused
3605                // rather than ignored, for the reason the two gates above are: a key that reads
3606                // like configuration and is silently dropped is worse than one that is refused.
3607                // The block's *shape* is the runtime's business, not this parser's; only the
3608                // role is decided here.
3609                if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
3610                    return Err(RuntimeManifestError::InvalidArtifact {
3611                        index,
3612                        message: format!(
3613                            "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
3614                             is recognized only on 'runtime: hook', 'runtime: tool', and \
3615                             'runtime: driver' entries",
3616                            runtime.as_str()
3617                        ),
3618                    });
3619                }
3620
3621                let gateway = match artifact.gateway {
3622                    None => None,
3623                    Some(raw_gateway) => {
3624                        if runtime == ArtifactRuntime::Skill {
3625                            return Err(RuntimeManifestError::InvalidArtifact {
3626                                index,
3627                                message: format!(
3628                                    "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
3629                                     the key is recognized only on 'runtime: hook', \
3630                                     'runtime: tool', and 'runtime: driver' entries",
3631                                    runtime.as_str()
3632                                ),
3633                            });
3634                        }
3635                        Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
3636                    }
3637                };
3638
3639                Ok(RuntimeArtifact {
3640                    name,
3641                    version,
3642                    runtime,
3643                    source,
3644                    local_source,
3645                    prompt_payload,
3646                    capabilities,
3647                    on_overflow,
3648                    config: artifact.config,
3649                    gateway,
3650                })
3651            })
3652            .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
3653
3654        // The capsule-wide block reaches capsule, tool and driver components, none of which
3655        // can be handed the murmur:task-io/read import. Rejected rather than ignored, on the
3656        // same terms as declaring it on a non-hook artifact entry.
3657        if raw
3658            .capabilities
3659            .as_ref()
3660            .is_some_and(|caps| caps.task_io.is_some())
3661        {
3662            return Err(RuntimeManifestError::InvalidCapabilities {
3663                field: "capabilities.task_io".to_string(),
3664                message: "is recognized only on 'runtime: hook' artifact entries, not in the \
3665                          capsule-wide capabilities block — the grant is per-hook"
3666                    .to_string(),
3667            });
3668        }
3669        // `filesystem.records` names a hook's preopen, and the capsule-wide block preopens nothing
3670        // for any hook. Refused rather than ignored, on the `task_io` block's terms.
3671        if raw
3672            .capabilities
3673            .as_ref()
3674            .and_then(|caps| caps.filesystem.as_ref())
3675            .is_some_and(|filesystem| filesystem.records)
3676        {
3677            return Err(RuntimeManifestError::InvalidCapabilities {
3678                field: "capabilities.filesystem.records".to_string(),
3679                message: "is recognized only on 'runtime: hook' artifact entries, not in the \
3680                          capsule-wide capabilities block — the grant is per-hook"
3681                    .to_string(),
3682            });
3683        }
3684        // Config is delivered on one artifact's own grant, so there is no capsule-wide form of
3685        // it: the capsule's guest holds no artifact grant, and a top-level block would reach no
3686        // component at all. Refused rather than ignored, on the same terms as the capsule-wide
3687        // `task_io` block above.
3688        if raw.config.is_some() {
3689            return Err(RuntimeManifestError::InvalidCapabilities {
3690                field: "config".to_string(),
3691                message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
3692                          'runtime: driver' entry that reads it — there is no capsule-wide \
3693                          config block"
3694                    .to_string(),
3695            });
3696        }
3697        let capabilities = parse_capabilities(raw.capabilities)?;
3698        let inference = parse_inference(raw.inference)?;
3699        refuse_install_under_process_transport(capabilities.as_ref(), inference.as_ref())?;
3700        validate_driver_gateways(&artifacts, inference.as_ref())?;
3701        validate_driver_choices(&artifacts, inference.as_ref())?;
3702        validate_process_driver_entry(&artifacts, inference.as_ref())?;
3703
3704        // Validate system_prompt_artifact: must name a declared artifact whose payload may be
3705        // bound as the system prompt (`prompt_payload`, defaulted from the role when absent).
3706        if let Some(ref sp_art) = inference
3707            .as_ref()
3708            .and_then(|i| i.system_prompt_artifact.clone())
3709        {
3710            let matching = artifacts.iter().find(|a| &a.name == sp_art);
3711            match matching {
3712                None => {
3713                    return Err(RuntimeManifestError::InvalidInferenceConfig {
3714                        field: "inference.system_prompt_artifact".to_string(),
3715                        message: not_declared_message(&artifacts, sp_art),
3716                    });
3717                }
3718                Some(a) if !a.prompt_payload => {
3719                    return Err(RuntimeManifestError::InvalidInferenceConfig {
3720                        field: "inference.system_prompt_artifact".to_string(),
3721                        message: format!(
3722                            "artifact '{sp_art}' (runtime: {}) does not declare \
3723                             'prompt_payload: true', which system_prompt_artifact requires",
3724                            a.runtime.as_str()
3725                        ),
3726                    });
3727                }
3728                Some(_) => {} // valid
3729            }
3730        }
3731        let context = parse_context(raw.context)?;
3732        let observability = parse_observability(raw.observability);
3733        let trace = raw
3734            .trace
3735            .map(|raw_trace| {
3736                let capture = resolve_trace_capture(raw_trace.capture.as_deref())?;
3737                let retain = parse_trace_retain(raw_trace.retain)?;
3738                Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
3739            })
3740            .transpose()?;
3741        let network = raw
3742            .network
3743            .map(|n| {
3744                Ok::<_, RuntimeManifestError>(NetworkConfig {
3745                    internal_port: n.internal_port,
3746                    authentication: parse_network_authentication(n.authentication)?,
3747                })
3748            })
3749            .transpose()?;
3750        refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
3751        let lifecycle = raw
3752            .lifecycle
3753            .map(|raw_lc| {
3754                let defaults = LifecycleConfig::default();
3755                let lifecycle = LifecycleConfig {
3756                    task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
3757                    after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
3758                    queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
3759                    input_timeout_secs: raw_lc.input_timeout_secs,
3760                    conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
3761                    max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
3762                    shell_grace_secs: raw_lc.shell_grace_secs.unwrap_or(defaults.shell_grace_secs),
3763                    delegation_deadline_secs: raw_lc
3764                        .delegation_deadline_secs
3765                        .unwrap_or(defaults.delegation_deadline_secs),
3766                };
3767                if lifecycle.queue_has_room() {
3768                    Ok(lifecycle)
3769                } else {
3770                    Err(RuntimeManifestError::QueueWithoutRoom)
3771                }
3772            })
3773            .transpose()?;
3774
3775        let exports = parse_exports(raw.exports)?;
3776        let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
3777        validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
3778
3779        Ok(Self {
3780            name,
3781            version,
3782            artifacts,
3783            capabilities,
3784            inference,
3785            context,
3786            observability,
3787            trace,
3788            network,
3789            lifecycle,
3790            exports,
3791            control,
3792            deploy: raw.deploy.map(|deploy| DeployConfig {
3793                mur_version: deploy.mur_version.filter(|s| !s.trim().is_empty()),
3794            }),
3795            unknown_keys,
3796        })
3797    }
3798}
3799
3800/// Every artifact entry whose `gateway.api_key` is written as `${NAME}`, as `(artifact, NAME)`.
3801fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
3802    artifacts
3803        .iter()
3804        .filter_map(|artifact| {
3805            let name = artifact.name.as_deref()?.trim();
3806            let written = artifact
3807                .gateway
3808                .as_ref()?
3809                .as_ref()?
3810                .api_key
3811                .as_ref()?
3812                .as_ref()?;
3813            let variable = parse_env_reference(written.trim())?;
3814            Some((name.to_string(), variable.to_string()))
3815        })
3816        .collect()
3817}
3818
3819/// Lowers the top-level `control:` block. A block that declares nothing is no block.
3820///
3821/// `gateway_references` is every `(artifact, NAME)` pair an entry's `gateway.api_key: ${NAME}`
3822/// wrote. A secret must back one of them, and must not back the configured `transport: http`
3823/// driver's: that credential is resolved at staging and keys every turn, so a session launched
3824/// without it could make no inference call at all.
3825fn parse_control(
3826    raw: Option<RawControl>,
3827    gateway_references: &[(String, String)],
3828    inference: Option<&InferenceConfig>,
3829) -> Result<Option<ControlConfig>, RuntimeManifestError> {
3830    let Some(raw) = raw else {
3831        return Ok(None);
3832    };
3833    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
3834        field: field.to_string(),
3835        message,
3836    };
3837    let settings_written = control_string_list("control.settings", raw.settings)?;
3838    let secrets_written = control_string_list("control.secrets", raw.secrets)?;
3839    let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
3840    if settings_written.is_empty()
3841        && secrets_written.is_empty()
3842        && agent_settings_written.is_empty()
3843    {
3844        return Ok(None);
3845    }
3846    let Some(inference) = inference else {
3847        return Err(refuse(
3848            "control",
3849            "is declared on a capsule with no inference: block; the control surface is served on \
3850             an agent capsule's A2A listener, and a script capsule opens none"
3851                .to_string(),
3852        ));
3853    };
3854
3855    let mut settings = Vec::new();
3856    for entry in settings_written {
3857        let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
3858            let controllable: Vec<&str> = ControllableSetting::ALL
3859                .iter()
3860                .map(|setting| setting.wire_name())
3861                .collect();
3862            return Err(refuse(
3863                "control.settings",
3864                format!(
3865                    "'{entry}' is not a controllable setting; the controllable settings are: {}",
3866                    controllable.join(", ")
3867                ),
3868            ));
3869        };
3870        if settings.contains(&setting) {
3871            return Err(refuse(
3872                "control.settings",
3873                format!("'{entry}' is listed more than once"),
3874            ));
3875        }
3876        match setting {
3877            ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
3878                return Err(refuse(
3879                    "control.settings",
3880                    format!(
3881                        "'{entry}' is controllable only under inference.transport: http; a \
3882                         transport: {} harness sets its own output cap",
3883                        inference.transport
3884                    ),
3885                ));
3886            }
3887            ControllableSetting::InferenceMaxTokens => {}
3888            ControllableSetting::InferenceDriver => {
3889                check_driver_setting(inference).map_err(|message| {
3890                    refuse("control.settings", format!("'{entry}' {message}"))
3891                })?;
3892            }
3893        }
3894        settings.push(setting);
3895    }
3896
3897    let mut agent_settings = Vec::new();
3898    for entry in agent_settings_written {
3899        let Some(setting) = ControllableSetting::from_wire_name(&entry)
3900            .filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
3901        else {
3902            let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
3903                .iter()
3904                .map(|setting| setting.wire_name())
3905                .collect();
3906            return Err(refuse(
3907                "control.agent_settings",
3908                format!(
3909                    "'{entry}' is not a setting the agent may change; control.agent_settings \
3910                     accepts only: {}",
3911                    settable.join(", ")
3912                ),
3913            ));
3914        };
3915        if agent_settings.contains(&setting) {
3916            return Err(refuse(
3917                "control.agent_settings",
3918                format!("'{entry}' is listed more than once"),
3919            ));
3920        }
3921        check_driver_setting(inference)
3922            .map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
3923        agent_settings.push(setting);
3924    }
3925
3926    let inference_driver = (inference.transport == "http")
3927        .then(|| {
3928            inference
3929                .driver
3930                .as_ref()
3931                .map(|driver| driver.artifact.as_str())
3932        })
3933        .flatten();
3934    let mut secrets: Vec<String> = Vec::new();
3935    for entry in secrets_written {
3936        if !is_valid_env_variable(&entry) {
3937            return Err(refuse(
3938                "control.secrets",
3939                format!(
3940                    "'{entry}' is not a credential name; a name is uppercase letters, digits and \
3941                     underscores, and does not start with a digit"
3942                ),
3943            ));
3944        }
3945        if secrets.contains(&entry) {
3946            return Err(refuse(
3947                "control.secrets",
3948                format!("'{entry}' is listed more than once"),
3949            ));
3950        }
3951        let backing: Vec<&str> = gateway_references
3952            .iter()
3953            .filter(|(_, variable)| *variable == entry)
3954            .map(|(artifact, _)| artifact.as_str())
3955            .collect();
3956        if backing.is_empty() {
3957            return Err(refuse(
3958                "control.secrets",
3959                format!(
3960                    "'{entry}' is not referenced by any artifact's gateway.api_key as \
3961                     ${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
3962                ),
3963            ));
3964        }
3965        if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
3966            return Err(refuse(
3967                "control.secrets",
3968                format!(
3969                    "'{entry}' keys the configured inference driver's gateway \
3970                     (artifacts.{driver}.gateway.api_key); the inference credential is resolved \
3971                     at launch and cannot be supplied by a controller"
3972                ),
3973            ));
3974        }
3975        secrets.push(entry);
3976    }
3977
3978    Ok(Some(ControlConfig {
3979        settings,
3980        secrets,
3981        agent_settings,
3982    }))
3983}
3984
3985/// Why `inference.driver` cannot be listed for `inference`, or `Ok` when it can: only under
3986/// `transport: http`, and only with at least one alternate to select.
3987fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
3988    if inference.transport != "http" {
3989        return Err(format!(
3990            "is controllable only under inference.transport: http; a transport: {} harness owns \
3991             the model it runs",
3992            inference.transport
3993        ));
3994    }
3995    if inference.alternates.is_empty() {
3996        return Err(
3997            "selects among inference.alternates, and this capsule declares none; declare an \
3998             alternate to switch to"
3999                .to_string(),
4000        );
4001    }
4002    Ok(())
4003}
4004
4005/// `value` as a list of strings, trimmed, for `field`. Absent and YAML null are the empty list.
4006fn control_string_list(
4007    field: &str,
4008    value: Option<serde_yaml::Value>,
4009) -> Result<Vec<String>, RuntimeManifestError> {
4010    let refuse = || RuntimeManifestError::InvalidControl {
4011        field: field.to_string(),
4012        message: "must be a list of names".to_string(),
4013    };
4014    let items = match value {
4015        None | Some(serde_yaml::Value::Null) => return Ok(Vec::new()),
4016        Some(serde_yaml::Value::Sequence(items)) => items,
4017        Some(_) => return Err(refuse()),
4018    };
4019    items
4020        .into_iter()
4021        .map(|item| match item {
4022            serde_yaml::Value::String(name) => Ok(name.trim().to_string()),
4023            _ => Err(refuse()),
4024        })
4025        .collect()
4026}
4027
4028/// Lowers `network.authentication`. An absent or null block is a public door.
4029fn parse_network_authentication(
4030    raw: Option<RawNetworkAuthentication>,
4031) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
4032    let Some(raw) = raw else {
4033        return Ok(None);
4034    };
4035    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
4036        field: field.to_string(),
4037        message,
4038    };
4039    let schemes: Vec<&str> = AuthenticationScheme::ALL
4040        .iter()
4041        .map(|scheme| scheme.wire_name())
4042        .collect();
4043    let scheme = match raw.scheme.as_deref().map(str::trim) {
4044        None | Some("") => {
4045            return Err(refuse(
4046                "network.authentication.scheme",
4047                format!("is required; the schemes are: {}", schemes.join(", ")),
4048            ))
4049        }
4050        Some(written) => AuthenticationScheme::ALL
4051            .iter()
4052            .copied()
4053            .find(|scheme| scheme.wire_name() == written)
4054            .ok_or_else(|| {
4055                refuse(
4056                    "network.authentication.scheme",
4057                    format!(
4058                        "'{written}' is not a door authentication scheme; the schemes are: {}",
4059                        schemes.join(", ")
4060                    ),
4061                )
4062            })?,
4063    };
4064
4065    let mut credentials = Vec::new();
4066    for (name, credential) in raw.credentials.unwrap_or_default() {
4067        if name == OPERATOR_CREDENTIAL {
4068            return Err(refuse(
4069                "network.authentication.credentials",
4070                format!(
4071                    "'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
4072                     token, which holds every scope"
4073                ),
4074            ));
4075        }
4076        if !is_valid_credential_name(&name) {
4077            return Err(refuse(
4078                "network.authentication.credentials",
4079                format!(
4080                    "'{name}' is not a credential name; a name is a lowercase letter followed by \
4081                     at most {} lowercase letters, digits, '_' or '-'",
4082                    MAX_CREDENTIAL_NAME_LEN - 1
4083                ),
4084            ));
4085        }
4086        let field = format!("network.authentication.credentials.{name}.scopes");
4087        let scopes = door_scope_list(&field, credential.scopes)?;
4088        credentials.push(DoorCredential { name, scopes });
4089    }
4090
4091    Ok(Some(NetworkAuthentication {
4092        scheme,
4093        credentials,
4094    }))
4095}
4096
4097/// `^[a-z][a-z0-9_-]{0,31}$`.
4098fn is_valid_credential_name(name: &str) -> bool {
4099    let mut chars = name.chars();
4100    chars.next().is_some_and(|first| first.is_ascii_lowercase())
4101        && name.len() <= MAX_CREDENTIAL_NAME_LEN
4102        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
4103}
4104
4105/// One credential's `scopes`: required, non-empty, each in [`DOOR_SCOPES`] and listed once.
4106fn door_scope_list(
4107    field: &str,
4108    value: Option<serde_yaml::Value>,
4109) -> Result<Vec<String>, RuntimeManifestError> {
4110    let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
4111        field: field.to_string(),
4112        message,
4113    };
4114    let items = match value {
4115        None | Some(serde_yaml::Value::Null) => {
4116            return Err(refuse(
4117                "is required: a credential reaches only the scopes it lists".to_string(),
4118            ))
4119        }
4120        Some(serde_yaml::Value::Sequence(items)) => items,
4121        Some(_) => return Err(refuse("must be a list of scopes".to_string())),
4122    };
4123    if items.is_empty() {
4124        return Err(refuse(
4125            "lists no scope; a credential must reach at least one".to_string(),
4126        ));
4127    }
4128    let mut scopes: Vec<String> = Vec::new();
4129    for item in items {
4130        let serde_yaml::Value::String(scope) = item else {
4131            return Err(refuse("must be a list of scopes".to_string()));
4132        };
4133        let scope = scope.trim().to_string();
4134        if let Some(&(_, replacement)) = RETIRED_DOOR_SCOPES
4135            .iter()
4136            .find(|(retired, _)| *retired == scope)
4137        {
4138            return Err(RuntimeManifestError::RetiredDoorScope {
4139                field: field.to_string(),
4140                scope,
4141                replacement,
4142            });
4143        }
4144        if !DOOR_SCOPES.contains(&scope.as_str()) {
4145            return Err(refuse(format!(
4146                "'{scope}' is not a door scope; the scopes are: {}",
4147                DOOR_SCOPES.join(", ")
4148            )));
4149        }
4150        if scopes.contains(&scope) {
4151            return Err(refuse(format!("'{scope}' is listed more than once")));
4152        }
4153        scopes.push(scope);
4154    }
4155    Ok(scopes)
4156}
4157
4158/// Refuses `network.authentication` on a capsule that declares `capabilities.spawn.allow`.
4159///
4160/// A delegated child posts its completion to its parent's door, and nothing issues a child a token
4161/// for that door, so an authenticated parent would refuse every completion it asked for.
4162fn refuse_authenticated_delegating_parent(
4163    network: Option<&NetworkConfig>,
4164    capabilities: Option<&Capabilities>,
4165) -> Result<(), RuntimeManifestError> {
4166    let authenticated = network.is_some_and(|network| network.authentication.is_some());
4167    let delegates = capabilities
4168        .and_then(|capabilities| capabilities.spawn.as_ref())
4169        .is_some_and(|spawn| !spawn.allow.is_empty());
4170    if authenticated && delegates {
4171        return Err(RuntimeManifestError::InvalidNetworkConfig {
4172            field: "network.authentication".to_string(),
4173            message: "cannot be declared together with capabilities.spawn.allow: a delegated \
4174                      child posts its outcome to this door, and a delegated child cannot yet be \
4175                      issued a token for this door"
4176                .to_string(),
4177        });
4178    }
4179    Ok(())
4180}
4181
4182/// Lowers the top-level `exports:` block, rejecting anything the runtime would otherwise have to
4183/// decide about later.
4184///
4185/// The `root` rules are checked here and never again by a caller: a gateway that "cleans up" a
4186/// path is a gateway that has taken over the containment boundary, so the only legal answer to a
4187/// root naming somewhere outside the workdir is a refusal.
4188fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
4189    let Some(raw_exports) = raw else {
4190        return Ok(None);
4191    };
4192    let files = raw_exports.files.map(parse_file_export).transpose()?;
4193    let peer_files = raw_exports
4194        .peer_files
4195        .map(parse_peer_files_export)
4196        .transpose()?;
4197    let peer_tasks = raw_exports
4198        .peer_tasks
4199        .map(|raw| {
4200            raw.accept
4201                .map(|accept| PeerTasksExport { accept })
4202                .ok_or_else(|| RuntimeManifestError::InvalidExports {
4203                    field: "exports.peer_tasks.accept".to_string(),
4204                    message: "must be set explicitly to true or false — consent to peer tasks is \
4205                              never inferred"
4206                        .to_string(),
4207                })
4208        })
4209        .transpose()?;
4210    Ok(Some(Exports {
4211        files,
4212        peer_files,
4213        peer_tasks,
4214    }))
4215}
4216
4217/// Lowers `exports.peer_files`, on exactly the same terms as [`parse_file_export`]: a root that
4218/// could name somewhere outside the workdir is refused here and never repaired later.
4219///
4220/// `max_ttl` is left as the operator declared it, including absent. Whether absent is legal
4221/// depends on `lifecycle.after_task`, which is a different block of the same manifest and a
4222/// different kind of question — one about what survives teardown rather than about whether a
4223/// value is well-formed. The runtime asks it, at launch, and refuses with `E-CAP-008`.
4224fn parse_peer_files_export(
4225    raw: RawPeerFilesExport,
4226) -> Result<PeerFilesExport, RuntimeManifestError> {
4227    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4228        field: field.to_string(),
4229        message,
4230    };
4231
4232    let root = raw
4233        .root
4234        .map(|root| root.trim().to_string())
4235        .filter(|root| !root.is_empty())
4236        .ok_or_else(|| {
4237            invalid(
4238                "exports.peer_files.root",
4239                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4240            )
4241        })?;
4242    check_export_root_shape("exports.peer_files.root", &root)?;
4243
4244    let max_ttl_secs = match raw.max_ttl {
4245        None => None,
4246        Some(value) => {
4247            let text = scalar_text(&value).ok_or_else(|| {
4248                invalid(
4249                    "exports.peer_files.max_ttl",
4250                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
4251                )
4252            })?;
4253            let parsed = parse_duration_secs(&text)
4254                .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
4255            if parsed == 0 {
4256                return Err(invalid(
4257                    "exports.peer_files.max_ttl",
4258                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
4259                ));
4260            }
4261            Some(parsed)
4262        }
4263    };
4264
4265    let max_bytes = match raw.max_bytes {
4266        None => DEFAULT_PEER_FILES_MAX_BYTES,
4267        Some(value) => {
4268            let text = scalar_text(&value).ok_or_else(|| {
4269                invalid(
4270                    "exports.peer_files.max_bytes",
4271                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4272                )
4273            })?;
4274            let parsed = parse_byte_size(&text)
4275                .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
4276            if parsed == 0 {
4277                return Err(invalid(
4278                    "exports.peer_files.max_bytes",
4279                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4280                ));
4281            }
4282            parsed
4283        }
4284    };
4285
4286    Ok(PeerFilesExport {
4287        root,
4288        max_ttl_secs,
4289        max_bytes,
4290    })
4291}
4292
4293/// A YAML scalar as the text the byte-size and duration parsers expect, or `None` for a mapping
4294/// or a sequence — a shape neither parser could report on without quoting the whole node.
4295/// `Some` for a key that is present, whatever its value — including an explicit null.
4296///
4297/// The plain `Option<serde_yaml::Value>` a `#[serde(default)]` field gets collapses `retain:`
4298/// with nothing under it into the same `None` an absent key produces, and those two say opposite
4299/// things: absent means "keep everything", and empty is refused.
4300fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_yaml::Value>, D::Error>
4301where
4302    D: serde::Deserializer<'de>,
4303{
4304    serde_yaml::Value::deserialize(deserializer).map(Some)
4305}
4306
4307fn scalar_text(value: &serde_yaml::Value) -> Option<String> {
4308    match value {
4309        serde_yaml::Value::String(text) => Some(text.clone()),
4310        serde_yaml::Value::Number(number) => Some(number.to_string()),
4311        _ => None,
4312    }
4313}
4314
4315/// The shared `root` shape rule: relative, no `..`, no absolute prefix. Both export blocks
4316/// resolve their root against the same accessible workdir, so both refuse the same shapes with
4317/// the same sentence.
4318fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
4319    for component in Path::new(root).components() {
4320        match component {
4321            std::path::Component::Normal(_) | std::path::Component::CurDir => {}
4322            std::path::Component::ParentDir => {
4323                return Err(RuntimeManifestError::InvalidExports {
4324                    field: field.to_string(),
4325                    message: format!(
4326                        "'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}"
4327                    ),
4328                });
4329            }
4330            std::path::Component::RootDir | std::path::Component::Prefix(_) => {
4331                return Err(RuntimeManifestError::InvalidExports {
4332                    field: field.to_string(),
4333                    message: format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}"),
4334                });
4335            }
4336        }
4337    }
4338    Ok(())
4339}
4340
4341fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
4342    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4343        field: field.to_string(),
4344        message,
4345    };
4346
4347    let root = raw
4348        .root
4349        .map(|root| root.trim().to_string())
4350        .filter(|root| !root.is_empty())
4351        .ok_or_else(|| {
4352            invalid(
4353                "exports.files.root",
4354                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4355            )
4356        })?;
4357    check_export_root_shape("exports.files.root", &root)?;
4358
4359    let mode = match raw.mode.as_deref().map(str::trim) {
4360        None | Some("") => {
4361            return Err(invalid(
4362                "exports.files.mode",
4363                "is required and must be 'read-only'".to_string(),
4364            ));
4365        }
4366        Some("read-only") => ExportMode::ReadOnly,
4367        Some(other) => {
4368            return Err(invalid(
4369                "exports.files.mode",
4370                format!("'{other}' must be 'read-only'"),
4371            ));
4372        }
4373    };
4374
4375    let max_bytes = match raw.max_bytes {
4376        None => DEFAULT_EXPORT_MAX_BYTES,
4377        Some(value) => {
4378            let Some(text) = scalar_text(&value) else {
4379                return Err(invalid(
4380                    "exports.files.max_bytes",
4381                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4382                ));
4383            };
4384            let parsed = parse_byte_size(&text)
4385                .map_err(|message| invalid("exports.files.max_bytes", message))?;
4386            if parsed == 0 {
4387                return Err(invalid(
4388                    "exports.files.max_bytes",
4389                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4390                ));
4391            }
4392            parsed
4393        }
4394    };
4395
4396    Ok(FileExport {
4397        root,
4398        mode,
4399        max_bytes,
4400    })
4401}
4402
4403/// The accepted spelling of `exports.files.root`, stated once so every rejection says the same
4404/// thing about what a legal root looks like.
4405const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
4406
4407/// The accepted spelling of `capabilities.peer_fetch.allow`, stated once.
4408pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
4409
4410/// Structural check on one network destination: an `http`/`https` URL with no path, query or
4411/// fragment, or a bare `host[:port]`.
4412///
4413/// Deliberately shape-only. The authoritative matcher is the runtime's network-rule parser, which
4414/// this crate cannot see; what this catches is the class of entry that could never become a rule
4415/// at all, at the line that wrote it rather than at launch.
4416fn check_network_destination_shape(entry: &str) -> Result<(), String> {
4417    let trimmed = entry.trim();
4418    if trimmed.is_empty() {
4419        return Err(format!(
4420            "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4421        ));
4422    }
4423
4424    if trimmed.contains("://") {
4425        let url = Url::parse(trimmed).map_err(|error| {
4426            format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
4427        })?;
4428        let scheme = url.scheme().to_ascii_lowercase();
4429        if scheme != "http" && scheme != "https" {
4430            return Err(format!(
4431                "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
4432                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4433            ));
4434        }
4435        if url.host_str().is_none_or(str::is_empty) {
4436            return Err(format!(
4437                "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4438            ));
4439        }
4440        if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
4441            return Err(format!(
4442                "'{entry}' must not include a path, query or fragment; \
4443                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4444            ));
4445        }
4446        return Ok(());
4447    }
4448
4449    if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
4450        return Err(format!(
4451            "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4452        ));
4453    }
4454    // Split from the right so an IPv6 literal's own colons are not mistaken for a port separator.
4455    if let Some((host, port)) = trimmed.rsplit_once(':') {
4456        if !host.ends_with(']') {
4457            if host.is_empty() {
4458                return Err(format!(
4459                    "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4460                ));
4461            }
4462            if port.parse::<u16>().is_err() {
4463                return Err(format!(
4464                    "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4465                ));
4466            }
4467        }
4468    }
4469    Ok(())
4470}
4471
4472fn parse_capabilities(
4473    raw: Option<RawCapabilities>,
4474) -> Result<Option<Capabilities>, RuntimeManifestError> {
4475    let Some(raw_caps) = raw else {
4476        return Ok(None);
4477    };
4478
4479    let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
4480        allow: raw_network.allow,
4481        unix_sockets: raw_network.unix_sockets,
4482    });
4483
4484    let peer_fetch = raw_caps
4485        .peer_fetch
4486        .map(|raw_peer_fetch| {
4487            if raw_peer_fetch.allow.is_empty() {
4488                return Err(RuntimeManifestError::InvalidCapabilities {
4489                    field: "capabilities.peer_fetch.allow".to_string(),
4490                    message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
4491                });
4492            }
4493            for entry in &raw_peer_fetch.allow {
4494                check_network_destination_shape(entry).map_err(|message| {
4495                    RuntimeManifestError::InvalidCapabilities {
4496                        field: "capabilities.peer_fetch.allow".to_string(),
4497                        message,
4498                    }
4499                })?;
4500            }
4501            Ok(PeerFetchCapabilities {
4502                allow: raw_peer_fetch.allow,
4503            })
4504        })
4505        .transpose()?;
4506
4507    let filesystem = raw_caps
4508        .filesystem
4509        .map(|raw_filesystem| FilesystemCapabilities {
4510            scope: raw_filesystem.scope.and_then(|scope| {
4511                let trimmed = scope.trim();
4512                (!trimmed.is_empty()).then(|| trimmed.to_string())
4513            }),
4514            // Copied straight across: a bool has no empty/whitespace case to normalise, and there
4515            // is nothing to validate here — the *consequence* of declaring it (no `scoped`, and a
4516            // refusal when `capabilities.containment: scoped` is also declared) belongs to the
4517            // runtime's containment layer, not to manifest parsing.
4518            workdir_exec: raw_filesystem.workdir_exec,
4519            // Normalised on exactly the terms `scope` above is: trimmed, and an entry left empty
4520            // by that trim is dropped rather than carried as a rule covering nothing. Shape is
4521            // not judged here either — an absolute or escaping entry is the runtime's refusal.
4522            read_only: raw_filesystem
4523                .read_only
4524                .into_iter()
4525                .filter_map(|entry| {
4526                    let trimmed = entry.trim();
4527                    (!trimmed.is_empty()).then(|| trimmed.to_string())
4528                })
4529                .collect(),
4530            // Where it is recognized is decided by the caller, which knows the entry's role.
4531            records: raw_filesystem.records,
4532        });
4533
4534    let shell = raw_caps
4535        .shell
4536        .map(|raw_shell| {
4537            if raw_shell.allow.is_empty() {
4538                return Err(RuntimeManifestError::InvalidCapabilities {
4539                    field: "capabilities.shell.allow".to_string(),
4540                    message: "must contain at least one binary".to_string(),
4541                });
4542            }
4543            for (index, entry) in raw_shell.allow.iter().enumerate() {
4544                if let Some(reason) = shell_allow_entry_error(entry) {
4545                    return Err(RuntimeManifestError::ShellAllowEntry {
4546                        index,
4547                        entry: entry.clone(),
4548                        reason,
4549                    });
4550                }
4551            }
4552
4553            let interpreter_runtime =
4554                parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
4555            // Parsed after `interpreter_runtime` because the mutual-exclusion rule needs the
4556            // already-validated grant list to test against.
4557            let staged_runtime = parse_staged_runtime(
4558                &raw_shell.allow,
4559                &interpreter_runtime,
4560                raw_shell.staged_runtime,
4561            )?;
4562
4563            Ok(ShellCapabilities {
4564                allow: raw_shell.allow,
4565                strip_env: raw_shell.strip_env,
4566                baseline_env: raw_shell.baseline_env,
4567                interpreter_runtime,
4568                staged_runtime,
4569            })
4570        })
4571        .transpose()?;
4572
4573    let spawn = raw_caps
4574        .spawn
4575        .map(|raw_spawn| parse_spawn_allow(raw_spawn.allow))
4576        .transpose()?;
4577
4578    let env = raw_caps.env.map(|raw_env| EnvCapabilities {
4579        allow: raw_env.allow,
4580    });
4581
4582    let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
4583
4584    let resources = raw_caps
4585        .resources
4586        .map(parse_resource_capabilities)
4587        .transpose()?;
4588
4589    let state = raw_caps.state.map(parse_state_capabilities);
4590
4591    let task_io = raw_caps
4592        .task_io
4593        .map(|raw_task_io| {
4594            raw_task_io
4595                .read
4596                .map(|read| TaskIoCapabilities { read })
4597                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4598                    field: "capabilities.task_io.read".to_string(),
4599                    message: "must be set explicitly to true or false — a capability is never \
4600                              inferred"
4601                        .to_string(),
4602                })
4603        })
4604        .transpose()?;
4605
4606    let conversation = raw_caps
4607        .conversation
4608        .map(|raw_conversation| {
4609            raw_conversation
4610                .read
4611                .map(|read| ConversationCapabilities { read })
4612                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4613                    field: "capabilities.conversation.read".to_string(),
4614                    message: "must be set explicitly to true or false — a capability is never \
4615                              inferred"
4616                        .to_string(),
4617                })
4618        })
4619        .transpose()?;
4620
4621    let plan = raw_caps
4622        .plan
4623        .map(|raw_plan| {
4624            raw_plan
4625                .submit
4626                .map(|submit| PlanCapabilities { submit })
4627                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4628                    field: "capabilities.plan.submit".to_string(),
4629                    message: "must be set explicitly to true or false — a capability is never \
4630                              inferred"
4631                        .to_string(),
4632                })
4633        })
4634        .transpose()?;
4635
4636    let install = raw_caps.install.map(parse_install).transpose()?;
4637
4638    let containment = raw_caps
4639        .containment
4640        .as_deref()
4641        .map(str::trim)
4642        .filter(|value| !value.is_empty())
4643        .map(|value| {
4644            value.parse::<ContainmentClass>().map_err(|error| {
4645                RuntimeManifestError::InvalidCapabilities {
4646                    field: "capabilities.containment".to_string(),
4647                    message: error.to_string(),
4648                }
4649            })
4650        })
4651        .transpose()?;
4652
4653    Ok(Some(Capabilities {
4654        network,
4655        peer_fetch,
4656        filesystem,
4657        shell,
4658        spawn,
4659        env,
4660        limits,
4661        resources,
4662        state,
4663        task_io,
4664        conversation,
4665        plan,
4666        install,
4667        containment,
4668    }))
4669}
4670
4671/// Lower `capabilities.spawn.allow`: every entry a full name, no two sharing a bare part —
4672/// `delegate-task` offers the model the bare parts, so each must pick out one capsule.
4673fn parse_spawn_allow(allow: Vec<String>) -> Result<SpawnCapabilities, RuntimeManifestError> {
4674    let mut parsed: Vec<ArtifactName> = Vec::with_capacity(allow.len());
4675    for (index, entry) in allow.iter().enumerate() {
4676        let name = ArtifactName::parse(entry).map_err(|error| {
4677            RuntimeManifestError::InvalidArtifactName {
4678                field: format!("capabilities.spawn.allow[{index}]"),
4679                message: error.to_string(),
4680                hint: error.hint(),
4681            }
4682        })?;
4683        if let Some(earlier) = parsed
4684            .iter()
4685            .find(|earlier| earlier.name() == name.name() && **earlier != name)
4686        {
4687            return Err(RuntimeManifestError::InvalidArtifactName {
4688                field: format!("capabilities.spawn.allow[{index}]"),
4689                message: format!(
4690                    "'{earlier}' and '{name}' share the name '{}', which is how delegate-task \
4691                     offers a capsule to the model",
4692                    name.name()
4693                ),
4694                hint: Some(format!(
4695                    "keep one of '{earlier}' and '{name}' in capabilities.spawn.allow"
4696                )),
4697            });
4698        }
4699        parsed.push(name);
4700    }
4701    Ok(SpawnCapabilities { allow })
4702}
4703
4704/// Refuse an `artifacts[index].name` of the wrong form: a full name for an entry that resolves,
4705/// a bare name for a `source:` entry, which is never resolved or locked.
4706fn check_entry_name(
4707    index: usize,
4708    name: &str,
4709    has_source: bool,
4710) -> Result<(), RuntimeManifestError> {
4711    let field = format!("artifacts[{index}].name");
4712    if !has_source {
4713        return ArtifactName::parse(name).map(|_| ()).map_err(|error| {
4714            RuntimeManifestError::InvalidArtifactName {
4715                field,
4716                message: error.to_string(),
4717                hint: error.hint(),
4718            }
4719        });
4720    }
4721    if let Some((_, bare)) = name.split_once('/') {
4722        return Err(RuntimeManifestError::InvalidArtifactName {
4723            field,
4724            message: format!(
4725                "'{name}' declares 'source:', so it is named by its bare name; a scope says \
4726                 where a resolved artifact comes from, and a source: entry is never resolved"
4727            ),
4728            hint: Some(format!("drop the scope: name it '{bare}'")),
4729        });
4730    }
4731    match artifact_name_format_error(name) {
4732        Some(reason) => Err(RuntimeManifestError::InvalidArtifactName {
4733            field,
4734            message: format!("'{name}' is not a valid artifact name: it {reason}"),
4735            hint: None,
4736        }),
4737        None => Ok(()),
4738    }
4739}
4740
4741/// The refusal of a reference to an artifact `artifacts:` does not declare. When exactly one
4742/// declared entry has `name` as its bare part, the message names that entry, the reference the
4743/// operator most likely meant.
4744fn not_declared_message(artifacts: &[RuntimeArtifact], name: &str) -> String {
4745    let mut same_bare = artifacts
4746        .iter()
4747        .filter(|artifact| artifact.name != name && artifact.tool_name() == tool_name_of(name));
4748    match (same_bare.next(), same_bare.next()) {
4749        (Some(only), None) => format!(
4750            "artifact '{name}' is not declared in artifacts: (the declared '{}' has that name; \
4751             refer to it as '{}')",
4752            only.name, only.name
4753        ),
4754        _ => format!("artifact '{name}' is not declared in artifacts:"),
4755    }
4756}
4757
4758/// Lower `capabilities.install`, refusing a block that names nothing and any entry outside
4759/// [`crate::install_pattern`]'s grammar.
4760fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
4761    if raw.skill.is_empty() && raw.tool.is_empty() {
4762        return Err(RuntimeManifestError::InvalidCapabilities {
4763            field: "capabilities.install".to_string(),
4764            message: "must name at least one 'skill' or 'tool' entry — a capability is never \
4765                      inferred; omit the block to grant no install at all"
4766                .to_string(),
4767        });
4768    }
4769    for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
4770        for (index, entry) in entries.iter().enumerate() {
4771            crate::install_pattern::validate(entry).map_err(|error| {
4772                RuntimeManifestError::InvalidArtifactName {
4773                    field: format!("capabilities.install.{key}[{index}]"),
4774                    message: format!(
4775                        "entry {entry:?} is invalid ({}); expected {}",
4776                        error.reason,
4777                        crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
4778                    ),
4779                    hint: error.hint,
4780                }
4781            })?;
4782        }
4783    }
4784    let from = match raw.from {
4785        None => InstallFrom::Stores,
4786        Some(list) => parse_install_from(&list)?,
4787    };
4788    Ok(InstallCapabilities {
4789        skill: raw.skill,
4790        tool: raw.tool,
4791        from,
4792    })
4793}
4794
4795/// Refuse `capabilities.install` on a `transport: process` capsule: the pull and remove tools are
4796/// offered through the http agent loop's tool array, which that transport never sends.
4797fn refuse_install_under_process_transport(
4798    capabilities: Option<&Capabilities>,
4799    inference: Option<&InferenceConfig>,
4800) -> Result<(), RuntimeManifestError> {
4801    let installs = capabilities.is_some_and(|capabilities| capabilities.install.is_some());
4802    let process = inference.is_some_and(|inference| inference.transport == "process");
4803    if installs && process {
4804        return Err(RuntimeManifestError::InvalidCapabilities {
4805            field: "capabilities.install".to_string(),
4806            message: "is not valid with transport: process; the harness owns its tool list, so a \
4807                      pulled artifact could never reach it"
4808                .to_string(),
4809        });
4810    }
4811    Ok(())
4812}
4813
4814/// The values `capabilities.install.from` accepts, as its refusals name them.
4815const INSTALL_FROM_ACCEPTED: &str = "[stores] or [stores, sources]";
4816
4817/// Lower `capabilities.install.from`: `stores` is required, `sources` optional, each at most
4818/// once and in either order.
4819fn parse_install_from(list: &[String]) -> Result<InstallFrom, RuntimeManifestError> {
4820    let refuse = |message: String| RuntimeManifestError::InvalidCapabilities {
4821        field: "capabilities.install.from".to_string(),
4822        message: format!("{message}; expected {INSTALL_FROM_ACCEPTED}"),
4823    };
4824    if list.is_empty() {
4825        return Err(refuse("is empty".to_string()));
4826    }
4827    let (mut stores, mut sources) = (false, false);
4828    for entry in list {
4829        let seen = match entry.as_str() {
4830            "stores" => &mut stores,
4831            "sources" => &mut sources,
4832            other => return Err(refuse(format!("names {other:?}, which is not a place"))),
4833        };
4834        if *seen {
4835            return Err(refuse(format!("names {entry:?} twice")));
4836        }
4837        *seen = true;
4838    }
4839    if !stores {
4840        return Err(refuse(
4841            "does not name stores, which every pull is answered from first".to_string(),
4842        ));
4843    }
4844    Ok(if sources {
4845        InstallFrom::StoresAndSources
4846    } else {
4847        InstallFrom::Stores
4848    })
4849}
4850
4851/// Lower `capabilities.state`. Infallible: the only field is a name, and a name's *shape* is a
4852/// runtime question (does it resolve to one directory under `~/.murmur/state/`?) rather than a
4853/// manifest-parse one — `capsule_runtime::state_store::validate_store_name` answers it and refuses
4854/// with `E-CAP-009`.
4855///
4856/// Surrounding whitespace is trimmed but an emptied value is *kept*, unlike
4857/// `capabilities.filesystem.scope`, which normalises empty to absent. `store: ""` is a written
4858/// declaration that names nothing, and silently defaulting it to the capsule name would hand a
4859/// store to someone who asked for a different one; it reaches the runtime and is refused there.
4860fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
4861    StateCapabilities {
4862        store: raw.store.map(|store| store.trim().to_string()),
4863    }
4864}
4865
4866/// Why `entry` is not a `capabilities.shell.allow` entry, which must be one path component: the
4867/// binary's name, looked up on `PATH`, and the name of the tool the model calls. `None` for a
4868/// bare binary name.
4869fn shell_allow_entry_error(entry: &str) -> Option<&'static str> {
4870    if entry.is_empty() {
4871        Some("is empty")
4872    } else if entry.contains('\0') {
4873        Some("contains a NUL byte")
4874    } else if entry.contains('/') {
4875        Some("is a path")
4876    } else if entry == "." || entry == ".." {
4877        Some("names a directory")
4878    } else if entry.chars().any(char::is_whitespace) {
4879        Some("contains whitespace")
4880    } else {
4881        None
4882    }
4883}
4884
4885/// Lower and validate `capabilities.shell.interpreter_runtime`. Every rejection is an
4886/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending value, matching the rest
4887/// of this file's "structurally present but semantically invalid" reporting. Nothing here can
4888/// expand a whole install prefix: each grant names specific directories, and each directory
4889/// carries an explicit `list_dir` the author had to write — enumerability is never inferred.
4890fn parse_interpreter_runtime(
4891    allow: &[String],
4892    raw_grants: Vec<RawInterpreterRuntimeGrant>,
4893) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
4894    let mut grants = Vec::with_capacity(raw_grants.len());
4895    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
4896        let base = format!("capabilities.shell.interpreter_runtime[{index}]");
4897
4898        let binary = raw_grant
4899            .binary
4900            .filter(|b| !b.trim().is_empty())
4901            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4902                field: format!("{base}.binary"),
4903                message: "must name a binary".to_string(),
4904            })?;
4905
4906        // The whole point of this mechanism: it narrows filesystem access alongside an exec
4907        // grant that already exists. It can never itself grant exec, so the binary must already
4908        // be allowlisted in this same block's `allow`.
4909        if !allow.contains(&binary) {
4910            return Err(RuntimeManifestError::InvalidCapabilities {
4911                field: format!("{base}.binary"),
4912                message: format!(
4913                    "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
4914                     only narrow filesystem access for an already-allowlisted binary, never \
4915                     grant exec"
4916                ),
4917            });
4918        }
4919
4920        if raw_grant.dirs.is_empty() {
4921            return Err(RuntimeManifestError::InvalidCapabilities {
4922                field: format!("{base}.dirs"),
4923                message: "must name at least one directory".to_string(),
4924            });
4925        }
4926
4927        let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
4928        for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
4929            let dir_base = format!("{base}.dirs[{dir_index}]");
4930
4931            let path = raw_dir
4932                .path
4933                .filter(|p| !p.trim().is_empty())
4934                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4935                    field: format!("{dir_base}.path"),
4936                    message: "must name a host directory".to_string(),
4937                })?;
4938            if !path.starts_with('/') {
4939                return Err(RuntimeManifestError::InvalidCapabilities {
4940                    field: format!("{dir_base}.path"),
4941                    message: format!(
4942                        "'{path}' must be an absolute host path (start with '/') — these are \
4943                         filesystem paths outside the workdir"
4944                    ),
4945                });
4946            }
4947
4948            let list_dir =
4949                raw_dir
4950                    .list_dir
4951                    .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4952                        field: format!("{dir_base}.list_dir"),
4953                        message: format!(
4954                            "'{path}' must set list_dir explicitly to true or false — \
4955                         enumerability is never inferred"
4956                        ),
4957                    })?;
4958
4959            dirs.push(InterpreterRuntimeDir { path, list_dir });
4960        }
4961
4962        grants.push(InterpreterRuntimeGrant { binary, dirs });
4963    }
4964
4965    Ok(grants)
4966}
4967
4968/// Lower and validate `capabilities.shell.staged_runtime`, given this same block's `allow` list
4969/// and its already-validated `interpreter_runtime` grants. Every rejection is a
4970/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending field, matching
4971/// [`parse_interpreter_runtime`] and the rest of this file.
4972///
4973/// Four rules, each of them a thing that would otherwise be silently wrong at launch rather than
4974/// loudly wrong here:
4975///
4976///   1. `binary` must already be in `allow` — staging a runtime tree never itself grants exec.
4977///   2. `binary` must not also carry an `interpreter_runtime` grant — the two are alternatives,
4978///      and declaring both means the author expects a host-widening grant that a composed root
4979///      makes both unnecessary and (once Landlock re-installs inside the root) meaningless.
4980///   3. `source_path` must be absolute — it is a host path outside the workdir, resolved by the
4981///      launch host and not relative to anything the runtime knows.
4982///   4. `pin` must be present and non-empty — it is the value a human compares across two hosts,
4983///      so an absent one defeats the field's only purpose.
4984///
4985/// Nothing here touches the filesystem: whether `source_path` exists on *this* host is a launch
4986/// -time fact, not a manifest-validity one, and a manifest must stay parseable on a machine that
4987/// will never run it (`mur build` on a laptop for a Linux fleet).
4988fn parse_staged_runtime(
4989    allow: &[String],
4990    interpreter_runtime: &[InterpreterRuntimeGrant],
4991    raw_grants: Vec<RawStagedRuntimeGrant>,
4992) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
4993    let mut grants = Vec::with_capacity(raw_grants.len());
4994    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
4995        let base = format!("capabilities.shell.staged_runtime[{index}]");
4996
4997        let binary = raw_grant
4998            .binary
4999            .filter(|b| !b.trim().is_empty())
5000            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5001                field: format!("{base}.binary"),
5002                message: "must name a binary".to_string(),
5003            })?;
5004
5005        if !allow.contains(&binary) {
5006            return Err(RuntimeManifestError::InvalidCapabilities {
5007                field: format!("{base}.binary"),
5008                message: format!(
5009                    "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
5010                     stage the runtime tree behind an already-allowlisted binary, never grant exec"
5011                ),
5012            });
5013        }
5014
5015        if interpreter_runtime
5016            .iter()
5017            .any(|grant| grant.binary == binary)
5018        {
5019            return Err(RuntimeManifestError::InvalidCapabilities {
5020                field: format!("{base}.binary"),
5021                message: format!(
5022                    "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
5023                     are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
5024                     tree into the capsule's own root, which is what makes widening the capsule's \
5025                     host filesystem scope unnecessary rather than something to add to it. Remove \
5026                     the interpreter_runtime grant for '{binary}'"
5027                ),
5028            });
5029        }
5030
5031        let source_path = raw_grant
5032            .source_path
5033            .filter(|p| !p.trim().is_empty())
5034            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5035                field: format!("{base}.source_path"),
5036                message: "must name the host directory holding the pinned runtime tree".to_string(),
5037            })?;
5038        if !source_path.starts_with('/') {
5039            return Err(RuntimeManifestError::InvalidCapabilities {
5040                field: format!("{base}.source_path"),
5041                message: format!(
5042                    "'{source_path}' must be an absolute host path (start with '/') — \
5043                     staged-runtime sources are host filesystem paths outside the workdir"
5044                ),
5045            });
5046        }
5047
5048        let pin = raw_grant
5049            .pin
5050            .map(|p| p.trim().to_string())
5051            .filter(|p| !p.is_empty())
5052            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5053                field: format!("{base}.pin"),
5054                message: format!(
5055                    "'{source_path}' must declare a non-empty pin identifying which build this \
5056                     tree is — it is never inferred, because it is the value a human compares \
5057                     across two hosts to confirm the same runtime shipped to both"
5058                ),
5059            })?;
5060
5061        grants.push(StagedRuntimeGrant {
5062            binary,
5063            source_path,
5064            pin,
5065        });
5066    }
5067
5068    Ok(grants)
5069}
5070
5071/// Lower `capabilities.limits`, rejecting values that can only ever produce a guest that
5072/// traps on its first instruction. A zero cap is always a manifest authoring mistake, and
5073/// catching it here means `mur run` fails at parse time with a field name rather than
5074/// surfacing much later as an opaque wasm trap.
5075fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
5076    let reject_zero_usize =
5077        |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
5078            match value {
5079                Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5080                    field: format!("capabilities.limits.{field}"),
5081                    message: "must be greater than zero".to_string(),
5082                }),
5083                _ => Ok(()),
5084            }
5085        };
5086
5087    reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
5088    reject_zero_usize(raw.table_elements, "table_elements")?;
5089    reject_zero_usize(raw.instances, "instances")?;
5090    if raw.deadline_seconds == Some(0) {
5091        return Err(RuntimeManifestError::InvalidCapabilities {
5092            field: "capabilities.limits.deadline_seconds".to_string(),
5093            message: "must be greater than zero".to_string(),
5094        });
5095    }
5096
5097    Ok(ResourceLimits {
5098        memory_bytes: raw.memory_bytes,
5099        table_elements: raw.table_elements,
5100        instances: raw.instances,
5101        deadline_seconds: raw.deadline_seconds,
5102    })
5103}
5104
5105/// Lower and validate `capabilities.resources`. Mirrors [`parse_resource_limits`]: nothing is
5106/// defaulted here (the runtime owns the defaults, so "omitted" must stay distinguishable from
5107/// "declared"), and a declared `0` is rejected outright on every field — a zero ceiling is
5108/// never what an author means, and letting it through would turn a typo into a subprocess that
5109/// cannot fork, open a file, or run for a single CPU-second.
5110fn parse_resource_capabilities(
5111    raw: RawResourceCapabilities,
5112) -> Result<ResourceCapabilities, RuntimeManifestError> {
5113    let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
5114        match value {
5115            Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5116                field: format!("capabilities.resources.{field}"),
5117                message: "must be greater than zero".to_string(),
5118            }),
5119            _ => Ok(()),
5120        }
5121    };
5122
5123    reject_zero(raw.max_processes, "max_processes")?;
5124    reject_zero(raw.max_open_files, "max_open_files")?;
5125    reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
5126    reject_zero(raw.cpu_seconds, "cpu_seconds")?;
5127    reject_zero(raw.memory_bytes, "memory_bytes")?;
5128    reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
5129    reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
5130    reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
5131    reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
5132    reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
5133
5134    Ok(ResourceCapabilities {
5135        max_processes: raw.max_processes,
5136        max_open_files: raw.max_open_files,
5137        max_file_size_bytes: raw.max_file_size_bytes,
5138        cpu_seconds: raw.cpu_seconds,
5139        memory_bytes: raw.memory_bytes,
5140        cgroup_memory_bytes: raw.cgroup_memory_bytes,
5141        cgroup_pids_max: raw.cgroup_pids_max,
5142        cgroup_cpu_percent: raw.cgroup_cpu_percent,
5143        cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
5144        workdir_max_bytes: raw.workdir_max_bytes,
5145    })
5146}
5147
5148/// Why a `transport: process` manifest with no `inference.driver` is refused. Names one driver
5149/// artifact by way of example: this is the one message a reader reaches with nothing to go on, and
5150/// an abstract instruction to "name a process driver" leaves them no way to find one.
5151const PROCESS_DRIVER_REQUIRED: &str =
5152    "missing required field; a transport: process capsule must name the process driver that \
5153     knows how to drive its harness. inference.command no longer selects a harness — it only \
5154     overrides the binary the driver names. Add inference.driver.artifact naming a process \
5155     driver, for example murmur/driver-claude-code";
5156
5157fn parse_inference(
5158    raw: Option<RawInferenceConfig>,
5159) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
5160    let Some(raw) = raw else {
5161        return Ok(None);
5162    };
5163
5164    let transport = raw
5165        .transport
5166        .map(|s| s.trim().to_string())
5167        .filter(|s| !s.is_empty())
5168        .unwrap_or_else(|| "http".to_string());
5169
5170    let compaction = parse_compaction(raw.compaction)?;
5171    let system_prompt = optional_trimmed_string(raw.system_prompt);
5172    let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
5173    let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
5174
5175    let prompt_sources_set = [
5176        system_prompt.is_some(),
5177        system_prompt_file.is_some(),
5178        system_prompt_artifact.is_some(),
5179    ]
5180    .iter()
5181    .filter(|&&b| b)
5182    .count();
5183    if prompt_sources_set > 1 {
5184        return Err(RuntimeManifestError::InvalidInferenceConfig {
5185            field: "inference.system_prompt".to_string(),
5186            message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
5187                      may be set"
5188                .to_string(),
5189        });
5190    }
5191
5192    let max_turns = match raw.max_turns {
5193        None => 10,
5194        Some(0) => {
5195            return Err(RuntimeManifestError::InvalidInferenceConfig {
5196                field: "inference.max_turns".to_string(),
5197                message: "must be greater than 0".to_string(),
5198            });
5199        }
5200        Some(n) => n,
5201    };
5202
5203    // The reopen budget is a lifecycle knob, read from `lifecycle.max_task_reopens`. Nothing
5204    // here consumes the `inference` spelling, so it would be silently inert — reject it rather
5205    // than let it look effective.
5206    if raw.max_task_reopens.is_some() {
5207        return Err(RuntimeManifestError::InvalidInferenceConfig {
5208            field: "inference.max_task_reopens".to_string(),
5209            message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
5210                .to_string(),
5211        });
5212    }
5213
5214    // Left to the unknown-key pass, either field would only draw a warning and the capsule would
5215    // launch with no upstream or credential bound, so each is refused by name, pointing at the
5216    // driver entry's `gateway:` block. Nothing translates it.
5217    for (present, removed, moved_to) in [
5218        (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
5219        (raw.api_key.is_some(), "api_key", "gateway.api_key"),
5220    ] {
5221        if !present {
5222            continue;
5223        }
5224        let entry = raw
5225            .driver
5226            .as_ref()
5227            .and_then(|driver| driver.artifact.as_deref())
5228            .map(str::trim)
5229            .filter(|name| !name.is_empty())
5230            .map(|name| format!("on the artifacts: entry '{name}'"))
5231            .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
5232        return Err(RuntimeManifestError::InvalidInferenceConfig {
5233            field: format!("inference.{removed}"),
5234            message: format!("was removed; set {moved_to} {entry} instead"),
5235        });
5236    }
5237
5238    match transport.as_str() {
5239        "http" => {
5240            let model = required_inference_field(raw.model, "model")?;
5241
5242            let driver_raw =
5243                raw.driver
5244                    .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5245                        field: "inference.driver".to_string(),
5246                        message: "missing required field".to_string(),
5247                    })?;
5248            let driver = parse_inference_driver(driver_raw)?;
5249
5250            // Advisory only: catch an authoring typo at parse time rather than as a confusing
5251            // provider 400. Large values are deliberately NOT clamped — a ceiling here would
5252            // block a model whose real limit is higher than anything we could hard-code.
5253            if raw.max_tokens == Some(0) {
5254                return Err(RuntimeManifestError::InvalidInferenceConfig {
5255                    field: "inference.max_tokens".to_string(),
5256                    message: "must be greater than 0".to_string(),
5257                });
5258            }
5259            if raw.max_session_tokens == Some(0) {
5260                return Err(RuntimeManifestError::InvalidInferenceConfig {
5261                    field: "inference.max_session_tokens".to_string(),
5262                    message: "must be greater than 0".to_string(),
5263                });
5264            }
5265            let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
5266            let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
5267
5268            Ok(Some(InferenceConfig {
5269                transport,
5270                model,
5271                driver: Some(driver),
5272                command: None,
5273                compaction,
5274                system_prompt,
5275                system_prompt_file,
5276                system_prompt_artifact,
5277                max_turns,
5278                max_tokens: raw.max_tokens,
5279                max_session_tokens: raw.max_session_tokens,
5280                tool_refresh,
5281                alternates,
5282            }))
5283        }
5284        "process" => {
5285            // The CLI subprocess path never builds a driver payload, so this value would be
5286            // silently inert here — reject it rather than let it look effective.
5287            if raw.max_tokens.is_some() {
5288                return Err(RuntimeManifestError::InvalidInferenceConfig {
5289                    field: "inference.max_tokens".to_string(),
5290                    message: "is not valid with transport: process".to_string(),
5291                });
5292            }
5293            // The harness owns its tool list and runs its own compaction, so neither trigger has a
5294            // turn boundary here to act on.
5295            if raw.tool_refresh.is_some() {
5296                return Err(RuntimeManifestError::InvalidInferenceConfig {
5297                    field: "inference.tool_refresh".to_string(),
5298                    message: "is not valid with transport: process".to_string(),
5299                });
5300            }
5301            // The harness owns the conversation and the model it runs, so there is no agent-loop
5302            // call for a switch to reroute.
5303            if raw.alternates.is_some() {
5304                return Err(RuntimeManifestError::InvalidInferenceConfig {
5305                    field: "inference.alternates".to_string(),
5306                    message: "is not valid with transport: process; the harness owns the \
5307                              conversation and the model it runs"
5308                        .to_string(),
5309                });
5310            }
5311
5312            // Valid here: the harness's own reported token counts are what this transport's
5313            // turns are metered against, and a driver that reports none is refused at load
5314            // rather than run for ever beneath a ceiling nothing could reach.
5315            if raw.max_session_tokens == Some(0) {
5316                return Err(RuntimeManifestError::InvalidInferenceConfig {
5317                    field: "inference.max_session_tokens".to_string(),
5318                    message: "must be greater than 0".to_string(),
5319                });
5320            }
5321
5322            let driver = raw.driver.map(parse_inference_driver).transpose()?;
5323            let command = optional_trimmed_string(raw.command);
5324            // The driver is what knows how to drive a harness CLI: its flags, its output format,
5325            // its version probe. `command` only says which executable to run instead of the one
5326            // the driver names, so it selects nothing on its own.
5327            if driver.is_none() {
5328                return Err(RuntimeManifestError::InvalidInferenceConfig {
5329                    field: "inference.driver.artifact".to_string(),
5330                    message: PROCESS_DRIVER_REQUIRED.to_string(),
5331                });
5332            }
5333            // model is OPTIONAL for transport: process — an empty string leaves the choice of
5334            // model to the harness, which has an account default of its own. The driver is handed
5335            // `none` rather than an empty string, so it never passes a model flag with no value.
5336            let model = optional_trimmed_string(raw.model).unwrap_or_default();
5337
5338            Ok(Some(InferenceConfig {
5339                transport,
5340                model,
5341                driver,
5342                command,
5343                compaction,
5344                system_prompt,
5345                system_prompt_file,
5346                system_prompt_artifact,
5347                max_turns,
5348                max_tokens: None,
5349                max_session_tokens: raw.max_session_tokens,
5350                tool_refresh: ToolRefresh::default(),
5351                alternates: Vec::new(),
5352            }))
5353        }
5354        other => Err(RuntimeManifestError::InvalidInferenceConfig {
5355            field: "inference.transport".to_string(),
5356            message: format!("unknown value '{other}'"),
5357        }),
5358    }
5359}
5360
5361/// Whether `name` is an alternate's name: a lowercase letter, then up to 62 lowercase letters,
5362/// digits and hyphens.
5363fn is_valid_driver_choice_name(name: &str) -> bool {
5364    let mut chars = name.chars();
5365    chars.next().is_some_and(|first| first.is_ascii_lowercase())
5366        && name.len() <= 63
5367        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
5368}
5369
5370/// Lowers `inference.alternates` under `transport: http`, with the rules one entry and the list
5371/// can be judged on alone. What needs `artifacts:` is [`validate_driver_choices`]'s.
5372///
5373/// `primary_driver` and `primary_model` are the primary choice's pair, which no alternate may
5374/// repeat: two names for one pair would make a switch between them a change of nothing.
5375fn parse_alternates(
5376    raw: Option<Vec<RawInferenceAlternate>>,
5377    primary_driver: &str,
5378    primary_model: &str,
5379) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
5380    let mut alternates: Vec<InferenceAlternate> = Vec::new();
5381    for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
5382        let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5383            field: if key.is_empty() {
5384                format!("inference.alternates[{index}]")
5385            } else {
5386                format!("inference.alternates[{index}].{key}")
5387            },
5388            message,
5389        };
5390        let name = optional_trimmed_string(entry.name)
5391            .ok_or_else(|| refuse("name", "missing required field".to_string()))?;
5392        if name == PRIMARY_DRIVER_CHOICE {
5393            return Err(refuse(
5394                "name",
5395                format!(
5396                    "'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
5397                     inference.model; name the alternate something else"
5398                ),
5399            ));
5400        }
5401        if !is_valid_driver_choice_name(&name) {
5402            return Err(refuse(
5403                "name",
5404                format!(
5405                    "'{name}' is not a driver choice name; a name is a lowercase letter followed \
5406                     by up to 62 lowercase letters, digits and hyphens"
5407                ),
5408            ));
5409        }
5410        if alternates.iter().any(|alternate| alternate.name == name) {
5411            return Err(refuse(
5412                "name",
5413                format!("'{name}' names more than one alternate"),
5414            ));
5415        }
5416        let model = optional_trimmed_string(entry.model)
5417            .ok_or_else(|| refuse("model", "missing required field".to_string()))?;
5418        let Some(driver) = entry.driver else {
5419            return Err(refuse(
5420                "driver.artifact",
5421                "missing required field".to_string(),
5422            ));
5423        };
5424        if driver
5425            .config
5426            .as_ref()
5427            .is_some_and(|config| !matches!(config, serde_yaml::Value::Null))
5428        {
5429            return Err(refuse(
5430                "driver.config",
5431                "is not valid on an alternate; inference.driver.config applies to every \
5432                 declared driver, and per-driver settings belong in that driver's \
5433                 artifacts[].config"
5434                    .to_string(),
5435            ));
5436        }
5437        let driver = optional_trimmed_string(driver.artifact)
5438            .ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
5439        if driver == primary_driver && model == primary_model {
5440            return Err(refuse(
5441                "",
5442                format!(
5443                    "'{name}' is driver '{driver}' with model '{model}', which is the primary \
5444                     choice; an alternate names a different pair"
5445                ),
5446            ));
5447        }
5448        if let Some(twin) = alternates
5449            .iter()
5450            .find(|alternate| alternate.driver == driver && alternate.model == model)
5451        {
5452            return Err(refuse(
5453                "",
5454                format!(
5455                    "'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
5456                     already is; an alternate names a different pair",
5457                    twin.name
5458                ),
5459            ));
5460        }
5461        alternates.push(InferenceAlternate {
5462            name,
5463            model,
5464            driver,
5465        });
5466    }
5467    Ok(alternates)
5468}
5469
5470/// `inference.tool_refresh` under `transport: http`: absent is [`ToolRefresh::Compaction`].
5471fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
5472    let Some(value) = raw else {
5473        return Ok(ToolRefresh::default());
5474    };
5475    ToolRefresh::ALL
5476        .into_iter()
5477        .find(|refresh| refresh.wire_name() == value)
5478        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5479            field: "inference.tool_refresh".to_string(),
5480            message: format!(
5481                "must be one of: {}; got '{value}'",
5482                ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
5483            ),
5484        })
5485}
5486
5487fn parse_context(
5488    raw: Option<RawContextConfig>,
5489) -> Result<Option<ContextConfig>, RuntimeManifestError> {
5490    let Some(raw) = raw else {
5491        return Ok(None);
5492    };
5493
5494    if let Some(max_tokens) = raw.max_tokens {
5495        if max_tokens == 0 {
5496            return Err(RuntimeManifestError::InvalidInferenceConfig {
5497                field: "context.max_tokens".to_string(),
5498                message: "must be greater than 0".to_string(),
5499            });
5500        }
5501    }
5502
5503    let fraction = |field: &str, value: Option<f32>, default: f32| match value {
5504        None => Ok(default),
5505        Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
5506        Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
5507            field: field.to_string(),
5508            message: "must be between 0.0 and 1.0".to_string(),
5509        }),
5510    };
5511
5512    let record = match raw.record.as_deref().map(str::trim) {
5513        None => true,
5514        Some("on") => true,
5515        Some("off") => false,
5516        Some(other) => {
5517            return Err(RuntimeManifestError::InvalidInferenceConfig {
5518                field: "context.record".to_string(),
5519                message: format!("unknown value '{other}'; expected: on, off"),
5520            })
5521        }
5522    };
5523
5524    Ok(Some(ContextConfig {
5525        max_tokens: raw.max_tokens,
5526        record,
5527        record_store: raw.record_store.map(|store| store.trim().to_string()),
5528        seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
5529        seed_overflow_margin: fraction(
5530            "context.seed_overflow_margin",
5531            raw.seed_overflow_margin,
5532            DEFAULT_SEED_OVERFLOW_MARGIN,
5533        )?,
5534        retain: parse_context_retain(raw.retain)?,
5535    }))
5536}
5537
5538/// The keys `trace.retain` accepts, in the order an error lists them.
5539const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
5540
5541/// The keys `context.retain` accepts, in the order an error lists them.
5542const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
5543
5544/// What both `retain:` blocks say when they carry nothing. Stated once so the two errors, which
5545/// travel through different `RuntimeManifestError` variants, cannot drift apart.
5546const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
5547                                  keep everything, which is what no policy means";
5548
5549/// One `retain:` block, reduced to its two recognized keys.
5550///
5551/// Shared by `trace.retain` and `context.retain`: they differ only in the name of the count key,
5552/// so the shape check, the unknown-key refusal and the empty-block refusal are written once.
5553/// `count_key` is `max_sessions` or `max_messages`; `block` is the dotted path an error names.
5554fn parse_retain_block(
5555    block: &str,
5556    count_key: &str,
5557    accepted: &[&str],
5558    raw: serde_yaml::Value,
5559    invalid: &dyn Fn(String, String) -> RuntimeManifestError,
5560) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
5561    let mapping = match raw {
5562        serde_yaml::Value::Mapping(mapping) => mapping,
5563        // `retain:` with nothing under it parses as null and means exactly `retain: {}`.
5564        serde_yaml::Value::Null => {
5565            return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
5566        }
5567        other => {
5568            return Err(invalid(
5569                block.to_string(),
5570                format!(
5571                    "must be a block of keys ({}), got '{other:?}'",
5572                    accepted.join(", ")
5573                ),
5574            ))
5575        }
5576    };
5577    if mapping.is_empty() {
5578        return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
5579    }
5580
5581    let mut count: Option<u32> = None;
5582    let mut max_age_secs: Option<u64> = None;
5583    for (key, value) in &mapping {
5584        let key = key.as_str().unwrap_or_default();
5585        let field = format!("{block}.{key}");
5586        if key == count_key {
5587            let parsed = value
5588                .as_u64()
5589                .and_then(|n| u32::try_from(n).ok())
5590                .ok_or_else(|| {
5591                    invalid(
5592                        field.clone(),
5593                        "must be a whole number of at least 1".to_string(),
5594                    )
5595                })?;
5596            if parsed == 0 {
5597                return Err(invalid(
5598                    field,
5599                    "must be at least 1 — a limit of zero would delete what retention exists \
5600                     to keep; remove the key to leave it unbounded"
5601                        .to_string(),
5602                ));
5603            }
5604            count = Some(parsed);
5605        } else if key == "max_age" {
5606            let text = scalar_text(value).ok_or_else(|| {
5607                invalid(
5608                    field.clone(),
5609                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
5610                )
5611            })?;
5612            let parsed =
5613                parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
5614            if parsed == 0 {
5615                return Err(invalid(
5616                    field,
5617                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
5618                ));
5619            }
5620            max_age_secs = Some(parsed);
5621        } else {
5622            return Err(invalid(
5623                block.to_string(),
5624                format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
5625            ));
5626        }
5627    }
5628    Ok((count, max_age_secs))
5629}
5630
5631fn parse_trace_retain(
5632    raw: Option<serde_yaml::Value>,
5633) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
5634    let Some(raw) = raw else {
5635        return Ok(None);
5636    };
5637    let (max_sessions, max_age_secs) = parse_retain_block(
5638        "trace.retain",
5639        "max_sessions",
5640        &TRACE_RETAIN_KEYS,
5641        raw,
5642        &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
5643    )?;
5644    Ok(Some(TraceRetainConfig {
5645        max_sessions,
5646        max_age_secs,
5647    }))
5648}
5649
5650fn parse_context_retain(
5651    raw: Option<serde_yaml::Value>,
5652) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
5653    let Some(raw) = raw else {
5654        return Ok(None);
5655    };
5656    let (max_messages, max_age_secs) = parse_retain_block(
5657        "context.retain",
5658        "max_messages",
5659        &CONTEXT_RETAIN_KEYS,
5660        raw,
5661        &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
5662    )?;
5663    Ok(Some(ContextRetainConfig {
5664        max_messages,
5665        max_age_secs,
5666    }))
5667}
5668
5669fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
5670    raw.map(|raw| ObservabilityConfig {
5671        otel_endpoint: raw
5672            .otel_endpoint
5673            .map(|value| value.trim().to_string())
5674            .filter(|value| !value.is_empty()),
5675        eval: raw.eval.map(parse_eval_config),
5676    })
5677}
5678
5679fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
5680    let scorers = raw
5681        .scorers
5682        .into_iter()
5683        .filter_map(|raw| {
5684            let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
5685            match raw.scorer_type.as_str() {
5686                "exit_ok" => Some(ScorerConfig::ExitOk { name }),
5687                "max_turns" => Some(ScorerConfig::MaxTurns {
5688                    name,
5689                    max: raw.max.unwrap_or(10) as u32,
5690                }),
5691                "max_tokens" => Some(ScorerConfig::MaxTokens {
5692                    name,
5693                    max: raw.max.unwrap_or(100_000),
5694                }),
5695                "tool_sequence" => Some(ScorerConfig::ToolSequence {
5696                    name,
5697                    expected: raw.expected.unwrap_or_default(),
5698                }),
5699                "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
5700                other => {
5701                    crate::warn_line(format_args!(
5702                        "[murmur-artifact] unknown scorer type '{other}' — skipping"
5703                    ));
5704                    None
5705                }
5706            }
5707        })
5708        .collect();
5709
5710    EvalConfig {
5711        dataset_id: raw.dataset_id,
5712        scorers,
5713    }
5714}
5715
5716fn parse_compaction(
5717    raw: Option<RawCompactionConfig>,
5718) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
5719    let Some(raw) = raw else {
5720        return Ok(None);
5721    };
5722
5723    if let Some(threshold) = raw.threshold {
5724        if threshold <= 0.0 || threshold > 1.0 {
5725            return Err(RuntimeManifestError::InvalidInferenceConfig {
5726                field: "inference.compaction.threshold".to_string(),
5727                message: format!("must be in (0.0, 1.0] but got {threshold}"),
5728            });
5729        }
5730    }
5731
5732    if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
5733        return Err(RuntimeManifestError::InvalidInferenceConfig {
5734            field: "inference.compaction.system_prompt".to_string(),
5735            message: "at most one of inference.compaction.system_prompt, \
5736                      inference.compaction.system_prompt_file may be set"
5737                .to_string(),
5738        });
5739    }
5740
5741    Ok(Some(CompactionConfig {
5742        threshold: raw.threshold,
5743        model: raw.model,
5744        system_prompt: raw.system_prompt,
5745        system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
5746        // A plain optional boolean: nothing to validate, and deliberately independent of the
5747        // threshold-range and prompt-source checks above.
5748        dump_summaries: raw.dump_summaries,
5749    }))
5750}
5751
5752fn optional_trimmed_string(value: Option<String>) -> Option<String> {
5753    value
5754        .map(|s| s.trim().to_string())
5755        .filter(|s| !s.is_empty())
5756}
5757
5758fn required_inference_field(
5759    value: Option<String>,
5760    key: &str,
5761) -> Result<String, RuntimeManifestError> {
5762    value
5763        .map(|value| value.trim().to_string())
5764        .filter(|value| !value.is_empty())
5765        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5766            field: format!("inference.{key}"),
5767            message: "missing required field".to_string(),
5768        })
5769}
5770
5771/// One artifact entry's `gateway:` block, checked and with its key reference kept unresolved.
5772///
5773/// The credential rule reads what the manifest wrote, never a resolved value, so it decides the
5774/// same under [`SecretResolution::Resolve`] and [`SecretResolution::Skip`]: a non-blank `api_key`
5775/// is keyed, `keyless: true` with no `api_key` key is keyless, both together is
5776/// [`RuntimeManifestError::InvalidArtifact`], and anything else is
5777/// [`RuntimeManifestError::GatewayWithoutCredential`]. The endpoint's address plays no part.
5778fn parse_artifact_gateway(
5779    index: usize,
5780    name: &str,
5781    raw: Option<RawArtifactGateway>,
5782    secrets: SecretResolution,
5783) -> Result<ArtifactGateway, RuntimeManifestError> {
5784    let (endpoint, api_key, keyless) = match raw {
5785        Some(raw) => (
5786            optional_trimmed_string(raw.endpoint),
5787            raw.api_key,
5788            raw.keyless,
5789        ),
5790        None => (None, None, None),
5791    };
5792    let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
5793        index,
5794        message: format!(
5795            "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
5796             the upstream URL the runtime sends this artifact's keyed requests to"
5797        ),
5798    })?;
5799    validate_gateway_endpoint(name, &endpoint)
5800        .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
5801
5802    let keyless = match keyless {
5803        None | Some(serde_yaml::Value::Null) | Some(serde_yaml::Value::Bool(false)) => false,
5804        Some(serde_yaml::Value::Bool(true)) => true,
5805        Some(_) => {
5806            return Err(RuntimeManifestError::InvalidArtifact {
5807                index,
5808                message: format!(
5809                    "artifact '{name}' declares gateway.keyless with a value that is not a \
5810                     boolean; write keyless: true for an upstream that takes no key"
5811                ),
5812            })
5813        }
5814    };
5815    if keyless && api_key.is_some() {
5816        return Err(RuntimeManifestError::InvalidArtifact {
5817            index,
5818            message: format!(
5819                "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
5820                 gateway either presents a key or is keyless"
5821            ),
5822        });
5823    }
5824    if keyless {
5825        return Ok(ArtifactGateway {
5826            endpoint,
5827            api_key: None,
5828            keyless: true,
5829        });
5830    }
5831
5832    let written = api_key
5833        .flatten()
5834        .map(|value| value.trim().to_string())
5835        .filter(|value| !value.is_empty());
5836    let Some(written) = written else {
5837        return Err(RuntimeManifestError::GatewayWithoutCredential {
5838            index,
5839            name: name.to_string(),
5840            endpoint,
5841        });
5842    };
5843    let api_key = match secrets {
5844        SecretResolution::Skip => None,
5845        SecretResolution::Resolve => Some(match parse_env_reference(&written) {
5846            Some(variable) => ApiKeyReference::Environment(variable.to_string()),
5847            None => ApiKeyReference::Literal(written),
5848        }),
5849    };
5850
5851    Ok(ArtifactGateway {
5852        endpoint,
5853        api_key,
5854        keyless: false,
5855    })
5856}
5857
5858/// Checks a `gateway.endpoint`, the only control on where an artifact's key is sent: a
5859/// gateway-addressed request is readdressed before any `network.allow` rule is consulted, so
5860/// nothing else confines the destination.
5861///
5862/// Accepted: an `https://` URL with a host, or an `http://` URL whose host is `localhost` or an
5863/// IP literal for which [`IpAddr::is_loopback`] returns true (`127.0.0.0/8`, `::1`). An optional
5864/// port and path are kept.
5865///
5866/// Refused:
5867/// - `${` anywhere, checked before parsing: the field is never interpolated, and the URL parser
5868///   accepts `$`, `{` and `}` in a host, so `https://${HOST}` would otherwise parse as a literal
5869///   host.
5870/// - A malformed URL, a scheme other than `http`/`https`, or no host.
5871/// - Userinfo (`user@`, `user:pass@`): `https://api.example.com@evil.example` addresses
5872///   `evil.example`, and the gateway never sends userinfo, so a password there is a second,
5873///   unmanaged credential. The message names the host the URL addresses.
5874/// - A query or a fragment: the gateway readdresses each request by its own path and query, so
5875///   either would be silently dropped.
5876/// - `http://` to any host that is not loopback, including `0.0.0.0` and `::ffff:127.0.0.1`.
5877///
5878/// The endpoint confines the origin only. The guest chooses the whole path and query of every
5879/// gateway request.
5880///
5881/// The error is the whole message, naming `gateway.endpoint` and the artifact entry `artifact`.
5882/// It quotes the endpoint with any userinfo masked, so a password written into the URL is never
5883/// echoed.
5884fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
5885    let shown = mask_endpoint_userinfo(endpoint);
5886    let refuse =
5887        |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
5888    if endpoint.contains("${") {
5889        return Err(refuse(
5890            "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
5891             literally"
5892                .to_string(),
5893        ));
5894    }
5895    let url = Url::parse(endpoint)
5896        .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
5897    let host = match url.scheme() {
5898        "http" | "https" => url
5899            .host_str()
5900            .filter(|host| !host.is_empty())
5901            .ok_or_else(|| refuse("which has no host".to_string()))?,
5902        other => {
5903            return Err(refuse(format!(
5904                "whose scheme '{other}' is unsupported — expected http or https"
5905            )))
5906        }
5907    };
5908    if !url.username().is_empty() || url.password().is_some() {
5909        return Err(refuse(format!(
5910            "which carries userinfo before '@' — the gateway never sends it, and this URL \
5911             addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
5912             the key through gateway.api_key"
5913        )));
5914    }
5915    if url.query().is_some() {
5916        return Err(refuse(
5917            "which carries a query — the gateway keeps each request's own query, so write the \
5918             upstream without one"
5919                .to_string(),
5920        ));
5921    }
5922    if url.fragment().is_some() {
5923        return Err(refuse(
5924            "which carries a fragment — write the upstream without one".to_string(),
5925        ));
5926    }
5927    if url.scheme() == "http" {
5928        let bare = host.trim_start_matches('[').trim_end_matches(']');
5929        let is_loopback = bare == "localhost"
5930            || bare
5931                .parse::<IpAddr>()
5932                .map(|ip| ip.is_loopback())
5933                .unwrap_or(false);
5934        if !is_loopback {
5935            return Err(refuse(format!(
5936                "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
5937                 remote upstream, or http://localhost or a loopback IP literal for a local one"
5938            )));
5939        }
5940    }
5941    Ok(())
5942}
5943
5944/// `endpoint` with everything before the last `@` of its authority replaced by `***`.
5945///
5946/// Textual rather than parsed, so it also masks an endpoint the URL parser rejects.
5947fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
5948    let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
5949    let rest = &endpoint[authority_start..];
5950    let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
5951    match rest[..authority_len].rfind('@') {
5952        Some(at) => format!(
5953            "{}***{}",
5954            &endpoint[..authority_start],
5955            &endpoint[authority_start + at..]
5956        )
5957        .into(),
5958        None => endpoint.into(),
5959    }
5960}
5961
5962/// The cross-entry rules for a `gateway:` on a driver, which need the parsed `inference:` block.
5963///
5964/// A driver's gateway is a metered inference gateway only when that driver is the configured
5965/// `transport: http` driver or an `inference.alternates` entry's driver. On any other driver entry
5966/// it could be spent only through tool dispatch, where nothing meters it, so it is refused. Under
5967/// `transport: http` the configured driver's entry must exist and must carry the upstream.
5968fn validate_driver_gateways(
5969    artifacts: &[RuntimeArtifact],
5970    inference: Option<&InferenceConfig>,
5971) -> Result<(), RuntimeManifestError> {
5972    let configured_driver = inference
5973        .filter(|inference| inference.transport == "http")
5974        .and_then(|inference| inference.driver.as_ref())
5975        .map(|driver| driver.artifact.as_str());
5976    let alternate_drivers: Vec<&str> = inference
5977        .map(|inference| {
5978            inference
5979                .alternates
5980                .iter()
5981                .map(|alternate| alternate.driver.as_str())
5982                .collect()
5983        })
5984        .unwrap_or_default();
5985    let under_process = inference.is_some_and(|inference| inference.transport == "process");
5986
5987    // A reference to nothing is reported as such before any entry is judged against it: a
5988    // misspelt driver name would otherwise read as a gateway on the wrong driver.
5989    if let Some(driver) = configured_driver {
5990        if !artifacts.iter().any(|artifact| artifact.name == driver) {
5991            return Err(RuntimeManifestError::InvalidInferenceConfig {
5992                field: "inference.driver.artifact".to_string(),
5993                message: not_declared_message(artifacts, driver),
5994            });
5995        }
5996    }
5997
5998    for (index, artifact) in artifacts.iter().enumerate() {
5999        if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
6000            continue;
6001        }
6002        if under_process {
6003            return Err(RuntimeManifestError::InvalidArtifact {
6004                index,
6005                message: format!(
6006                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
6007                     valid with transport: process — the CLI subprocess holds its own \
6008                     credentials and a process driver is granted nothing",
6009                    artifact.name
6010                ),
6011            });
6012        }
6013        if configured_driver != Some(artifact.name.as_str())
6014            && !alternate_drivers.contains(&artifact.name.as_str())
6015        {
6016            return Err(RuntimeManifestError::InvalidArtifact {
6017                index,
6018                message: format!(
6019                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
6020                     the configured inference driver; a driver's gateway is accepted only on the \
6021                     entry inference.driver.artifact names under transport: http, or on one an \
6022                     inference.alternates entry names",
6023                    artifact.name
6024                ),
6025            });
6026        }
6027    }
6028
6029    if let Some(driver) = configured_driver {
6030        let Some((index, entry)) = artifacts
6031            .iter()
6032            .enumerate()
6033            .find(|(_, artifact)| artifact.name == driver)
6034        else {
6035            return Err(RuntimeManifestError::InvalidInferenceConfig {
6036                field: "inference.driver.artifact".to_string(),
6037                message: not_declared_message(artifacts, driver),
6038            });
6039        };
6040        if entry.gateway.is_none() {
6041            return Err(RuntimeManifestError::InvalidArtifact {
6042                index,
6043                message: format!(
6044                    "artifact '{driver}' is the transport: http inference driver but declares no \
6045                     gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
6046                     on its entry"
6047                ),
6048            });
6049        }
6050    }
6051    Ok(())
6052}
6053
6054/// The cross-entry rules for `inference.alternates`, which need `artifacts:`.
6055///
6056/// Each alternate's `driver.artifact` must be an `artifacts:` entry with `runtime: driver`, and
6057/// that entry must carry its own `gateway:` — the credential a switch attaches — unless it is the
6058/// primary's own driver, whose gateway [`validate_driver_gateways`] already requires.
6059fn validate_driver_choices(
6060    artifacts: &[RuntimeArtifact],
6061    inference: Option<&InferenceConfig>,
6062) -> Result<(), RuntimeManifestError> {
6063    let Some(inference) = inference else {
6064        return Ok(());
6065    };
6066    let primary_driver = inference
6067        .driver
6068        .as_ref()
6069        .map(|driver| driver.artifact.as_str());
6070    for (index, alternate) in inference.alternates.iter().enumerate() {
6071        let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
6072            field: format!("inference.alternates[{index}].driver.artifact"),
6073            message,
6074        };
6075        let driver = alternate.driver.as_str();
6076        let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
6077            return Err(refuse(not_declared_message(artifacts, driver)));
6078        };
6079        if entry.runtime != ArtifactRuntime::Driver {
6080            return Err(refuse(format!(
6081                "artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
6082                 runtime: driver entry",
6083                entry.runtime.as_str()
6084            )));
6085        }
6086        if entry.gateway.is_none() && primary_driver != Some(driver) {
6087            return Err(refuse(format!(
6088                "artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
6089                 its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
6090                 URL>, api_key: ... }}' on its entry"
6091            )));
6092        }
6093    }
6094    Ok(())
6095}
6096
6097/// Refuses alternates nothing could ever select: `inference.driver` is in neither
6098/// `control.settings` nor `control.agent_settings`.
6099fn validate_alternates_selectable(
6100    inference: Option<&InferenceConfig>,
6101    control: Option<&ControlConfig>,
6102) -> Result<(), RuntimeManifestError> {
6103    if inference.is_none_or(|inference| inference.alternates.is_empty()) {
6104        return Ok(());
6105    }
6106    let selectable = control.is_some_and(|control| {
6107        control.controller_may_set(ControllableSetting::InferenceDriver)
6108            || control.agent_may_set(ControllableSetting::InferenceDriver)
6109    });
6110    if selectable {
6111        return Ok(());
6112    }
6113    Err(RuntimeManifestError::InvalidInferenceConfig {
6114        field: "inference.alternates".to_string(),
6115        message: "declares alternates that nothing could ever select; list inference.driver in \
6116                  control.settings for a controller to switch, or in control.agent_settings for \
6117                  the agent to"
6118            .to_string(),
6119    })
6120}
6121
6122/// The cross-entry rules for the `transport: process` driver, which need the parsed
6123/// `inference:` block.
6124///
6125/// The artifact `inference.driver.artifact` names must be declared in `artifacts:` with
6126/// `runtime: driver`. A process driver is granted nothing, so its entry may carry neither
6127/// `capabilities:` nor `config:`; its settings travel in `inference.driver.config`. A `gateway:`
6128/// on it is refused by [`validate_driver_gateways`].
6129fn validate_process_driver_entry(
6130    artifacts: &[RuntimeArtifact],
6131    inference: Option<&InferenceConfig>,
6132) -> Result<(), RuntimeManifestError> {
6133    let Some(driver) = inference
6134        .filter(|inference| inference.transport == "process")
6135        .and_then(|inference| inference.driver.as_ref())
6136        .map(|driver| driver.artifact.as_str())
6137    else {
6138        return Ok(());
6139    };
6140    let Some((index, entry)) = artifacts
6141        .iter()
6142        .enumerate()
6143        .find(|(_, artifact)| artifact.name == driver)
6144    else {
6145        return Err(RuntimeManifestError::InvalidInferenceConfig {
6146            field: "inference.driver.artifact".to_string(),
6147            message: not_declared_message(artifacts, driver),
6148        });
6149    };
6150    if entry.runtime != ArtifactRuntime::Driver {
6151        return Err(RuntimeManifestError::InvalidArtifact {
6152            index,
6153            message: format!(
6154                "artifact '{driver}' is the transport: process driver and must be declared with \
6155                 runtime: driver"
6156            ),
6157        });
6158    }
6159    if entry.capabilities.is_some() || entry.config.is_some() {
6160        return Err(RuntimeManifestError::InvalidArtifact {
6161            index,
6162            message: format!(
6163                "artifact '{driver}' is the transport: process driver, which is granted nothing; \
6164                 remove capabilities:/config: from its entry — driver settings go in \
6165                 inference.driver.config"
6166            ),
6167        });
6168    }
6169    Ok(())
6170}
6171
6172fn parse_inference_driver(
6173    raw: RawInferenceDriver,
6174) -> Result<InferenceDriver, RuntimeManifestError> {
6175    let artifact = raw
6176        .artifact
6177        .map(|value| value.trim().to_string())
6178        .filter(|value| !value.is_empty())
6179        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6180            field: "inference.driver.artifact".to_string(),
6181            message: "missing required field".to_string(),
6182        })?;
6183
6184    let config = parse_inference_driver_config(raw.config)?;
6185
6186    Ok(InferenceDriver { artifact, config })
6187}
6188
6189fn parse_inference_driver_config(
6190    raw: Option<serde_yaml::Value>,
6191) -> Result<Option<String>, RuntimeManifestError> {
6192    let Some(raw) = raw else {
6193        return Ok(None);
6194    };
6195
6196    if matches!(raw, serde_yaml::Value::Null) {
6197        return Ok(None);
6198    }
6199
6200    if !matches!(raw, serde_yaml::Value::Mapping(_)) {
6201        return Err(RuntimeManifestError::InvalidInferenceConfig {
6202            field: "inference.driver.config".to_string(),
6203            message: "expected mapping/object".to_string(),
6204        });
6205    }
6206
6207    let value =
6208        serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
6209            field: "inference.driver.config".to_string(),
6210            message: format!("failed to convert to JSON: {err}"),
6211        })?;
6212
6213    serde_json::to_string(&value).map(Some).map_err(|err| {
6214        RuntimeManifestError::InvalidInferenceConfig {
6215            field: "inference.driver.config".to_string(),
6216            message: format!("failed to encode JSON: {err}"),
6217        }
6218    })
6219}
6220
6221fn parse_env_reference(value: &str) -> Option<&str> {
6222    if !value.starts_with("${") || !value.ends_with('}') {
6223        return None;
6224    }
6225
6226    let variable = &value[2..value.len() - 1];
6227    is_valid_env_variable(variable).then_some(variable)
6228}
6229
6230fn is_valid_env_variable(value: &str) -> bool {
6231    let mut chars = value.chars();
6232    let Some(first) = chars.next() else {
6233        return false;
6234    };
6235
6236    if !(first == '_' || first.is_ascii_uppercase()) {
6237        return false;
6238    }
6239
6240    chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
6241}
6242
6243#[cfg(test)]
6244mod tests {
6245    use super::*;
6246    use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
6247
6248    fn manifest_with_trace(block: &str) -> String {
6249        format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
6250    }
6251
6252    fn lifecycle(task_acceptance: TaskAcceptance, queue_depth: usize) -> LifecycleConfig {
6253        LifecycleConfig {
6254            task_acceptance,
6255            queue_depth,
6256            ..Default::default()
6257        }
6258    }
6259
6260    #[test]
6261    fn tasks_held_at_once_under_queue_is_one_running_plus_queue_depth_waiting() {
6262        assert_eq!(lifecycle(TaskAcceptance::Queue, 1).tasks_held_at_once(), 2);
6263        assert_eq!(lifecycle(TaskAcceptance::Queue, 9).tasks_held_at_once(), 10);
6264    }
6265
6266    #[test]
6267    fn queue_has_room_is_false_only_for_a_queue_at_depth_zero() {
6268        assert!(!lifecycle(TaskAcceptance::Queue, 0).queue_has_room());
6269        assert!(lifecycle(TaskAcceptance::Queue, 1).queue_has_room());
6270        assert!(lifecycle(TaskAcceptance::Single, 0).queue_has_room());
6271        assert!(lifecycle(TaskAcceptance::None, 0).queue_has_room());
6272        assert!(lifecycle(TaskAcceptance::Single, 3).queue_has_room());
6273    }
6274
6275    fn parse_lifecycle(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
6276        RuntimeManifest::from_yaml_str(&format!(
6277            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n{block}"
6278        ))
6279    }
6280
6281    #[test]
6282    fn queue_depth_zero_under_queue_is_refused_at_load() {
6283        let error = parse_lifecycle("  task_acceptance: queue\n  queue_depth: 0\n").unwrap_err();
6284        assert!(
6285            matches!(error, RuntimeManifestError::QueueWithoutRoom),
6286            "{error:?}"
6287        );
6288        assert_eq!(
6289            error.to_string(),
6290            "murmur.yaml: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves \
6291             task_acceptance: queue no room for a waiting task"
6292        );
6293    }
6294
6295    /// `queue_depth` means nothing outside `queue`, so `0` there parses.
6296    #[test]
6297    fn queue_depth_zero_outside_queue_still_parses() {
6298        for block in [
6299            "  task_acceptance: single\n  queue_depth: 0\n",
6300            "  task_acceptance: none\n  queue_depth: 0\n",
6301            "  queue_depth: 0\n",
6302        ] {
6303            let lifecycle = parse_lifecycle(block)
6304                .unwrap_or_else(|error| panic!("{block:?}: {error}"))
6305                .effective_lifecycle();
6306            assert_eq!(lifecycle.queue_depth, 0, "{block:?}");
6307        }
6308        let queue = parse_lifecycle("  task_acceptance: queue\n  queue_depth: 1\n")
6309            .unwrap()
6310            .effective_lifecycle();
6311        assert_eq!(queue.task_acceptance, TaskAcceptance::Queue);
6312        assert_eq!(queue.queue_depth, 1);
6313    }
6314
6315    #[test]
6316    fn tasks_held_at_once_under_single_is_one_whatever_the_depth() {
6317        assert_eq!(lifecycle(TaskAcceptance::Single, 1).tasks_held_at_once(), 1);
6318        assert_eq!(lifecycle(TaskAcceptance::Single, 5).tasks_held_at_once(), 1);
6319    }
6320
6321    #[test]
6322    fn tasks_held_at_once_under_none_is_zero_whatever_the_depth() {
6323        assert_eq!(lifecycle(TaskAcceptance::None, 1).tasks_held_at_once(), 0);
6324        assert_eq!(lifecycle(TaskAcceptance::None, 5).tasks_held_at_once(), 0);
6325    }
6326
6327    /// The default is `single` at `queue_depth: 1`, which holds one task; switching only
6328    /// `task_acceptance` to `queue` keeps the default depth and holds two.
6329    #[test]
6330    fn tasks_held_at_once_of_the_default_lifecycle_and_default_depth() {
6331        assert_eq!(LifecycleConfig::default().tasks_held_at_once(), 1);
6332        let queue = RuntimeManifest::from_yaml_str(
6333            "name: cap\nversion: 0.0.1\nlifecycle:\n  task_acceptance: queue\n",
6334        )
6335        .unwrap()
6336        .effective_lifecycle();
6337        assert_eq!(queue.queue_depth, 1);
6338        assert_eq!(queue.tasks_held_at_once(), 2);
6339    }
6340
6341    /// No `trace:` block at all leaves the config absent; the runtime substitutes
6342    /// [`TraceCapture::default`] rather than the manifest carrying a synthesized one.
6343    #[test]
6344    fn absent_trace_block_stays_absent_and_defaults_to_meta() {
6345        let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
6346        assert_eq!(manifest.trace, None);
6347        assert_eq!(TraceCapture::default(), TraceCapture::Meta);
6348    }
6349
6350    #[test]
6351    fn each_capture_mode_parses_from_the_trace_block() {
6352        for (yaml, expected) in [
6353            ("none", TraceCapture::None),
6354            ("meta", TraceCapture::Meta),
6355            ("content", TraceCapture::Content),
6356        ] {
6357            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
6358                "  capture: {yaml}\n"
6359            )))
6360            .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
6361            assert_eq!(
6362                manifest.trace,
6363                Some(TraceConfig {
6364                    capture: expected,
6365                    retain: None
6366                })
6367            );
6368        }
6369    }
6370
6371    // ── retain ───────────────────────────────────────────────────────────────
6372
6373    /// A `trace:` block with only `capture` and a `context:` block with only `record` declare no
6374    /// retention at all: an absent `retain:` is the only way to say "keep everything", and it is
6375    /// what a capsule that never mentions retention gets.
6376    #[test]
6377    fn no_retain_block_anywhere_parses_as_no_policy() {
6378        let manifest = RuntimeManifest::from_yaml_str(
6379            "name: cap\nversion: 0.0.1\ntrace:\n  capture: content\ncontext:\n  record: on\n",
6380        )
6381        .unwrap();
6382        assert_eq!(manifest.trace.unwrap().retain, None);
6383        assert_eq!(manifest.context.unwrap().retain, None);
6384    }
6385
6386    #[test]
6387    fn trace_retain_parses_both_keys_and_the_day_suffix() {
6388        let manifest = RuntimeManifest::from_yaml_str(
6389            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 50\n    max_age: 14d\n",
6390        )
6391        .unwrap();
6392        assert_eq!(
6393            manifest.trace.unwrap().retain,
6394            Some(TraceRetainConfig {
6395                max_sessions: Some(50),
6396                max_age_secs: Some(14 * 86_400),
6397            })
6398        );
6399    }
6400
6401    #[test]
6402    fn context_retain_parses_both_keys_and_either_alone() {
6403        let both = RuntimeManifest::from_yaml_str(
6404            "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n    max_messages: 2000\n    max_age: 90d\n",
6405        )
6406        .unwrap();
6407        assert_eq!(
6408            both.context.unwrap().retain,
6409            Some(ContextRetainConfig {
6410                max_messages: Some(2000),
6411                max_age_secs: Some(90 * 86_400),
6412            })
6413        );
6414
6415        let count_only = RuntimeManifest::from_yaml_str(
6416            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 10\n",
6417        )
6418        .unwrap();
6419        assert_eq!(
6420            count_only.context.unwrap().retain,
6421            Some(ContextRetainConfig {
6422                max_messages: Some(10),
6423                max_age_secs: None,
6424            })
6425        );
6426    }
6427
6428    /// An empty block is not a way to declare no policy — omitting the block is. Both spellings
6429    /// of empty are refused, naming the block an operator has to go and change.
6430    #[test]
6431    fn an_empty_retain_block_is_refused_naming_the_block() {
6432        for (yaml, expect_trace) in [
6433            (
6434                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain: {}\n",
6435                true,
6436            ),
6437            (
6438                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n",
6439                true,
6440            ),
6441            (
6442                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain: {}\n",
6443                false,
6444            ),
6445            (
6446                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n",
6447                false,
6448            ),
6449        ] {
6450            let err = RuntimeManifest::from_yaml_str(yaml)
6451                .expect_err("an empty retain block must be refused");
6452            let (field, message) = match (&err, expect_trace) {
6453                (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
6454                    (field, message)
6455                }
6456                (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
6457                    (field, message)
6458                }
6459                _ => panic!("wrong variant for {yaml:?}: {err:?}"),
6460            };
6461            assert_eq!(
6462                field,
6463                if expect_trace {
6464                    "trace.retain"
6465                } else {
6466                    "context.retain"
6467                }
6468            );
6469            assert!(message.contains("at least one key"), "{message}");
6470        }
6471    }
6472
6473    /// A limit of zero would delete what retention exists to keep, so it is
6474    /// refused naming the key rather than accepted as "keep nothing".
6475    #[test]
6476    fn a_zero_retain_limit_is_refused_naming_the_key() {
6477        let trace = RuntimeManifest::from_yaml_str(
6478            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 0\n",
6479        )
6480        .expect_err("max_sessions: 0 must be refused");
6481        assert!(
6482            matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
6483                     if field == "trace.retain.max_sessions"),
6484            "{trace:?}"
6485        );
6486
6487        let context = RuntimeManifest::from_yaml_str(
6488            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 0\n",
6489        )
6490        .expect_err("max_messages: 0 must be refused");
6491        assert!(
6492            matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
6493                     if field == "context.retain.max_messages"),
6494            "{context:?}"
6495        );
6496
6497        let age = RuntimeManifest::from_yaml_str(
6498            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_age: 0\n",
6499        )
6500        .expect_err("max_age: 0 must be refused");
6501        assert!(
6502            matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
6503                     if field == "trace.retain.max_age"),
6504            "{age:?}"
6505        );
6506    }
6507
6508    #[test]
6509    fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
6510        let err = RuntimeManifest::from_yaml_str(
6511            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_traces: 3\n",
6512        )
6513        .expect_err("an unknown key must be refused");
6514        match err {
6515            RuntimeManifestError::InvalidTraceConfig { field, message } => {
6516                assert_eq!(field, "trace.retain");
6517                assert!(message.contains("max_traces"), "{message}");
6518                assert!(message.contains("max_sessions"), "{message}");
6519            }
6520            other => panic!("expected InvalidTraceConfig, got {other:?}"),
6521        }
6522    }
6523
6524    #[test]
6525    fn an_unparseable_retain_duration_reports_the_accepted_form() {
6526        let err = RuntimeManifest::from_yaml_str(
6527            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_age: 5 weeks\n",
6528        )
6529        .expect_err("'5 weeks' must be refused");
6530        match err {
6531            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
6532                assert_eq!(field, "context.retain.max_age");
6533                assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
6534            }
6535            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
6536        }
6537    }
6538
6539    #[test]
6540    fn unparseable_capture_value_names_the_field_and_accepted_values() {
6541        let err = RuntimeManifest::from_yaml_str(&manifest_with_trace("  capture: verbose\n"))
6542            .expect_err("'verbose' is not a capture mode");
6543        let rendered = err.to_string();
6544        assert!(rendered.contains("trace.capture"), "{rendered}");
6545        for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
6546            assert!(rendered.contains(accepted), "{rendered}");
6547        }
6548    }
6549
6550    #[test]
6551    fn resources_block_is_optional_and_absent_stays_absent() {
6552        let manifest = RuntimeManifest::from_yaml_str(
6553            r#"
6554name: cap
6555version: 0.0.1
6556capabilities:
6557  shell:
6558    allow: [bash]
6559"#,
6560        )
6561        .unwrap();
6562
6563        // `None` here is "the manifest said nothing", not "unlimited" — the runtime substitutes
6564        // its own defaults (see `capsule_runtime::resources::HostResourceLimits::resolve`), and
6565        // keeping the two states distinct is what lets it own them in one place.
6566        let caps = manifest.capabilities.expect("shell block must parse");
6567        assert_eq!(caps.resources, None);
6568    }
6569
6570    #[test]
6571    fn resources_fields_parse_and_each_stays_independently_optional() {
6572        let manifest = RuntimeManifest::from_yaml_str(
6573            r#"
6574name: cap
6575version: 0.0.1
6576capabilities:
6577  resources:
6578    max_processes: 32
6579    max_open_files: 16
6580    max_file_size_bytes: 1048576
6581    cpu_seconds: 30
6582    memory_bytes: 268435456
6583    cgroup_memory_bytes: 536870912
6584    cgroup_pids_max: 64
6585    cgroup_cpu_percent: 50
6586    cgroup_io_bytes_per_sec: 1048576
6587    workdir_max_bytes: 2097152
6588"#,
6589        )
6590        .unwrap();
6591
6592        let resources = manifest
6593            .capabilities
6594            .and_then(|caps| caps.resources)
6595            .expect("resources block must parse");
6596        assert_eq!(resources.max_processes, Some(32));
6597        assert_eq!(resources.max_open_files, Some(16));
6598        assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
6599        assert_eq!(resources.cpu_seconds, Some(30));
6600        assert_eq!(resources.memory_bytes, Some(268_435_456));
6601        assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
6602        assert_eq!(resources.cgroup_pids_max, Some(64));
6603        assert_eq!(resources.cgroup_cpu_percent, Some(50));
6604        assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
6605        assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
6606    }
6607
6608    #[test]
6609    fn state_block_is_optional_and_absent_stays_absent() {
6610        let manifest = RuntimeManifest::from_yaml_str(
6611            r#"
6612name: cap
6613version: 0.0.1
6614artifacts:
6615  - name: local/notes-tool
6616    version: 1.0.0
6617    runtime: tool
6618    capabilities:
6619      filesystem:
6620        scope: cache
6621"#,
6622        )
6623        .unwrap();
6624
6625        // Absent is deny: no store name to default, and nothing downstream creates a directory.
6626        let caps = manifest.artifacts[0]
6627            .capabilities
6628            .as_ref()
6629            .expect("filesystem block must parse");
6630        assert_eq!(caps.state, None);
6631    }
6632
6633    /// A declared-but-empty `state:` block is a real grant — it means "give me a store, named
6634    /// after the capsule" — so it must lower to `Some(..)` with no name, distinguishable from
6635    /// the absent block above.
6636    #[test]
6637    fn state_block_declared_empty_grants_the_capsule_named_store() {
6638        let manifest = RuntimeManifest::from_yaml_str(
6639            r#"
6640name: cap
6641version: 0.0.1
6642artifacts:
6643  - name: local/notes-tool
6644    version: 1.0.0
6645    runtime: tool
6646    capabilities:
6647      state: {}
6648"#,
6649        )
6650        .unwrap();
6651
6652        let state = manifest.artifacts[0]
6653            .capabilities
6654            .as_ref()
6655            .and_then(|caps| caps.state.as_ref())
6656            .expect("state block must parse");
6657        assert_eq!(state.store, None);
6658    }
6659
6660    #[test]
6661    fn state_store_name_parses_and_stays_independently_optional() {
6662        let manifest = RuntimeManifest::from_yaml_str(
6663            r#"
6664name: cap
6665version: 0.0.1
6666artifacts:
6667  - name: local/notes-tool
6668    version: 1.0.0
6669    runtime: tool
6670    capabilities:
6671      state:
6672        store: "  shey  "
6673      network:
6674        allow: [https://api.example.com]
6675"#,
6676        )
6677        .unwrap();
6678
6679        let caps = manifest.artifacts[0]
6680            .capabilities
6681            .as_ref()
6682            .expect("capabilities must parse");
6683        // Trimmed, but otherwise verbatim: whether the name is a usable directory segment is
6684        // decided by the runtime (`E-CAP-009`), not here.
6685        assert_eq!(
6686            caps.state.as_ref().and_then(|state| state.store.as_deref()),
6687            Some("shey")
6688        );
6689        // Declaring a store never touches any sibling sub-block.
6690        assert_eq!(
6691            caps.network
6692                .as_ref()
6693                .map(|network| network.allow.as_slice()),
6694            Some(["https://api.example.com".to_string()].as_slice())
6695        );
6696        assert_eq!(caps.filesystem, None);
6697    }
6698
6699    /// An explicitly empty name is kept rather than normalised to "default to the capsule name":
6700    /// the operator wrote a name, and handing them a different store than the one they wrote
6701    /// would be worse than the refusal the runtime raises for it.
6702    #[test]
6703    fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
6704        let manifest = RuntimeManifest::from_yaml_str(
6705            r#"
6706name: cap
6707version: 0.0.1
6708artifacts:
6709  - name: local/notes-tool
6710    version: 1.0.0
6711    runtime: tool
6712    capabilities:
6713      state:
6714        store: ""
6715"#,
6716        )
6717        .unwrap();
6718
6719        assert_eq!(
6720            manifest.artifacts[0]
6721                .capabilities
6722                .as_ref()
6723                .and_then(|caps| caps.state.as_ref())
6724                .and_then(|state| state.store.as_deref()),
6725            Some("")
6726        );
6727    }
6728
6729    #[test]
6730    fn resources_partial_block_leaves_undeclared_fields_none() {
6731        let manifest = RuntimeManifest::from_yaml_str(
6732            r#"
6733name: cap
6734version: 0.0.1
6735capabilities:
6736  resources:
6737    max_open_files: 16
6738"#,
6739        )
6740        .unwrap();
6741
6742        let resources = manifest
6743            .capabilities
6744            .and_then(|caps| caps.resources)
6745            .expect("resources block must parse");
6746        assert_eq!(resources.max_open_files, Some(16));
6747        assert_eq!(resources.max_processes, None);
6748        assert_eq!(resources.workdir_max_bytes, None);
6749    }
6750
6751    /// Every field rejects `0` at parse time, naming its own path. A zero ceiling is never what
6752    /// an author means, and accepting one would turn a typo into a subprocess that cannot fork,
6753    /// open a file, or run for a single CPU-second — discovered at runtime instead of at parse.
6754    #[test]
6755    fn resources_zero_is_rejected_on_every_field() {
6756        for field in [
6757            "max_processes",
6758            "max_open_files",
6759            "max_file_size_bytes",
6760            "cpu_seconds",
6761            "memory_bytes",
6762            "cgroup_memory_bytes",
6763            "cgroup_pids_max",
6764            "cgroup_cpu_percent",
6765            "cgroup_io_bytes_per_sec",
6766            "workdir_max_bytes",
6767        ] {
6768            let yaml =
6769                format!("name: cap\nversion: 0.0.1\ncapabilities:\n  resources:\n    {field}: 0\n");
6770            let error =
6771                RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
6772
6773            match error {
6774                RuntimeManifestError::InvalidCapabilities {
6775                    field: reported,
6776                    message,
6777                } => {
6778                    assert_eq!(reported, format!("capabilities.resources.{field}"));
6779                    assert_eq!(message, "must be greater than zero");
6780                }
6781                other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
6782            }
6783        }
6784    }
6785
6786    #[test]
6787    fn artifact_runtime_defaults_to_tool() {
6788        let manifest = RuntimeManifest::from_yaml_str(
6789            r#"
6790name: cap
6791version: 0.0.1
6792artifacts:
6793  - name: local/echo
6794    version: 1.2.3
6795"#,
6796        )
6797        .unwrap();
6798
6799        assert_eq!(manifest.artifacts.len(), 1);
6800        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
6801    }
6802
6803    #[test]
6804    fn artifact_runtime_tool_explicit() {
6805        let manifest = RuntimeManifest::from_yaml_str(
6806            r#"
6807name: cap
6808version: 0.0.1
6809artifacts:
6810  - name: local/echo
6811    version: 1.2.3
6812    runtime: tool
6813"#,
6814        )
6815        .unwrap();
6816
6817        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
6818    }
6819
6820    #[test]
6821    fn artifact_runtime_wasm_is_error() {
6822        let err = RuntimeManifest::from_yaml_str(
6823            r#"
6824name: cap
6825version: 0.0.1
6826artifacts:
6827  - name: local/echo
6828    version: 1.2.3
6829    runtime: wasm
6830"#,
6831        )
6832        .unwrap_err();
6833
6834        let msg = err.to_string();
6835        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
6836        assert!(msg.contains("wasm"), "error was: {msg}");
6837    }
6838
6839    #[test]
6840    fn artifact_runtime_native_is_error() {
6841        let err = RuntimeManifest::from_yaml_str(
6842            r#"
6843name: cap
6844version: 0.0.1
6845artifacts:
6846  - name: local/echo
6847    version: 1.2.3
6848    runtime: native
6849"#,
6850        )
6851        .unwrap_err();
6852
6853        let msg = err.to_string();
6854        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
6855        assert!(msg.contains("native"), "error was: {msg}");
6856    }
6857
6858    #[test]
6859    fn invalid_artifact_reports_index() {
6860        let err = RuntimeManifest::from_yaml_str(
6861            r#"
6862name: cap
6863version: 0.0.1
6864artifacts:
6865  - name: ""
6866    version: 1.2.3
6867"#,
6868        )
6869        .unwrap_err();
6870
6871        let msg = err.to_string();
6872        assert!(msg.contains("index 0"));
6873        assert!(msg.contains("missing required field 'name'"));
6874    }
6875
6876    #[test]
6877    fn capabilities_parse_when_all_fields_present() {
6878        let manifest = RuntimeManifest::from_yaml_str(
6879            r#"
6880name: cap
6881version: 0.0.1
6882artifacts:
6883  - name: local/echo
6884    version: 1.2.3
6885capabilities:
6886  network:
6887    allow:
6888      - https://api.anthropic.com
6889      - http://127.0.0.1:8080
6890  filesystem:
6891    scope: ./sandbox
6892"#,
6893        )
6894        .unwrap();
6895
6896        let capabilities = manifest.capabilities.expect("capabilities should exist");
6897        assert_eq!(
6898            capabilities.network.unwrap().allow,
6899            vec![
6900                "https://api.anthropic.com".to_string(),
6901                "http://127.0.0.1:8080".to_string()
6902            ]
6903        );
6904        assert_eq!(
6905            capabilities.filesystem.unwrap().scope,
6906            Some("./sandbox".to_string())
6907        );
6908    }
6909
6910    #[test]
6911    fn env_capabilities_parse_allowlist() {
6912        let manifest = RuntimeManifest::from_yaml_str(
6913            r#"
6914name: cap
6915version: 0.0.1
6916artifacts:
6917  - name: local/echo
6918    version: 1.2.3
6919capabilities:
6920  env:
6921    allow:
6922      - MY_APP_REGION
6923      - GITHUB_TOKEN
6924"#,
6925        )
6926        .unwrap();
6927
6928        let capabilities = manifest.capabilities.expect("capabilities should exist");
6929        assert_eq!(
6930            capabilities.env.unwrap().allow,
6931            vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
6932        );
6933    }
6934
6935    #[test]
6936    fn env_capabilities_absent_when_block_omitted() {
6937        let manifest = RuntimeManifest::from_yaml_str(
6938            r#"
6939name: cap
6940version: 0.0.1
6941artifacts:
6942  - name: local/echo
6943    version: 1.2.3
6944capabilities:
6945  network:
6946    allow:
6947      - https://api.anthropic.com
6948"#,
6949        )
6950        .unwrap();
6951
6952        assert!(manifest.capabilities.unwrap().env.is_none());
6953    }
6954
6955    /// Unlike `capabilities.shell.allow`, an empty `env.allow` is a legitimate no-op rather
6956    /// than a manifest error.
6957    #[test]
6958    fn env_capabilities_allow_empty_list_is_accepted() {
6959        let manifest = RuntimeManifest::from_yaml_str(
6960            r#"
6961name: cap
6962version: 0.0.1
6963artifacts:
6964  - name: local/echo
6965    version: 1.2.3
6966capabilities:
6967  env: {}
6968"#,
6969        )
6970        .unwrap();
6971
6972        assert_eq!(
6973            manifest.capabilities.unwrap().env,
6974            Some(EnvCapabilities { allow: vec![] })
6975        );
6976    }
6977
6978    #[test]
6979    fn missing_capabilities_block_is_backward_compatible() {
6980        let manifest = RuntimeManifest::from_yaml_str(
6981            r#"
6982name: cap
6983version: 0.0.1
6984artifacts:
6985  - name: local/echo
6986    version: 1.2.3
6987"#,
6988        )
6989        .unwrap();
6990
6991        assert!(manifest.capabilities.is_none());
6992    }
6993
6994    #[test]
6995    fn network_only_capabilities_parse() {
6996        let manifest = RuntimeManifest::from_yaml_str(
6997            r#"
6998name: cap
6999version: 0.0.1
7000artifacts:
7001  - name: local/echo
7002    version: 1.2.3
7003capabilities:
7004  network:
7005    allow:
7006      - https://allowed.example.com
7007"#,
7008        )
7009        .unwrap();
7010
7011        let capabilities = manifest.capabilities.unwrap();
7012        assert_eq!(
7013            capabilities.network.unwrap().allow,
7014            vec!["https://allowed.example.com".to_string()]
7015        );
7016        assert!(capabilities.filesystem.is_none());
7017    }
7018
7019    #[test]
7020    fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
7021        let manifest = RuntimeManifest::from_yaml_str(
7022            r#"
7023name: cap
7024version: 0.0.1
7025artifacts:
7026  - name: local/echo
7027    version: 1.2.3
7028capabilities:
7029  network: {}
7030"#,
7031        )
7032        .unwrap();
7033
7034        let capabilities = manifest.capabilities.unwrap();
7035        assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
7036    }
7037
7038    /// `unix_sockets` is optional and denies by default. A capsule that never heard of the key
7039    /// must not silently get `AF_UNIX` sockets — that is the whole `/var/run/docker.sock` escape.
7040    #[test]
7041    fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
7042        let manifest = RuntimeManifest::from_yaml_str(
7043            r#"
7044name: cap
7045version: 0.0.1
7046artifacts:
7047  - name: local/echo
7048    version: 1.2.3
7049capabilities:
7050  network:
7051    allow:
7052      - https://api.anthropic.com
7053"#,
7054        )
7055        .unwrap();
7056
7057        let network = manifest.capabilities.unwrap().network.unwrap();
7058        assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
7059        assert!(!network.unix_sockets);
7060    }
7061
7062    #[test]
7063    fn network_unix_sockets_parses_explicit_false() {
7064        let manifest = RuntimeManifest::from_yaml_str(
7065            r#"
7066name: cap
7067version: 0.0.1
7068artifacts:
7069  - name: local/echo
7070    version: 1.2.3
7071capabilities:
7072  network:
7073    unix_sockets: false
7074"#,
7075        )
7076        .unwrap();
7077
7078        assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
7079    }
7080
7081    #[test]
7082    fn network_unix_sockets_parses_explicit_true() {
7083        let manifest = RuntimeManifest::from_yaml_str(
7084            r#"
7085name: cap
7086version: 0.0.1
7087artifacts:
7088  - name: local/echo
7089    version: 1.2.3
7090capabilities:
7091  network:
7092    unix_sockets: true
7093"#,
7094        )
7095        .unwrap();
7096
7097        let network = manifest.capabilities.unwrap().network.unwrap();
7098        assert!(network.unix_sockets);
7099        // The opt-in is orthogonal to the IP allowlist — declaring it does not imply any host.
7100        assert_eq!(network.allow, Vec::<String>::new());
7101    }
7102
7103    /// `read_only` is normalised on exactly the terms `scope` is — trimmed, with an entry left
7104    /// empty by that trim dropped rather than carried as a rule covering the whole workdir — and
7105    /// path shape is deliberately not judged here.
7106    #[test]
7107    fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
7108        let manifest = RuntimeManifest::from_yaml_str(
7109            r#"
7110name: cap
7111version: 0.0.1
7112capabilities:
7113  filesystem:
7114    read_only:
7115      - tests
7116      - "  bench/fixtures  "
7117      - ""
7118      - "   "
7119"#,
7120        )
7121        .unwrap();
7122
7123        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7124        assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
7125    }
7126
7127    /// An absent key and an empty list are one declaration: nothing is protected.
7128    #[test]
7129    fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
7130        let manifest = RuntimeManifest::from_yaml_str(
7131            r#"
7132name: cap
7133version: 0.0.1
7134capabilities:
7135  filesystem:
7136    scope: workdir
7137"#,
7138        )
7139        .unwrap();
7140
7141        assert!(manifest
7142            .capabilities
7143            .unwrap()
7144            .filesystem
7145            .unwrap()
7146            .read_only
7147            .is_empty());
7148    }
7149
7150    /// Path shape is the runtime's refusal, not the parser's: an absolute or escaping entry parses
7151    /// here exactly as an absolute `scope` does, and fails at launch with `E-CAP-012`.
7152    #[test]
7153    fn filesystem_read_only_does_not_judge_path_shape() {
7154        let manifest = RuntimeManifest::from_yaml_str(
7155            r#"
7156name: cap
7157version: 0.0.1
7158capabilities:
7159  filesystem:
7160    read_only:
7161      - /etc
7162      - ../outside
7163"#,
7164        )
7165        .expect("shape is judged by the runtime, not the parser");
7166
7167        assert_eq!(
7168            manifest.capabilities.unwrap().filesystem.unwrap().read_only,
7169            vec!["/etc", "../outside"]
7170        );
7171    }
7172
7173    /// The default every pre-existing manifest gets: a `filesystem:` block that has never heard of
7174    /// `workdir_exec` must not silently keep the workdir's `Execute` right, because that right is
7175    /// exactly what makes `capabilities.shell.allow` bypassable from inside the workdir.
7176    #[test]
7177    fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
7178        let manifest = RuntimeManifest::from_yaml_str(
7179            r#"
7180name: cap
7181version: 0.0.1
7182artifacts:
7183  - name: local/echo
7184    version: 1.2.3
7185capabilities:
7186  filesystem:
7187    scope: workdir
7188"#,
7189        )
7190        .unwrap();
7191
7192        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7193        assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
7194        assert!(!filesystem.workdir_exec);
7195    }
7196
7197    /// The other absent case: no `filesystem:` block at all. There is nothing to default here —
7198    /// the whole sub-block stays `None` — but a capsule with no filesystem declaration must still
7199    /// read as "workdir exec denied" downstream, which `capability_policy_from_runtime_manifest`
7200    /// covers with its own test.
7201    #[test]
7202    fn filesystem_block_stays_absent_when_undeclared() {
7203        let manifest = RuntimeManifest::from_yaml_str(
7204            r#"
7205name: cap
7206version: 0.0.1
7207artifacts:
7208  - name: local/echo
7209    version: 1.2.3
7210capabilities:
7211  network:
7212    allow:
7213      - https://api.anthropic.com
7214"#,
7215        )
7216        .unwrap();
7217
7218        assert!(manifest.capabilities.unwrap().filesystem.is_none());
7219    }
7220
7221    #[test]
7222    fn filesystem_workdir_exec_parses_explicit_false() {
7223        let manifest = RuntimeManifest::from_yaml_str(
7224            r#"
7225name: cap
7226version: 0.0.1
7227artifacts:
7228  - name: local/echo
7229    version: 1.2.3
7230capabilities:
7231  filesystem:
7232    workdir_exec: false
7233"#,
7234        )
7235        .unwrap();
7236
7237        assert!(
7238            !manifest
7239                .capabilities
7240                .unwrap()
7241                .filesystem
7242                .unwrap()
7243                .workdir_exec
7244        );
7245    }
7246
7247    #[test]
7248    fn filesystem_workdir_exec_parses_explicit_true() {
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  filesystem:
7258    workdir_exec: true
7259"#,
7260        )
7261        .unwrap();
7262
7263        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7264        assert!(filesystem.workdir_exec);
7265        // Orthogonal to `scope`, exactly as `unix_sockets` is orthogonal to `network.allow`:
7266        // declaring one says nothing about the other.
7267        assert_eq!(filesystem.scope, None);
7268    }
7269
7270    #[test]
7271    fn network_internal_port_parses() {
7272        let manifest = RuntimeManifest::from_yaml_str(
7273            r#"
7274name: worker
7275version: 0.1.0
7276artifacts: []
7277
7278network:
7279  internal_port: 58172
7280"#,
7281        )
7282        .unwrap();
7283
7284        assert_eq!(
7285            manifest.network.unwrap().internal_port,
7286            Some(58172),
7287            "network.internal_port should parse to Some(58172)"
7288        );
7289    }
7290
7291    #[test]
7292    fn network_internal_port_absent_when_omitted() {
7293        let manifest = RuntimeManifest::from_yaml_str(
7294            r#"
7295name: worker
7296version: 0.1.0
7297artifacts: []
7298"#,
7299        )
7300        .unwrap();
7301
7302        assert!(manifest.network.is_none());
7303    }
7304
7305    #[test]
7306    fn shell_capabilities_parse_with_allow_and_env_fields() {
7307        let manifest = RuntimeManifest::from_yaml_str(
7308            r#"
7309name: cap
7310version: 0.0.1
7311artifacts: []
7312capabilities:
7313  shell:
7314    allow:
7315      - bash
7316      - git
7317    strip_env:
7318      - "*_TOKEN"
7319      - SECRET_KEY
7320    baseline_env:
7321      - SSH_AUTH_SOCK
7322"#,
7323        )
7324        .unwrap();
7325
7326        let shell = manifest
7327            .capabilities
7328            .unwrap()
7329            .shell
7330            .expect("shell capabilities should be present");
7331        assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
7332        assert_eq!(
7333            shell.strip_env,
7334            Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
7335        );
7336        assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
7337    }
7338
7339    #[test]
7340    fn shell_capabilities_require_non_empty_allowlist() {
7341        let err = RuntimeManifest::from_yaml_str(
7342            r#"
7343name: cap
7344version: 0.0.1
7345artifacts: []
7346capabilities:
7347  shell:
7348    allow: []
7349"#,
7350        )
7351        .unwrap_err();
7352
7353        let msg = err.to_string();
7354        assert!(msg.contains("capabilities.shell.allow"));
7355        assert!(msg.contains("at least one"));
7356    }
7357
7358    fn shell_allow_error(entry: &str) -> String {
7359        let yaml = format!(
7360            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  shell:\n    allow: [bash, {}]\n",
7361            serde_json::to_string(entry).unwrap()
7362        );
7363        RuntimeManifest::from_yaml_str(&yaml)
7364            .expect_err(&format!("{entry:?} must be refused"))
7365            .to_string()
7366    }
7367
7368    #[test]
7369    fn shell_allow_refuses_an_entry_that_is_not_a_bare_name() {
7370        for (entry, reason) in [
7371            ("/tmp/uv-tool/bin/uv", "is a path"),
7372            ("bin/uv", "is a path"),
7373            ("..", "names a directory"),
7374            (".", "names a directory"),
7375            ("", "is empty"),
7376            ("a b", "contains whitespace"),
7377            ("uv\t", "contains whitespace"),
7378            ("uv\0", "contains a NUL byte"),
7379        ] {
7380            let message = shell_allow_error(entry);
7381            assert!(
7382                message.contains("'capabilities.shell.allow[1]'"),
7383                "{entry:?}: {message}"
7384            );
7385            assert!(
7386                message.contains(&format!(
7387                    "'{}' {reason}; an entry is a bare binary name",
7388                    entry.escape_debug()
7389                )),
7390                "{entry:?}: {message}"
7391            );
7392        }
7393    }
7394
7395    #[test]
7396    fn shell_allow_accepts_bare_names() {
7397        let manifest = RuntimeManifest::from_yaml_str(
7398            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  shell:\n    allow: [bash, python3.12, git-lfs, \"c++\"]\n",
7399        )
7400        .unwrap();
7401        assert_eq!(
7402            manifest.capabilities.unwrap().shell.unwrap().allow,
7403            ["bash", "python3.12", "git-lfs", "c++"]
7404        );
7405    }
7406
7407    #[test]
7408    fn interpreter_runtime_parses_accepted_shape() {
7409        let manifest = RuntimeManifest::from_yaml_str(
7410            r#"
7411name: cap
7412version: 0.0.1
7413artifacts: []
7414capabilities:
7415  shell:
7416    allow:
7417      - python3
7418    interpreter_runtime:
7419      - binary: python3
7420        dirs:
7421          - path: /usr/lib/python3.11
7422            list_dir: true
7423          - path: /usr/lib/python3.11/lib-dynload
7424            list_dir: false
7425"#,
7426        )
7427        .unwrap();
7428
7429        let shell = manifest.capabilities.unwrap().shell.unwrap();
7430        assert_eq!(
7431            shell.interpreter_runtime,
7432            vec![InterpreterRuntimeGrant {
7433                binary: "python3".to_string(),
7434                dirs: vec![
7435                    InterpreterRuntimeDir {
7436                        path: "/usr/lib/python3.11".to_string(),
7437                        list_dir: true,
7438                    },
7439                    InterpreterRuntimeDir {
7440                        path: "/usr/lib/python3.11/lib-dynload".to_string(),
7441                        list_dir: false,
7442                    },
7443                ],
7444            }]
7445        );
7446    }
7447
7448    #[test]
7449    fn interpreter_runtime_defaults_to_empty_when_absent() {
7450        let manifest = RuntimeManifest::from_yaml_str(
7451            r#"
7452name: cap
7453version: 0.0.1
7454artifacts: []
7455capabilities:
7456  shell:
7457    allow:
7458      - python3
7459"#,
7460        )
7461        .unwrap();
7462
7463        let shell = manifest.capabilities.unwrap().shell.unwrap();
7464        assert!(shell.interpreter_runtime.is_empty());
7465    }
7466
7467    /// Shared helper: parse a manifest whose sole `interpreter_runtime` grant is `grant_yaml`
7468    /// (indented to sit under `interpreter_runtime:`), returning the error message.
7469    fn interpreter_runtime_reject(grant_yaml: &str) -> String {
7470        let yaml = format!(
7471            r#"
7472name: cap
7473version: 0.0.1
7474artifacts: []
7475capabilities:
7476  shell:
7477    allow:
7478      - python3
7479    interpreter_runtime:
7480{grant_yaml}
7481"#,
7482        );
7483        RuntimeManifest::from_yaml_str(&yaml)
7484            .expect_err("grant should be rejected")
7485            .to_string()
7486    }
7487
7488    #[test]
7489    fn interpreter_runtime_rejects_binary_not_in_allow() {
7490        let msg = interpreter_runtime_reject(
7491            "      - binary: ruby\n        dirs:\n          - path: /usr/lib/ruby\n            list_dir: true\n",
7492        );
7493        assert!(
7494            msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
7495            "{msg}"
7496        );
7497        assert!(msg.contains("ruby"), "{msg}");
7498        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
7499    }
7500
7501    #[test]
7502    fn interpreter_runtime_rejects_relative_path() {
7503        let msg = interpreter_runtime_reject(
7504            "      - binary: python3\n        dirs:\n          - path: usr/lib/python3.11\n            list_dir: true\n",
7505        );
7506        assert!(
7507            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
7508            "{msg}"
7509        );
7510        assert!(msg.contains("absolute"), "{msg}");
7511    }
7512
7513    // ── capabilities.plan (capsule-wide) ─────────────────────────────────────
7514
7515    /// A capsule manifest whose capsule-wide capabilities carry `plan: <block>`.
7516    fn manifest_with_plan(block: &str) -> String {
7517        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n{block}")
7518    }
7519
7520    #[test]
7521    fn plan_submit_true_grants_the_capsule() {
7522        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n"))
7523            .expect("a plan block parses");
7524        assert_eq!(
7525            manifest.capabilities.unwrap().plan,
7526            Some(PlanCapabilities { submit: true })
7527        );
7528    }
7529
7530    /// `submit: false` and an absent `plan:` block both leave the capsule ungranted; the
7531    /// difference between them is only whether the operator wrote the denial down.
7532    #[test]
7533    fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
7534        let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: false\n"))
7535            .expect("an explicit denial parses");
7536        assert_eq!(
7537            explicit.capabilities.unwrap().plan,
7538            Some(PlanCapabilities { submit: false })
7539        );
7540
7541        let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
7542            .expect("no capabilities block parses");
7543        assert!(absent.capabilities.is_none());
7544    }
7545
7546    /// The capability is never inferred: a `plan:` block that omits `submit:` is a parse error
7547    /// naming the key, exactly as `task_io:` without `read:` is.
7548    #[test]
7549    fn plan_without_submit_is_rejected() {
7550        let err = RuntimeManifest::from_yaml_str(&manifest_with_plan("    {}\n"))
7551            .expect_err("an empty plan block is refused");
7552        let msg = err.to_string();
7553        assert!(msg.contains("capabilities.plan.submit"), "{msg}");
7554        assert!(msg.contains("never"), "{msg}");
7555    }
7556
7557    /// An unknown key inside the block is reported by path, like every other capability block.
7558    #[test]
7559    fn an_unknown_plan_key_is_reported_by_path() {
7560        let manifest =
7561            RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n    depth: 3\n"))
7562                .expect("an unknown key is a warning, not a parse error");
7563        assert!(
7564            manifest
7565                .unknown_keys
7566                .iter()
7567                .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
7568            "{:?}",
7569            manifest.unknown_keys
7570        );
7571    }
7572
7573    // ── capabilities.install (capsule-wide) ──────────────────────────────────
7574
7575    /// A capsule manifest whose capsule-wide capabilities carry `install: <block>`.
7576    fn manifest_with_install(block: &str) -> String {
7577        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  install:\n{block}")
7578    }
7579
7580    #[test]
7581    fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
7582        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
7583            "    skill: [ana/code-review, \"ana/style-*\"]\n    tool: [\"local/b-*\", local/a, \"murmur/*\", \"*\"]\n",
7584        ))
7585        .expect("an install block parses");
7586        assert_eq!(
7587            manifest.capabilities.unwrap().install,
7588            Some(InstallCapabilities {
7589                skill: vec!["ana/code-review".into(), "ana/style-*".into()],
7590                tool: vec![
7591                    "local/b-*".into(),
7592                    "local/a".into(),
7593                    "murmur/*".into(),
7594                    "*".into()
7595                ],
7596                from: InstallFrom::Stores,
7597            })
7598        );
7599    }
7600
7601    /// Either list alone is a grant; the other defaults to empty, which admits nothing of its
7602    /// kind.
7603    #[test]
7604    fn capabilities_install_accepts_either_list_alone() {
7605        let skill_only =
7606            RuntimeManifest::from_yaml_str(&manifest_with_install("    skill: [local/a]\n"))
7607                .expect("skill alone parses");
7608        assert_eq!(
7609            skill_only.capabilities.unwrap().install,
7610            Some(InstallCapabilities {
7611                skill: vec!["local/a".into()],
7612                tool: vec![],
7613                from: InstallFrom::Stores,
7614            })
7615        );
7616        let tool_only =
7617            RuntimeManifest::from_yaml_str(&manifest_with_install("    tool: [\"local/b-*\"]\n"))
7618                .expect("tool alone parses");
7619        assert_eq!(
7620            tool_only.capabilities.unwrap().install,
7621            Some(InstallCapabilities {
7622                skill: vec![],
7623                tool: vec!["local/b-*".into()],
7624                from: InstallFrom::Stores,
7625            })
7626        );
7627    }
7628
7629    #[test]
7630    fn capabilities_install_absent_is_none() {
7631        let manifest = RuntimeManifest::from_yaml_str(
7632            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n    submit: true\n",
7633        )
7634        .expect("a capabilities block without install parses");
7635        assert_eq!(manifest.capabilities.unwrap().install, None);
7636    }
7637
7638    fn install_from(list: &str) -> Result<InstallFrom, RuntimeManifestError> {
7639        RuntimeManifest::from_yaml_str(&manifest_with_install(&format!(
7640            "    tool: [\"murmur/tool-*\"]\n    from: {list}\n"
7641        )))
7642        .map(|manifest| manifest.capabilities.unwrap().install.unwrap().from)
7643    }
7644
7645    #[test]
7646    fn install_from_absent_is_stores() {
7647        let manifest =
7648            RuntimeManifest::from_yaml_str(&manifest_with_install("    tool: [local/a]\n"))
7649                .unwrap();
7650        assert_eq!(
7651            manifest.capabilities.unwrap().install.unwrap().from,
7652            InstallFrom::Stores
7653        );
7654    }
7655
7656    #[test]
7657    fn install_from_accepts_stores_alone_and_with_sources_in_either_order() {
7658        assert_eq!(install_from("[stores]").unwrap(), InstallFrom::Stores);
7659        assert_eq!(
7660            install_from("[stores, sources]").unwrap(),
7661            InstallFrom::StoresAndSources
7662        );
7663        assert_eq!(
7664            install_from("[sources, stores]").unwrap(),
7665            InstallFrom::StoresAndSources
7666        );
7667        assert_eq!(InstallFrom::StoresAndSources.as_list(), "stores, sources");
7668        assert!(InstallFrom::StoresAndSources.sources());
7669        assert!(!InstallFrom::Stores.sources());
7670    }
7671
7672    #[test]
7673    fn install_from_refuses_empty_sourceless_duplicate_and_unknown_lists() {
7674        for (list, why) in [
7675            ("[]", "is empty"),
7676            ("[sources]", "does not name stores"),
7677            ("[stores, stores]", "names \"stores\" twice"),
7678            ("[github]", "names \"github\", which is not a place"),
7679        ] {
7680            match install_from(list) {
7681                Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
7682                    assert_eq!(field, "capabilities.install.from", "{list}");
7683                    assert!(message.contains(why), "{list}: {message}");
7684                    assert!(
7685                        message.contains("expected [stores] or [stores, sources]"),
7686                        "{list}: {message}"
7687                    );
7688                }
7689                other => panic!("{list}: expected InvalidCapabilities, got {other:?}"),
7690            }
7691        }
7692    }
7693
7694    #[test]
7695    fn install_from_is_no_unknown_key() {
7696        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
7697            "    tool: [local/a]\n    from: [stores]\n",
7698        ))
7699        .unwrap();
7700        assert!(
7701            manifest
7702                .unknown_keys
7703                .iter()
7704                .all(|key| key.block_path != "capabilities.install"),
7705            "{:?}",
7706            manifest.unknown_keys
7707        );
7708    }
7709
7710    #[test]
7711    fn install_from_capabilities_install_is_refused_under_transport_process() {
7712        let yaml = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  install:\n    \
7713                    tool: [\"murmur/tool-*\"]\ninference:\n  transport: process\n  \
7714                    command: claude\n  model: claude-haiku-4-5-20251001\n  driver:\n    \
7715                    artifact: murmur/driver-claude-code\n";
7716        match RuntimeManifest::from_yaml_str(yaml) {
7717            Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
7718                assert_eq!(field, "capabilities.install");
7719                assert_eq!(
7720                    message,
7721                    "is not valid with transport: process; the harness owns its tool list, so \
7722                     a pulled artifact could never reach it"
7723                );
7724            }
7725            other => panic!("expected InvalidCapabilities, got {other:?}"),
7726        }
7727    }
7728
7729    /// A block that names nothing is refused rather than read as "grant nothing": omitting the
7730    /// block is how a capsule grants nothing.
7731    #[test]
7732    fn capabilities_install_that_names_nothing_is_refused() {
7733        for block in [
7734            "    {}\n",
7735            "    skill: []\n",
7736            "    skill: []\n    tool: []\n",
7737            "    driver: [x]\n",
7738        ] {
7739            let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
7740                .expect_err("an install block naming nothing is refused");
7741            match &err {
7742                RuntimeManifestError::InvalidCapabilities { field, message } => {
7743                    assert_eq!(field, "capabilities.install", "{block:?}");
7744                    assert!(message.contains("at least one"), "{message}");
7745                    assert!(message.contains("omit the block"), "{message}");
7746                }
7747                other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
7748            }
7749        }
7750    }
7751
7752    #[test]
7753    fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
7754        for entry in [
7755            "a*b", "*x", "**", "Foo", "-a", "a/b/c", " ", "*/x", "a/*x", "a/x*y", "A/x",
7756        ] {
7757            for key in ["skill", "tool"] {
7758                let block = format!("    {key}: [{entry:?}]\n");
7759                let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
7760                    .expect_err("a bad entry is refused");
7761                match &err {
7762                    RuntimeManifestError::InvalidArtifactName {
7763                        field,
7764                        message,
7765                        hint,
7766                    } => {
7767                        assert_eq!(field, &format!("capabilities.install.{key}[0]"));
7768                        assert!(message.contains(&format!("{entry:?}")), "{message}");
7769                        assert!(
7770                            message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
7771                            "{message}"
7772                        );
7773                        assert_eq!(hint, &None, "{entry:?}");
7774                    }
7775                    other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
7776                }
7777            }
7778        }
7779    }
7780
7781    /// An entry with no scope is refused at its index with the hint naming its scoped forms.
7782    #[test]
7783    fn capabilities_install_refuses_an_unscoped_entry_with_the_scoped_hint() {
7784        for (entry, scoped) in [
7785            ("tool-*", "local/tool-*"),
7786            ("mur*", "local/mur*"),
7787            ("tool-x", "local/tool-x"),
7788            ("murmur-tool-editor", "murmur/tool-editor"),
7789        ] {
7790            let block = format!("    tool: [\"local/ok\", {entry:?}]\n");
7791            let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
7792                .expect_err("an unscoped entry is refused");
7793            match &err {
7794                RuntimeManifestError::InvalidArtifactName {
7795                    field,
7796                    message,
7797                    hint,
7798                } => {
7799                    assert_eq!(field, "capabilities.install.tool[1]");
7800                    assert_eq!(
7801                        message,
7802                        &format!(
7803                            "entry {entry:?} is invalid ('{entry}' names no scope); expected {}",
7804                            crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
7805                        )
7806                    );
7807                    let hint = hint.as_deref().expect("an unscoped entry carries a hint");
7808                    assert!(hint.contains(scoped), "{entry:?}: {hint}");
7809                }
7810                other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
7811            }
7812            assert!(
7813                err.to_string().contains("'capabilities.install.tool[1]'"),
7814                "{err}"
7815            );
7816        }
7817    }
7818
7819    /// The grant is held by the session, so a per-artifact block is refused on every role.
7820    #[test]
7821    fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
7822        for runtime in ["tool", "hook", "driver", "skill"] {
7823            let yaml = format!(
7824                "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/a\n    version: 0.1.0\n    \
7825                 runtime: {runtime}\n    capabilities:\n      install:\n        skill: [x]\n"
7826            );
7827            let err = RuntimeManifest::from_yaml_str(&yaml)
7828                .expect_err("a per-artifact install block is refused");
7829            match &err {
7830                RuntimeManifestError::InvalidArtifact { index, message } => {
7831                    assert_eq!(*index, 0);
7832                    assert!(message.contains("capabilities.install"), "{message}");
7833                    assert!(message.contains("capsule-wide"), "{message}");
7834                }
7835                other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
7836            }
7837        }
7838    }
7839
7840    /// An unknown key inside the block is reported by path and grants nothing.
7841    #[test]
7842    fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
7843        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
7844            "    skill: [local/a]\n    driver: [x]\n",
7845        ))
7846        .expect("an unknown key beside a real entry is a warning, not a parse error");
7847        assert!(
7848            manifest
7849                .unknown_keys
7850                .iter()
7851                .any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
7852            "{:?}",
7853            manifest.unknown_keys
7854        );
7855        assert_eq!(
7856            manifest.capabilities.unwrap().install,
7857            Some(InstallCapabilities {
7858                skill: vec!["local/a".into()],
7859                tool: vec![],
7860                from: InstallFrom::Stores,
7861            })
7862        );
7863    }
7864
7865    // ── capabilities.task_io (per-hook, honored only on runtime: hook) ────────
7866
7867    /// A capsule manifest with one artifact entry carrying `capabilities: task_io: <block>`.
7868    fn manifest_with_task_io(runtime: &str, block: &str) -> String {
7869        format!(
7870            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/gate\n    version: 1.2.3\n    \
7871             runtime: {runtime}\n    capabilities:\n      task_io:\n{block}"
7872        )
7873    }
7874
7875    #[test]
7876    fn task_io_read_true_grants_the_hook() {
7877        let manifest =
7878            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: true\n"))
7879                .expect("task_io on a hook entry parses");
7880        assert_eq!(
7881            manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
7882            Some(TaskIoCapabilities { read: true })
7883        );
7884    }
7885
7886    /// `read: false` and an absent `task_io:` block both leave the hook ungranted; the
7887    /// difference between them is only whether the operator wrote the denial down.
7888    #[test]
7889    fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
7890        let explicit =
7891            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: false\n"))
7892                .expect("an explicit denial parses");
7893        assert_eq!(
7894            explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
7895            Some(TaskIoCapabilities { read: false })
7896        );
7897
7898        let absent = RuntimeManifest::from_yaml_str(
7899            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/gate\n    version: 1.2.3\n    \
7900             runtime: hook\n",
7901        )
7902        .expect("no capabilities block parses");
7903        assert!(absent.artifacts[0].capabilities.is_none());
7904    }
7905
7906    /// The capability is never inferred: a `task_io:` block that omits `read:` is a parse
7907    /// error naming the key, following `interpreter_runtime[].dirs[].list_dir`.
7908    #[test]
7909    fn task_io_without_read_is_rejected() {
7910        let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        {}\n"))
7911            .unwrap_err();
7912        let msg = err.to_string();
7913        assert!(
7914            msg.contains("capabilities.task_io.read"),
7915            "error was: {msg}"
7916        );
7917        assert!(msg.contains("explicitly"), "error was: {msg}");
7918    }
7919
7920    /// Nothing outside a hook can be handed the import, so declaring the key there is a parse
7921    /// error rather than a silently inert grant.
7922    #[test]
7923    fn task_io_outside_a_hook_entry_is_rejected() {
7924        for runtime in ["tool", "driver", "skill"] {
7925            let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
7926                runtime,
7927                "        read: true\n",
7928            ))
7929            .unwrap_err();
7930            let msg = err.to_string();
7931            assert!(
7932                msg.contains("capabilities") && msg.contains("runtime: hook"),
7933                "{runtime} entry: error was: {msg}"
7934            );
7935        }
7936    }
7937
7938    // ── capabilities.filesystem.records (per-hook, honored only on runtime: hook) ──
7939
7940    /// A capsule manifest with one artifact entry carrying `capabilities: filesystem: <block>`.
7941    fn manifest_with_filesystem(runtime: &str, block: &str) -> String {
7942        format!(
7943            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/thing\n    version: 0.1.0\n    \
7944             runtime: {runtime}\n    capabilities:\n      filesystem:\n{block}"
7945        )
7946    }
7947
7948    /// `records: true` on a hook entry grants it; absent, it is `false`.
7949    #[test]
7950    fn filesystem_records_on_a_hook_entry_parses() {
7951        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
7952            "hook",
7953            "        records: true\n",
7954        ))
7955        .expect("records on a hook entry parses");
7956        let filesystem = manifest.artifacts[0]
7957            .capabilities
7958            .as_ref()
7959            .unwrap()
7960            .filesystem
7961            .as_ref()
7962            .unwrap();
7963        assert!(filesystem.records);
7964        assert_eq!(filesystem.scope, None);
7965
7966        let scoped =
7967            RuntimeManifest::from_yaml_str(&manifest_with_filesystem("hook", "        scope: .\n"))
7968                .unwrap();
7969        let filesystem = scoped.artifacts[0]
7970            .capabilities
7971            .as_ref()
7972            .unwrap()
7973            .filesystem
7974            .as_ref()
7975            .unwrap();
7976        assert!(!filesystem.records);
7977        assert!(scoped.unknown_keys.is_empty(), "{:?}", scoped.unknown_keys);
7978    }
7979
7980    /// Beside a `scope`, on any other role and in the capsule-wide block, `records: true` is
7981    /// refused, naming the field.
7982    #[test]
7983    fn filesystem_records_is_refused_beside_a_scope_off_a_hook_and_capsule_wide() {
7984        let beside = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
7985            "hook",
7986            "        records: true\n        scope: .\n",
7987        ))
7988        .unwrap_err()
7989        .to_string();
7990        assert!(
7991            beside.contains("capabilities.filesystem.records"),
7992            "{beside}"
7993        );
7994        assert!(beside.contains("capabilities.filesystem.scope"), "{beside}");
7995        for runtime in ["tool", "driver"] {
7996            let off_hook = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
7997                runtime,
7998                "        records: true\n",
7999            ))
8000            .unwrap_err()
8001            .to_string();
8002            assert!(
8003                off_hook.contains("capabilities.filesystem.records"),
8004                "{off_hook}"
8005            );
8006            assert!(off_hook.contains("runtime: hook"), "{off_hook}");
8007        }
8008        let capsule_wide = RuntimeManifest::from_yaml_str(
8009            "name: cap\nversion: 0.0.1\ncapabilities:\n  filesystem:\n    records: true\n",
8010        )
8011        .unwrap_err()
8012        .to_string();
8013        assert!(
8014            capsule_wide.contains("capabilities.filesystem.records"),
8015            "{capsule_wide}"
8016        );
8017    }
8018
8019    /// The capsule-wide block reaches capsule, tool and driver components, none of which can
8020    /// receive the import — so it is rejected there too, naming the key.
8021    #[test]
8022    fn task_io_in_the_capsule_wide_block_is_rejected() {
8023        let err = RuntimeManifest::from_yaml_str(
8024            "name: cap\nversion: 0.0.1\ncapabilities:\n  task_io:\n    read: true\n",
8025        )
8026        .unwrap_err();
8027        let msg = err.to_string();
8028        assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
8029        assert!(msg.contains("runtime: hook"), "error was: {msg}");
8030    }
8031
8032    // ── capabilities.conversation (per-hook, honored only on runtime: hook) ───
8033
8034    /// A capsule manifest with one artifact entry carrying `capabilities: conversation: <block>`.
8035    fn manifest_with_conversation(runtime: &str, block: &str) -> String {
8036        format!(
8037            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/memory\n    version: 1.2.3\n    \
8038             runtime: {runtime}\n    capabilities:\n      conversation:\n{block}"
8039        )
8040    }
8041
8042    #[test]
8043    fn conversation_read_lowers_onto_the_hook_entry() {
8044        for (declared, expected) in [("true", true), ("false", false)] {
8045            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8046                "hook",
8047                &format!("        read: {declared}\n"),
8048            ))
8049            .expect("conversation on a hook entry parses");
8050            assert_eq!(
8051                manifest.artifacts[0]
8052                    .capabilities
8053                    .as_ref()
8054                    .unwrap()
8055                    .conversation,
8056                Some(ConversationCapabilities { read: expected })
8057            );
8058        }
8059    }
8060
8061    /// The capability is never inferred: a block that omits `read:` is a parse error naming the
8062    /// key, exactly as `capabilities.task_io.read` is.
8063    #[test]
8064    fn conversation_without_read_is_rejected() {
8065        let err =
8066            RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", "        {}\n"))
8067                .unwrap_err();
8068        let msg = err.to_string();
8069        assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
8070        assert!(msg.contains("explicitly"), "was: {msg}");
8071    }
8072
8073    /// Only a hook's world can import the interface, so declaring the grant on any other role is
8074    /// a parse error rather than a silently inert block.
8075    #[test]
8076    fn conversation_outside_a_hook_entry_is_rejected() {
8077        for runtime in ["tool", "driver", "skill"] {
8078            let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8079                runtime,
8080                "        read: true\n",
8081            ))
8082            .unwrap_err();
8083            let msg = err.to_string();
8084            assert!(
8085                msg.contains("capabilities") && msg.contains("runtime: hook"),
8086                "{runtime} entry: error was: {msg}"
8087            );
8088        }
8089    }
8090
8091    /// Unlike `task_io`, the capsule-wide block parses: it is inert, and the runtime says so once
8092    /// as `W-SEC-016` rather than refusing a manifest an operator can fix at leisure.
8093    #[test]
8094    fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
8095        let manifest = RuntimeManifest::from_yaml_str(
8096            "name: cap\nversion: 0.0.1\ncapabilities:\n  conversation:\n    read: true\n",
8097        )
8098        .expect("a capsule-wide block is accepted");
8099        assert_eq!(
8100            manifest.capabilities.unwrap().conversation,
8101            Some(ConversationCapabilities { read: true })
8102        );
8103    }
8104
8105    // ── context.record / context.record_store ────────────────────────────────
8106
8107    /// The record is on unless the manifest says otherwise, and `record_store` defaults to
8108    /// nothing — the runtime substitutes the capsule name.
8109    #[test]
8110    fn a_context_block_records_by_default() {
8111        let context = context_of("context:\n  max_tokens: 1000\n").unwrap();
8112        assert!(context.record);
8113        assert_eq!(context.record_store, None);
8114    }
8115
8116    #[test]
8117    fn record_off_and_on_are_the_two_accepted_values() {
8118        assert!(!context_of("context:\n  record: off\n").unwrap().record);
8119        assert!(context_of("context:\n  record: on\n").unwrap().record);
8120
8121        let err = RuntimeManifest::from_yaml_str(
8122            "name: cap\nversion: 0.0.1\ncontext:\n  record: sometimes\n",
8123        )
8124        .unwrap_err();
8125        let msg = err.to_string();
8126        assert!(msg.contains("context.record"), "was: {msg}");
8127        assert!(msg.contains("sometimes"), "was: {msg}");
8128    }
8129
8130    /// `record_store` is a name the runtime turns into a directory, so it is kept verbatim apart
8131    /// from surrounding whitespace; its *shape* is refused at launch, where the path is built.
8132    #[test]
8133    fn record_store_is_kept_verbatim_and_trimmed() {
8134        assert_eq!(
8135            context_of("context:\n  record_store: '  shey  '\n")
8136                .unwrap()
8137                .record_store,
8138            Some("shey".to_string())
8139        );
8140        assert_eq!(
8141            context_of("context:\n  record: off\n  record_store: shey\n")
8142                .unwrap()
8143                .record_store,
8144            Some("shey".to_string()),
8145            "a store declared beside record: off is kept and simply never used"
8146        );
8147    }
8148
8149    #[test]
8150    fn interpreter_runtime_rejects_missing_list_dir() {
8151        let msg = interpreter_runtime_reject(
8152            "      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.11\n",
8153        );
8154        assert!(
8155            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
8156            "{msg}"
8157        );
8158        assert!(msg.contains("explicitly"), "{msg}");
8159    }
8160
8161    #[test]
8162    fn interpreter_runtime_rejects_empty_dirs() {
8163        let msg = interpreter_runtime_reject("      - binary: python3\n        dirs: []\n");
8164        assert!(
8165            msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
8166            "{msg}"
8167        );
8168        assert!(msg.contains("at least one"), "{msg}");
8169    }
8170
8171    #[test]
8172    fn staged_runtime_parses_accepted_shape() {
8173        let manifest = RuntimeManifest::from_yaml_str(
8174            r#"
8175name: cap
8176version: 0.0.1
8177artifacts: []
8178capabilities:
8179  containment: sealed
8180  shell:
8181    allow:
8182      - python3
8183      - bash
8184    staged_runtime:
8185      - binary: python3
8186        source_path: /opt/testbed/conda/envs/django__django
8187        pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
8188"#,
8189        )
8190        .unwrap();
8191
8192        let shell = manifest.capabilities.unwrap().shell.unwrap();
8193        assert_eq!(
8194            shell.staged_runtime,
8195            vec![StagedRuntimeGrant {
8196                binary: "python3".to_string(),
8197                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
8198                pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
8199            }]
8200        );
8201        // The two mechanisms are alternatives, so an accepted `staged_runtime` grant leaves the
8202        // interpreter_runtime list untouched rather than populating it as a side effect.
8203        assert!(shell.interpreter_runtime.is_empty());
8204    }
8205
8206    #[test]
8207    fn staged_runtime_defaults_to_empty_when_absent() {
8208        let manifest = RuntimeManifest::from_yaml_str(
8209            r#"
8210name: cap
8211version: 0.0.1
8212artifacts: []
8213capabilities:
8214  shell:
8215    allow:
8216      - python3
8217"#,
8218        )
8219        .unwrap();
8220
8221        let shell = manifest.capabilities.unwrap().shell.unwrap();
8222        assert!(shell.staged_runtime.is_empty());
8223    }
8224
8225    /// Shared helper: parse a manifest whose sole `staged_runtime` grant is `grant_yaml` (indented
8226    /// to sit under `staged_runtime:`), returning the error message. `extra` is spliced into the
8227    /// same `shell` block, which is how the mutual-exclusion case adds an `interpreter_runtime`.
8228    fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
8229        let yaml = format!(
8230            r#"
8231name: cap
8232version: 0.0.1
8233artifacts: []
8234capabilities:
8235  shell:
8236    allow:
8237      - python3
8238{extra}    staged_runtime:
8239{grant_yaml}
8240"#,
8241        );
8242        RuntimeManifest::from_yaml_str(&yaml)
8243            .expect_err("grant should be rejected")
8244            .to_string()
8245    }
8246
8247    #[test]
8248    fn staged_runtime_rejects_binary_not_in_allow() {
8249        let msg = staged_runtime_reject(
8250            "      - binary: ruby\n        source_path: /opt/ruby\n        pin: ruby-3.2.2\n",
8251            "",
8252        );
8253        assert!(
8254            msg.contains("capabilities.shell.staged_runtime[0].binary"),
8255            "{msg}"
8256        );
8257        assert!(msg.contains("ruby"), "{msg}");
8258        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
8259    }
8260
8261    #[test]
8262    fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
8263        let msg = staged_runtime_reject(
8264            "      - binary: python3\n        source_path: /opt/py\n        pin: cpython-3.9.19\n",
8265            "    interpreter_runtime:\n      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.9\n            list_dir: true\n",
8266        );
8267        assert!(
8268            msg.contains("capabilities.shell.staged_runtime[0].binary"),
8269            "{msg}"
8270        );
8271        assert!(msg.contains("python3"), "{msg}");
8272        assert!(msg.contains("interpreter_runtime"), "{msg}");
8273        assert!(msg.contains("mutually exclusive"), "{msg}");
8274    }
8275
8276    #[test]
8277    fn staged_runtime_rejects_relative_source_path() {
8278        let msg = staged_runtime_reject(
8279            "      - binary: python3\n        source_path: opt/testbed/conda\n        pin: cpython-3.9.19\n",
8280            "",
8281        );
8282        assert!(
8283            msg.contains("capabilities.shell.staged_runtime[0].source_path"),
8284            "{msg}"
8285        );
8286        assert!(msg.contains("absolute"), "{msg}");
8287    }
8288
8289    #[test]
8290    fn staged_runtime_rejects_missing_or_empty_pin() {
8291        // Omitted entirely, and present-but-blank: both are "unpinned", and the field exists
8292        // precisely so that state is unrepresentable.
8293        for grant in [
8294            "      - binary: python3\n        source_path: /opt/testbed/conda\n",
8295            "      - binary: python3\n        source_path: /opt/testbed/conda\n        pin: \"   \"\n",
8296        ] {
8297            let msg = staged_runtime_reject(grant, "");
8298            assert!(
8299                msg.contains("capabilities.shell.staged_runtime[0].pin"),
8300                "{msg}"
8301            );
8302            assert!(msg.contains("never inferred"), "{msg}");
8303        }
8304    }
8305
8306    #[test]
8307    fn parses_inference_block() {
8308        let manifest = RuntimeManifest::from_yaml_str(
8309            r#"
8310name: cap
8311version: 0.0.1
8312artifacts:
8313  - name: murmur/driver-anthropic
8314    version: 0.1.0
8315    runtime: driver
8316    gateway:
8317      endpoint: http://127.0.0.1:8080
8318      api_key: literal-key
8319inference:
8320  transport: http
8321  model: test-model
8322  driver:
8323    artifact: murmur/driver-anthropic
8324    config:
8325      temperature: 0.2
8326      headers:
8327        x-test: true
8328"#,
8329        )
8330        .unwrap();
8331
8332        assert_eq!(
8333            manifest.artifacts[0].gateway,
8334            Some(ArtifactGateway {
8335                endpoint: "http://127.0.0.1:8080".to_string(),
8336                api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
8337                keyless: false,
8338            })
8339        );
8340        let inference = manifest.inference.expect("inference should exist");
8341        assert_eq!(inference.transport, "http");
8342        assert_eq!(inference.model, "test-model");
8343        let driver = inference.driver.as_ref().expect("driver should be present");
8344        assert_eq!(driver.artifact, "murmur/driver-anthropic");
8345        assert_eq!(
8346            driver.config,
8347            Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
8348        );
8349        assert!(inference.system_prompt.is_none());
8350        assert!(inference.system_prompt_file.is_none());
8351    }
8352
8353    /// A capsule whose one tool entry declares `gateway:` with `block` as its body, indented
8354    /// under the key.
8355    fn tool_gateway_manifest(block: &str) -> String {
8356        format!(
8357            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/web-search\n    version: 0.1.0\n    runtime: tool\n    gateway:{block}\n"
8358        )
8359    }
8360
8361    fn gateway_endpoint_error(endpoint: &str) -> String {
8362        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
8363            "\n      endpoint: \"{endpoint}\""
8364        )))
8365        .unwrap_err()
8366        .to_string()
8367    }
8368
8369    #[test]
8370    fn gateway_endpoint_https_is_accepted() {
8371        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8372            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
8373        ))
8374        .unwrap();
8375        assert_eq!(
8376            manifest.artifacts[0].gateway,
8377            Some(ArtifactGateway {
8378                endpoint: "https://api.tavily.com".to_string(),
8379                api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
8380                keyless: false,
8381            })
8382        );
8383    }
8384
8385    #[test]
8386    fn gateway_endpoint_http_localhost_is_accepted() {
8387        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8388            "\n      endpoint: http://localhost:11434\n      keyless: true",
8389        ))
8390        .unwrap();
8391        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
8392        assert_eq!(gateway.endpoint, "http://localhost:11434");
8393        assert_eq!(gateway.api_key, None);
8394        assert!(gateway.keyless);
8395    }
8396
8397    #[test]
8398    fn gateway_endpoint_http_loopback_ip_is_accepted() {
8399        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8400            "\n      endpoint: http://127.0.0.1:8080\n      api_key: ${LOCAL_KEY}",
8401        ))
8402        .unwrap();
8403        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
8404        assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
8405        assert_eq!(
8406            gateway.api_key,
8407            Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
8408        );
8409    }
8410
8411    #[test]
8412    fn gateway_endpoint_without_scheme_is_refused() {
8413        let msg = gateway_endpoint_error("api.tavily.com");
8414        assert!(msg.contains("gateway.endpoint"), "{msg}");
8415        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
8416        assert!(msg.contains("does not parse as a URL"), "{msg}");
8417    }
8418
8419    #[test]
8420    fn gateway_endpoint_ftp_scheme_is_refused() {
8421        let msg = gateway_endpoint_error("ftp://example.com");
8422        assert!(msg.contains("gateway.endpoint"), "{msg}");
8423        assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
8424    }
8425
8426    #[test]
8427    fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
8428        let msg = gateway_endpoint_error("http://api.attacker.example.com");
8429        assert!(msg.contains("gateway.endpoint"), "{msg}");
8430        assert!(msg.contains("api.attacker.example.com"), "{msg}");
8431        assert!(msg.contains("non-loopback"), "{msg}");
8432    }
8433
8434    #[test]
8435    fn gateway_endpoint_with_a_query_is_refused() {
8436        let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
8437        assert!(msg.contains("gateway.endpoint"), "{msg}");
8438        assert!(msg.contains("carries a query"), "{msg}");
8439    }
8440
8441    #[test]
8442    fn gateway_endpoint_with_a_fragment_is_refused() {
8443        let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
8444        assert!(msg.contains("gateway.endpoint"), "{msg}");
8445        assert!(msg.contains("carries a fragment"), "{msg}");
8446    }
8447
8448    #[test]
8449    fn gateway_endpoint_with_userinfo_is_refused() {
8450        let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
8451        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
8452        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
8453        assert!(msg.contains("host 'api.example.com'"), "{msg}");
8454        assert!(msg.contains("gateway.api_key"), "{msg}");
8455        assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
8456        assert!(
8457            !msg.contains("sk-in-url"),
8458            "a password in the URL is never echoed: {msg}"
8459        );
8460    }
8461
8462    #[test]
8463    fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
8464        let msg = gateway_endpoint_error("https://user:sk-in-url@");
8465        assert!(msg.contains("does not parse as a URL"), "{msg}");
8466        assert!(
8467            !msg.contains("sk-in-url"),
8468            "a password in the URL is never echoed: {msg}"
8469        );
8470    }
8471
8472    #[test]
8473    fn gateway_endpoint_with_a_masquerading_host_is_refused() {
8474        let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
8475        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
8476        assert!(
8477            msg.contains("addresses host '127.0.0.1'"),
8478            "the message names the host the URL really addresses: {msg}"
8479        );
8480    }
8481
8482    #[test]
8483    fn gateway_endpoint_with_interpolation_is_refused() {
8484        let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
8485        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
8486        assert!(msg.contains("contains '${'"), "{msg}");
8487        assert!(msg.contains("not interpolated"), "{msg}");
8488    }
8489
8490    #[test]
8491    fn gateway_endpoint_http_unspecified_address_is_refused() {
8492        let msg = gateway_endpoint_error("http://0.0.0.0:8080");
8493        assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
8494    }
8495
8496    #[test]
8497    fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
8498        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8499            "\n      endpoint: http://[::1]:8080\n      api_key: ${LOCAL_KEY}",
8500        ))
8501        .unwrap();
8502        assert_eq!(
8503            manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
8504            "http://[::1]:8080"
8505        );
8506    }
8507
8508    /// The error a tool entry whose `gateway:` body is `block` draws, parsed with secrets resolved.
8509    fn gateway_credential_error(block: &str) -> RuntimeManifestError {
8510        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
8511    }
8512
8513    fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
8514        match &error {
8515            RuntimeManifestError::GatewayWithoutCredential {
8516                index,
8517                name,
8518                endpoint: refused,
8519            } => {
8520                assert_eq!(*index, 0);
8521                assert_eq!(name, "local/web-search");
8522                assert_eq!(refused, endpoint);
8523            }
8524            other => panic!("expected GatewayWithoutCredential, got {other:?}"),
8525        }
8526        let msg = error.to_string();
8527        assert!(
8528            msg.contains(&format!(
8529                "artifact 'local/web-search' declares gateway.endpoint '{endpoint}' but binds no \
8530                 credential"
8531            )),
8532            "{msg}"
8533        );
8534    }
8535
8536    #[test]
8537    fn gateway_credential_absent_is_refused() {
8538        assert_without_credential(
8539            gateway_credential_error("\n      endpoint: https://api.tavily.com"),
8540            "https://api.tavily.com",
8541        );
8542    }
8543
8544    #[test]
8545    fn gateway_credential_blank_is_refused() {
8546        for key in ["\"\"", "\"   \"", "''"] {
8547            assert_without_credential(
8548                gateway_credential_error(&format!(
8549                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
8550                )),
8551                "https://api.tavily.com",
8552            );
8553        }
8554    }
8555
8556    #[test]
8557    fn gateway_credential_null_is_refused() {
8558        for key in ["~", "null", ""] {
8559            assert_without_credential(
8560                gateway_credential_error(&format!(
8561                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
8562                )),
8563                "https://api.tavily.com",
8564            );
8565        }
8566    }
8567
8568    #[test]
8569    fn gateway_credential_keyless_false_is_refused() {
8570        assert_without_credential(
8571            gateway_credential_error(
8572                "\n      endpoint: https://api.tavily.com\n      keyless: false",
8573            ),
8574            "https://api.tavily.com",
8575        );
8576    }
8577
8578    #[test]
8579    fn gateway_credential_keyless_is_accepted() {
8580        for (parse, mode) in [
8581            (
8582                RuntimeManifest::from_yaml_str as fn(&str) -> _,
8583                "with secrets",
8584            ),
8585            (
8586                RuntimeManifest::from_yaml_str_without_secrets,
8587                "without secrets",
8588            ),
8589        ] {
8590            let manifest = parse(&tool_gateway_manifest(
8591                "\n      endpoint: https://api.example.com\n      keyless: true",
8592            ))
8593            .unwrap();
8594            assert_eq!(
8595                manifest.artifacts[0].gateway,
8596                Some(ArtifactGateway {
8597                    endpoint: "https://api.example.com".to_string(),
8598                    api_key: None,
8599                    keyless: true,
8600                }),
8601                "{mode}"
8602            );
8603        }
8604    }
8605
8606    #[test]
8607    fn gateway_credential_keyless_not_a_boolean_is_refused() {
8608        for value in ["\"true\"", "yes please", "1"] {
8609            let error = gateway_credential_error(&format!(
8610                "\n      endpoint: https://api.tavily.com\n      keyless: {value}"
8611            ));
8612            assert!(
8613                matches!(
8614                    error,
8615                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
8616                ),
8617                "{value}: {error:?}"
8618            );
8619            assert!(
8620                error
8621                    .to_string()
8622                    .contains("gateway.keyless with a value that is not a boolean"),
8623                "{error}"
8624            );
8625        }
8626    }
8627
8628    #[test]
8629    fn gateway_credential_and_keyless_together_are_refused() {
8630        for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
8631            let error = gateway_credential_error(&format!(
8632                "\n      endpoint: https://api.tavily.com\n      api_key: {key}\n      keyless: true"
8633            ));
8634            assert!(
8635                matches!(
8636                    error,
8637                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
8638                ),
8639                "{error:?}"
8640            );
8641            assert!(
8642                error.to_string().contains(
8643                    "artifact 'local/web-search' declares both gateway.api_key and gateway.keyless: \
8644                     true; a gateway either presents a key or is keyless"
8645                ),
8646                "{error}"
8647            );
8648        }
8649    }
8650
8651    #[test]
8652    fn gateway_credential_absent_is_refused_without_secrets() {
8653        for block in [
8654            "\n      endpoint: https://api.tavily.com",
8655            "\n      endpoint: https://api.tavily.com\n      api_key: \"\"",
8656            "\n      endpoint: https://api.tavily.com\n      keyless: false",
8657        ] {
8658            assert_without_credential(
8659                RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
8660                    .unwrap_err(),
8661                "https://api.tavily.com",
8662            );
8663        }
8664        let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
8665            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
8666        ))
8667        .unwrap();
8668        let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
8669        assert_eq!(gateway.api_key, None, "the key reference is left unread");
8670        assert!(!gateway.keyless, "a keyed gateway is never keyless");
8671    }
8672
8673    /// The refusal reads what the manifest wrote, never the address: a loopback or private
8674    /// endpoint with no credential is refused exactly as a public one is.
8675    #[test]
8676    fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
8677        for endpoint in [
8678            "http://127.0.0.1:11434",
8679            "http://localhost:11434",
8680            "http://[::1]:11434",
8681            "https://10.0.0.5",
8682            "https://192.168.1.10:8443",
8683        ] {
8684            assert_without_credential(
8685                gateway_credential_error(&format!("\n      endpoint: {endpoint}")),
8686                endpoint,
8687            );
8688        }
8689    }
8690
8691    #[test]
8692    fn gateway_endpoint_missing_from_the_block_is_refused() {
8693        for block in [
8694            "\n      api_key: ${TAVILY_API_KEY}",
8695            "",
8696            " {}",
8697            "\n      endpoint: \"  \"",
8698        ] {
8699            let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
8700                .unwrap_err()
8701                .to_string();
8702            assert!(
8703                msg.contains("without 'gateway.endpoint'"),
8704                "{block:?}: {msg}"
8705            );
8706            assert!(
8707                msg.contains("artifact 'local/web-search'"),
8708                "{block:?}: {msg}"
8709            );
8710        }
8711    }
8712
8713    #[test]
8714    fn gateway_block_unknown_key_is_reported() {
8715        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
8716            "\n      endpoint: https://api.tavily.com\n      keyless: true\n      api_kee: x",
8717        ))
8718        .unwrap();
8719        let unknown = manifest.unknown_keys;
8720        assert_eq!(unknown.len(), 1, "{unknown:?}");
8721        assert_eq!(unknown[0].key, "api_kee");
8722        assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
8723    }
8724
8725    #[test]
8726    fn gateway_on_skill_entry_is_refused() {
8727        let msg = RuntimeManifest::from_yaml_str(
8728            "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",
8729        )
8730        .unwrap_err()
8731        .to_string();
8732        assert!(
8733            msg.contains("artifact 'local/notes' declares 'gateway:'"),
8734            "{msg}"
8735        );
8736        assert!(msg.contains("runtime: skill"), "{msg}");
8737    }
8738
8739    #[test]
8740    fn gateway_on_hook_entry_is_accepted() {
8741        let manifest = RuntimeManifest::from_yaml_str(
8742            "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",
8743        )
8744        .unwrap();
8745        assert!(manifest.artifacts[0].gateway.is_some());
8746    }
8747
8748    /// An `http` driver capsule. `driver_entry` is the YAML of the driver's artifact entry, or
8749    /// empty for none.
8750    fn http_driver_manifest(artifacts: &str) -> String {
8751        format!(
8752            "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n  transport: http\n  model: test-model\n  driver:\n    artifact: murmur/driver-anthropic\n"
8753        )
8754    }
8755
8756    #[test]
8757    fn http_driver_entry_without_gateway_endpoint_is_refused() {
8758        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
8759            "\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver",
8760        ))
8761        .unwrap_err()
8762        .to_string();
8763        assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
8764        assert!(msg.contains("gateway.endpoint"), "{msg}");
8765    }
8766
8767    #[test]
8768    fn http_driver_absent_from_artifacts_is_refused() {
8769        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
8770            .unwrap_err()
8771            .to_string();
8772        assert!(msg.contains("inference.driver.artifact"), "{msg}");
8773        assert!(msg.contains("not declared in artifacts:"), "{msg}");
8774    }
8775
8776    #[test]
8777    fn gateway_on_unconfigured_driver_entry_is_refused() {
8778        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
8779            "\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",
8780        ))
8781        .unwrap_err()
8782        .to_string();
8783        assert!(msg.contains("'murmur/driver-openai'"), "{msg}");
8784        assert!(
8785            msg.contains("accepted only on the entry inference.driver.artifact names"),
8786            "{msg}"
8787        );
8788
8789        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";
8790        let msg = RuntimeManifest::from_yaml_str(no_inference)
8791            .unwrap_err()
8792            .to_string();
8793        assert!(msg.contains("not the configured inference driver"), "{msg}");
8794    }
8795
8796    #[test]
8797    fn gateway_on_driver_entry_under_process_transport_is_refused() {
8798        let msg = RuntimeManifest::from_yaml_str(
8799            "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",
8800        )
8801        .unwrap_err()
8802        .to_string();
8803        assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
8804        assert!(msg.contains("transport: process"), "{msg}");
8805    }
8806
8807    #[test]
8808    fn inference_api_key_is_refused_naming_gateway_api_key() {
8809        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";
8810        for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
8811            let manifest = http_driver_manifest(entry)
8812                .replace("  model:", &format!("  api_key: {value}\n  model:"));
8813            let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
8814            let msg = err.to_string();
8815            assert!(msg.contains("'inference.api_key'"), "{msg}");
8816            assert!(msg.contains("was removed"), "{msg}");
8817            assert!(
8818                msg.contains(
8819                    "set gateway.api_key on the artifacts: entry 'murmur/driver-anthropic'"
8820                ),
8821                "{msg}"
8822            );
8823            assert!(!msg.contains("sk-literal"), "{msg}");
8824        }
8825
8826        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  api_key: ${KEY}\n";
8827        let msg = RuntimeManifest::from_yaml_str(process)
8828            .unwrap_err()
8829            .to_string();
8830        assert!(msg.contains("'inference.api_key'"), "{msg}");
8831        assert!(
8832            msg.contains("on the artifacts: entry inference.driver.artifact names"),
8833            "{msg}"
8834        );
8835        assert!(
8836            RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
8837            "the refusal does not depend on resolving secrets"
8838        );
8839    }
8840
8841    #[test]
8842    fn inference_endpoint_is_refused_naming_gateway_endpoint() {
8843        let manifest = http_driver_manifest(
8844            "\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",
8845        )
8846        .replace("  model:", "  endpoint: https://api.anthropic.com\n  model:");
8847        let msg = RuntimeManifest::from_yaml_str(&manifest)
8848            .unwrap_err()
8849            .to_string();
8850        assert!(msg.contains("'inference.endpoint'"), "{msg}");
8851        assert!(
8852            msg.contains("set gateway.endpoint on the artifacts: entry 'murmur/driver-anthropic'"),
8853            "{msg}"
8854        );
8855
8856        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  endpoint: http://localhost:8080\n";
8857        let msg = RuntimeManifest::from_yaml_str(process)
8858            .unwrap_err()
8859            .to_string();
8860        assert!(msg.contains("'inference.endpoint'"), "{msg}");
8861        assert!(msg.contains("was removed"), "{msg}");
8862    }
8863
8864    #[test]
8865    fn without_secrets_leaves_gateway_api_key_unread() {
8866        let manifest = tool_gateway_manifest(
8867            "\n      endpoint: https://api.tavily.com\n      api_key: tvly-literal",
8868        );
8869        let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
8870        assert_eq!(
8871            resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
8872            Some(ApiKeyReference::Literal("tvly-literal".to_string()))
8873        );
8874        let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
8875        let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
8876        assert_eq!(gateway.endpoint, "https://api.tavily.com");
8877        assert_eq!(gateway.api_key, None);
8878    }
8879
8880    #[test]
8881    fn referenced_env_variables_reads_gateway_api_key() {
8882        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";
8883        let referenced = referenced_env_variables(manifest);
8884        assert_eq!(
8885            referenced,
8886            vec![
8887                ReferencedEnvVariable {
8888                    field: "artifacts.murmur/driver-anthropic.gateway.api_key".to_string(),
8889                    variable: "ANTHROPIC_API_KEY".to_string(),
8890                },
8891                ReferencedEnvVariable {
8892                    field: "artifacts.local/web-search.gateway.api_key".to_string(),
8893                    variable: "TAVILY_API_KEY".to_string(),
8894                },
8895            ]
8896        );
8897        assert!(referenced
8898            .iter()
8899            .all(ReferencedEnvVariable::is_gateway_credential));
8900    }
8901
8902    #[test]
8903    fn parses_inline_system_prompt() {
8904        let manifest = RuntimeManifest::from_yaml_str(
8905            r#"
8906name: cap
8907version: 0.0.1
8908artifacts:
8909  - name: murmur/driver-anthropic
8910    version: 0.1.0
8911    runtime: driver
8912    gateway:
8913      endpoint: http://127.0.0.1:8080
8914      keyless: true
8915inference:
8916  model: test-model
8917  system_prompt: "Always begin with CONFIRMED:"
8918  driver:
8919    artifact: murmur/driver-anthropic
8920"#,
8921        )
8922        .unwrap();
8923
8924        let inference = manifest.inference.unwrap();
8925        assert_eq!(
8926            inference.system_prompt,
8927            Some("Always begin with CONFIRMED:".to_string())
8928        );
8929        assert!(inference.system_prompt_file.is_none());
8930    }
8931
8932    #[test]
8933    fn parses_compaction_system_prompt() {
8934        let manifest = RuntimeManifest::from_yaml_str(
8935            r#"
8936name: cap
8937version: 0.0.1
8938artifacts:
8939  - name: murmur/driver-anthropic
8940    version: 0.1.0
8941    runtime: driver
8942    gateway:
8943      endpoint: http://127.0.0.1:8080
8944      keyless: true
8945inference:
8946  model: test-model
8947  compaction:
8948    model: compaction-model
8949    system_prompt: "task = X, currently editing Y, already tried Z."
8950  driver:
8951    artifact: murmur/driver-anthropic
8952"#,
8953        )
8954        .unwrap();
8955
8956        let compaction = manifest.inference.unwrap().compaction.unwrap();
8957        assert_eq!(
8958            compaction.system_prompt,
8959            Some("task = X, currently editing Y, already tried Z.".to_string())
8960        );
8961        assert_eq!(compaction.model, Some("compaction-model".to_string()));
8962    }
8963
8964    /// The two override fields resolve independently: `model` set, `system_prompt`
8965    /// absent leaves the latter `None` with no default string substituted.
8966    #[test]
8967    fn compaction_system_prompt_absent_stays_none() {
8968        let manifest = RuntimeManifest::from_yaml_str(
8969            r#"
8970name: cap
8971version: 0.0.1
8972artifacts:
8973  - name: murmur/driver-anthropic
8974    version: 0.1.0
8975    runtime: driver
8976    gateway:
8977      endpoint: http://127.0.0.1:8080
8978      keyless: true
8979inference:
8980  model: test-model
8981  compaction:
8982    model: compaction-model
8983  driver:
8984    artifact: murmur/driver-anthropic
8985"#,
8986        )
8987        .unwrap();
8988
8989        let compaction = manifest.inference.unwrap().compaction.unwrap();
8990        assert!(compaction.system_prompt.is_none());
8991        assert!(compaction.system_prompt_file.is_none());
8992        assert_eq!(compaction.model, Some("compaction-model".to_string()));
8993    }
8994
8995    /// `system_prompt_file` parses as the second, independent prompt source: the path is
8996    /// carried through verbatim (resolution against the manifest dir happens in the
8997    /// runtime), the inline field stays `None`, and `model` is unaffected.
8998    #[test]
8999    fn parses_compaction_system_prompt_file() {
9000        let manifest = RuntimeManifest::from_yaml_str(
9001            r#"
9002name: cap
9003version: 0.0.1
9004artifacts:
9005  - name: murmur/driver-anthropic
9006    version: 0.1.0
9007    runtime: driver
9008    gateway:
9009      endpoint: http://127.0.0.1:8080
9010      keyless: true
9011inference:
9012  model: test-model
9013  compaction:
9014    model: compaction-model
9015    system_prompt_file: "compaction-instructions.md"
9016  driver:
9017    artifact: murmur/driver-anthropic
9018"#,
9019        )
9020        .unwrap();
9021
9022        let compaction = manifest.inference.unwrap().compaction.unwrap();
9023        assert_eq!(
9024            compaction.system_prompt_file,
9025            Some("compaction-instructions.md".to_string())
9026        );
9027        assert!(compaction.system_prompt.is_none());
9028        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9029    }
9030
9031    /// `system_prompt_file` alone, with no `model`, still parses — the two fields do not
9032    /// depend on each other in either direction.
9033    #[test]
9034    fn compaction_system_prompt_file_independent_of_model() {
9035        let manifest = RuntimeManifest::from_yaml_str(
9036            r#"
9037name: cap
9038version: 0.0.1
9039artifacts:
9040  - name: murmur/driver-anthropic
9041    version: 0.1.0
9042    runtime: driver
9043    gateway:
9044      endpoint: http://127.0.0.1:8080
9045      keyless: true
9046inference:
9047  model: test-model
9048  compaction:
9049    threshold: 0.5
9050    system_prompt_file: "compaction-instructions.md"
9051  driver:
9052    artifact: murmur/driver-anthropic
9053"#,
9054        )
9055        .unwrap();
9056
9057        let compaction = manifest.inference.unwrap().compaction.unwrap();
9058        assert_eq!(
9059            compaction.system_prompt_file,
9060            Some("compaction-instructions.md".to_string())
9061        );
9062        assert!(compaction.model.is_none());
9063        assert_eq!(compaction.threshold, Some(0.5));
9064    }
9065
9066    /// `dump_summaries` is a plain optional boolean: present-true, present-false and absent
9067    /// are three distinguishable states, so the runtime can tell "explicitly off" from
9068    /// "never configured" even though both resolve to the same behavior.
9069    #[test]
9070    fn parses_compaction_dump_summaries() {
9071        let parse = |line: &str| {
9072            RuntimeManifest::from_yaml_str(&format!(
9073                r#"
9074name: cap
9075version: 0.0.1
9076artifacts:
9077  - name: murmur/driver-anthropic
9078    version: 0.1.0
9079    runtime: driver
9080    gateway:
9081      endpoint: http://127.0.0.1:8080
9082      keyless: true
9083inference:
9084  model: test-model
9085  compaction:
9086    model: compaction-model
9087{line}  driver:
9088    artifact: murmur/driver-anthropic
9089"#
9090            ))
9091            .unwrap()
9092            .inference
9093            .unwrap()
9094            .compaction
9095            .unwrap()
9096        };
9097
9098        assert_eq!(
9099            parse("    dump_summaries: true\n").dump_summaries,
9100            Some(true)
9101        );
9102        assert_eq!(
9103            parse("    dump_summaries: false\n").dump_summaries,
9104            Some(false)
9105        );
9106        assert_eq!(parse("").dump_summaries, None);
9107    }
9108
9109    /// `dump_summaries` is orthogonal to every other compaction field: it neither trips the
9110    /// prompt-source exclusivity check nor the threshold range check, and does not disturb
9111    /// the values those fields parse to.
9112    #[test]
9113    fn compaction_dump_summaries_does_not_interact_with_other_fields() {
9114        let compaction = RuntimeManifest::from_yaml_str(
9115            r#"
9116name: cap
9117version: 0.0.1
9118artifacts:
9119  - name: murmur/driver-anthropic
9120    version: 0.1.0
9121    runtime: driver
9122    gateway:
9123      endpoint: http://127.0.0.1:8080
9124      keyless: true
9125inference:
9126  model: test-model
9127  compaction:
9128    threshold: 0.5
9129    model: compaction-model
9130    system_prompt_file: "compaction-instructions.md"
9131    dump_summaries: true
9132  driver:
9133    artifact: murmur/driver-anthropic
9134"#,
9135        )
9136        .unwrap()
9137        .inference
9138        .unwrap()
9139        .compaction
9140        .unwrap();
9141
9142        assert_eq!(compaction.dump_summaries, Some(true));
9143        assert_eq!(compaction.threshold, Some(0.5));
9144        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9145        assert_eq!(
9146            compaction.system_prompt_file,
9147            Some("compaction-instructions.md".to_string())
9148        );
9149        assert!(compaction.system_prompt.is_none());
9150
9151        // Still mutually exclusive with dump_summaries in the mix...
9152        let err = RuntimeManifest::from_yaml_str(
9153            r#"
9154name: cap
9155version: 0.0.1
9156artifacts:
9157  - name: murmur/driver-anthropic
9158    version: 0.1.0
9159    runtime: driver
9160    gateway:
9161      endpoint: http://127.0.0.1:8080
9162      keyless: true
9163inference:
9164  model: test-model
9165  compaction:
9166    dump_summaries: true
9167    system_prompt: "inline"
9168    system_prompt_file: "compaction-instructions.md"
9169  driver:
9170    artifact: murmur/driver-anthropic
9171"#,
9172        )
9173        .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
9174        assert!(matches!(
9175            err,
9176            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9177                if field == "inference.compaction.system_prompt"
9178        ));
9179
9180        // ...and the threshold range is still enforced.
9181        let err = RuntimeManifest::from_yaml_str(
9182            r#"
9183name: cap
9184version: 0.0.1
9185artifacts:
9186  - name: murmur/driver-anthropic
9187    version: 0.1.0
9188    runtime: driver
9189    gateway:
9190      endpoint: http://127.0.0.1:8080
9191      keyless: true
9192inference:
9193  model: test-model
9194  compaction:
9195    dump_summaries: true
9196    threshold: 1.5
9197  driver:
9198    artifact: murmur/driver-anthropic
9199"#,
9200        )
9201        .expect_err("dump_summaries must not suppress the threshold range check");
9202        assert!(matches!(
9203            err,
9204            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9205                if field == "inference.compaction.threshold"
9206        ));
9207    }
9208
9209    /// Setting both compaction prompt sources is a parse error naming the compaction
9210    /// fields — distinguishable from the top-level three-way `inference.system_prompt`
9211    /// exclusivity error, and with neither value silently preferred.
9212    #[test]
9213    fn compaction_prompt_sources_are_mutually_exclusive() {
9214        let err = RuntimeManifest::from_yaml_str(
9215            r#"
9216name: cap
9217version: 0.0.1
9218artifacts:
9219  - name: murmur/driver-anthropic
9220    version: 0.1.0
9221    runtime: driver
9222    gateway:
9223      endpoint: http://127.0.0.1:8080
9224      keyless: true
9225inference:
9226  model: test-model
9227  compaction:
9228    system_prompt: "inline"
9229    system_prompt_file: "compaction-instructions.md"
9230  driver:
9231    artifact: murmur/driver-anthropic
9232"#,
9233        )
9234        .expect_err("both compaction prompt sources set must fail");
9235
9236        match err {
9237            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9238                assert_eq!(field, "inference.compaction.system_prompt");
9239                assert_eq!(
9240                    message,
9241                    "at most one of inference.compaction.system_prompt, \
9242                     inference.compaction.system_prompt_file may be set"
9243                );
9244            }
9245            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
9246        }
9247    }
9248
9249    /// The compaction-level exclusivity check is separate from the top-level one: an
9250    /// inline top-level `system_prompt` alongside a compaction `system_prompt_file` is a
9251    /// perfectly legal manifest.
9252    #[test]
9253    fn top_level_and_compaction_prompt_sources_do_not_collide() {
9254        let manifest = RuntimeManifest::from_yaml_str(
9255            r#"
9256name: cap
9257version: 0.0.1
9258artifacts:
9259  - name: murmur/driver-anthropic
9260    version: 0.1.0
9261    runtime: driver
9262    gateway:
9263      endpoint: http://127.0.0.1:8080
9264      keyless: true
9265inference:
9266  model: test-model
9267  system_prompt: "be helpful"
9268  compaction:
9269    system_prompt_file: "compaction-instructions.md"
9270  driver:
9271    artifact: murmur/driver-anthropic
9272"#,
9273        )
9274        .unwrap();
9275
9276        let inference = manifest.inference.unwrap();
9277        assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
9278        assert_eq!(
9279            inference.compaction.unwrap().system_prompt_file,
9280            Some("compaction-instructions.md".to_string())
9281        );
9282    }
9283
9284    #[test]
9285    fn parses_system_prompt_file() {
9286        let manifest = RuntimeManifest::from_yaml_str(
9287            r#"
9288name: cap
9289version: 0.0.1
9290artifacts:
9291  - name: murmur/driver-anthropic
9292    version: 0.1.0
9293    runtime: driver
9294    gateway:
9295      endpoint: http://127.0.0.1:8080
9296      keyless: true
9297inference:
9298  model: test-model
9299  system_prompt_file: "conventions.md"
9300  driver:
9301    artifact: murmur/driver-anthropic
9302"#,
9303        )
9304        .unwrap();
9305
9306        let inference = manifest.inference.unwrap();
9307        assert_eq!(
9308            inference.system_prompt_file,
9309            Some("conventions.md".to_string())
9310        );
9311        assert!(inference.system_prompt.is_none());
9312    }
9313
9314    #[test]
9315    fn rejects_both_inline_and_file_system_prompt() {
9316        let err = RuntimeManifest::from_yaml_str(
9317            r#"
9318name: cap
9319version: 0.0.1
9320artifacts:
9321  - name: murmur/driver-anthropic
9322    version: 0.1.0
9323    runtime: driver
9324    gateway:
9325      endpoint: http://127.0.0.1:8080
9326      keyless: true
9327inference:
9328  model: test-model
9329  system_prompt: "inline"
9330  system_prompt_file: "conventions.md"
9331  driver:
9332    artifact: murmur/driver-anthropic
9333"#,
9334        )
9335        .unwrap_err();
9336
9337        let msg = err.to_string();
9338        assert!(msg.contains("inference.system_prompt"));
9339        assert!(msg.contains("system_prompt_file"));
9340    }
9341
9342    /// A `${NAME}` is kept as the name it is, whether or not this process holds the variable: the
9343    /// parse never reads the environment.
9344    #[test]
9345    fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
9346        let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
9347        let yaml = format!(
9348            "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"
9349        );
9350
9351        assert_eq!(
9352            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
9353                .gateway
9354                .clone()
9355                .unwrap()
9356                .api_key,
9357            Some(ApiKeyReference::Environment(key.to_string()))
9358        );
9359        assert_eq!(
9360            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
9361                .unwrap()
9362                .artifacts[0]
9363                .gateway
9364                .clone()
9365                .unwrap()
9366                .api_key,
9367            None
9368        );
9369    }
9370
9371    #[test]
9372    fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
9373        let literal = ["sk-", "ant-", "debug"].concat();
9374        let debug = format!(
9375            "{:?} {:?}",
9376            ApiKeyReference::Environment("SOME_KEY".to_string()),
9377            ApiKeyReference::Literal(literal.clone())
9378        );
9379        assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
9380        assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
9381        assert!(!debug.contains(&literal), "{debug}");
9382    }
9383
9384    #[test]
9385    fn parse_without_secrets_drops_a_literal_api_key() {
9386        // Key assembled at runtime so the source never contains a
9387        // credential-shaped literal that secret scanners could flag.
9388        let literal = ["sk-", "ant-", "literal"].concat();
9389        let yaml = format!(
9390            "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"
9391        );
9392
9393        assert_eq!(
9394            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
9395                .gateway
9396                .clone()
9397                .unwrap()
9398                .api_key,
9399            Some(ApiKeyReference::Literal(literal))
9400        );
9401        assert_eq!(
9402            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
9403                .unwrap()
9404                .artifacts[0]
9405                .gateway
9406                .clone()
9407                .unwrap()
9408                .api_key,
9409            None
9410        );
9411    }
9412
9413    /// The non-resolving parse is narrower only in what it resolves, never in what it validates:
9414    /// a manifest roost accepts is a manifest `mur run` would also accept.
9415    #[test]
9416    fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
9417        let process_with_key = format!(
9418            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  model: test-model\n  api_key: {}\n",
9419            ["sk-", "ant-", "secret"].concat()
9420        );
9421        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";
9422
9423        for yaml in [process_with_key.as_str(), http_without_model] {
9424            let resolving = RuntimeManifest::from_yaml_str(yaml)
9425                .unwrap_err()
9426                .to_string();
9427            let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
9428                .unwrap_err()
9429                .to_string();
9430            assert_eq!(resolving, without_secrets);
9431        }
9432    }
9433
9434    /// The reference scan answers from the manifest text alone: it reports a reference, ignores
9435    /// a literal, and has nothing to say about a manifest that carries no `inference` block or
9436    /// does not parse at all.
9437    #[test]
9438    fn referenced_env_variables_reports_a_reference_and_nothing_else() {
9439        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";
9440        let referenced = referenced_env_variables(referencing);
9441        assert_eq!(referenced.len(), 1);
9442        assert_eq!(
9443            referenced[0].field,
9444            "artifacts.murmur/driver-anthropic.gateway.api_key"
9445        );
9446        assert!(referenced[0].is_gateway_credential());
9447        assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
9448
9449        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";
9450        assert!(referenced_env_variables(literal).is_empty());
9451
9452        let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
9453        assert!(referenced_env_variables(no_inference).is_empty());
9454
9455        let unparseable = "name: broken\nversion: [\n";
9456        assert!(referenced_env_variables(unparseable).is_empty());
9457    }
9458
9459    #[test]
9460    fn inference_requires_driver_artifact() {
9461        let err = RuntimeManifest::from_yaml_str(
9462            r#"
9463name: cap
9464version: 0.0.1
9465artifacts: []
9466inference:
9467  transport: http
9468  endpoint: http://127.0.0.1:8080
9469  model: test-model
9470"#,
9471        )
9472        .unwrap_err();
9473
9474        assert!(err.to_string().contains("inference.driver"));
9475    }
9476
9477    #[test]
9478    fn test_runtime_driver_deserializes() {
9479        let manifest = RuntimeManifest::from_yaml_str(
9480            r#"
9481name: cap
9482version: 0.0.1
9483artifacts:
9484  - name: local/my-provider
9485    version: 1.0.0
9486    runtime: driver
9487"#,
9488        )
9489        .unwrap();
9490
9491        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
9492    }
9493
9494    #[test]
9495    fn test_runtime_unknown_is_error() {
9496        let err = RuntimeManifest::from_yaml_str(
9497            r#"
9498name: cap
9499version: 0.0.1
9500artifacts:
9501  - name: local/foo
9502    version: 1.0.0
9503    runtime: unknown_type
9504"#,
9505        )
9506        .unwrap_err();
9507
9508        let msg = err.to_string();
9509        assert!(msg.contains("runtime"), "error should mention 'runtime'");
9510        assert!(
9511            msg.contains("unknown_type"),
9512            "error should identify the bad value"
9513        );
9514    }
9515
9516    #[test]
9517    fn test_runtime_all_variants_roundtrip() {
9518        for variant in [
9519            ArtifactRuntime::Tool,
9520            ArtifactRuntime::Driver,
9521            ArtifactRuntime::Hook,
9522            ArtifactRuntime::Skill,
9523        ] {
9524            let json = serde_json::to_string(&variant).unwrap();
9525            let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
9526            assert_eq!(variant, back);
9527        }
9528    }
9529
9530    #[test]
9531    fn skill_source_without_version_defaults_to_local() {
9532        let manifest = RuntimeManifest::from_yaml_str(
9533            r#"
9534name: cap
9535version: 0.0.1
9536artifacts:
9537  - name: my-skill
9538    source: ./skills/my-skill/skill.md
9539    runtime: skill
9540"#,
9541        )
9542        .unwrap();
9543
9544        assert_eq!(manifest.artifacts.len(), 1);
9545        let artifact = &manifest.artifacts[0];
9546        assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
9547        assert_eq!(
9548            artifact.source.as_deref(),
9549            Some("./skills/my-skill/skill.md")
9550        );
9551        assert_eq!(artifact.version, "local");
9552    }
9553
9554    #[test]
9555    fn skill_source_directory_path_is_accepted() {
9556        let manifest = RuntimeManifest::from_yaml_str(
9557            r#"
9558name: cap
9559version: 0.0.1
9560artifacts:
9561  - name: my-skill
9562    source: ./skills/my-skill/
9563    runtime: skill
9564"#,
9565        )
9566        .unwrap();
9567
9568        assert_eq!(
9569            manifest.artifacts[0].source.as_deref(),
9570            Some("./skills/my-skill/")
9571        );
9572        assert_eq!(manifest.artifacts[0].version, "local");
9573    }
9574
9575    #[test]
9576    fn skill_source_with_explicit_version_substitutes_local() {
9577        // Both set is allowed; version is ignored and "local" is substituted.
9578        let manifest = RuntimeManifest::from_yaml_str(
9579            r#"
9580name: cap
9581version: 0.0.1
9582artifacts:
9583  - name: my-skill
9584    source: ./skills/my-skill/skill.md
9585    version: 9.9.9
9586    runtime: skill
9587"#,
9588        )
9589        .unwrap();
9590
9591        assert_eq!(manifest.artifacts[0].version, "local");
9592    }
9593
9594    #[test]
9595    fn local_source_true_allows_source_on_non_skill_runtime() {
9596        for runtime in ["tool", "driver", "hook"] {
9597            let yaml = format!(
9598                r#"
9599name: cap
9600version: 0.0.1
9601artifacts:
9602  - name: my-thing
9603    source: ./local/path
9604    local_source: true
9605    runtime: {runtime}
9606"#
9607            );
9608            let manifest = RuntimeManifest::from_yaml_str(&yaml)
9609                .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
9610            let artifact = &manifest.artifacts[0];
9611            assert!(artifact.local_source, "runtime {runtime}");
9612            assert_eq!(artifact.source.as_deref(), Some("./local/path"));
9613            assert_eq!(artifact.version, "local", "runtime {runtime}");
9614        }
9615    }
9616
9617    #[test]
9618    fn local_source_false_on_skill_with_source_is_error() {
9619        // The gate reads the declared field, not the role: an explicit `local_source: false`
9620        // overrides the skill default and rejects `source:`.
9621        let err = RuntimeManifest::from_yaml_str(
9622            r#"
9623name: cap
9624version: 0.0.1
9625artifacts:
9626  - name: my-skill
9627    source: ./skills/my-skill
9628    local_source: false
9629    runtime: skill
9630"#,
9631        )
9632        .unwrap_err();
9633        let msg = err.to_string();
9634        assert!(msg.contains("'local_source: true'"), "error was: {msg}");
9635        assert!(msg.contains("my-skill"), "error was: {msg}");
9636    }
9637
9638    #[test]
9639    fn local_source_defaults_from_role_when_absent() {
9640        let manifest = RuntimeManifest::from_yaml_str(
9641            r#"
9642name: cap
9643version: 0.0.1
9644artifacts:
9645  - name: my-skill
9646    source: ./skills/my-skill
9647    runtime: skill
9648  - name: local/my-tool
9649    version: 1.0.0
9650    runtime: tool
9651"#,
9652        )
9653        .unwrap();
9654        assert!(manifest.artifacts[0].local_source, "skill defaults to true");
9655        assert!(
9656            !manifest.artifacts[1].local_source,
9657            "tool defaults to false"
9658        );
9659    }
9660
9661    #[test]
9662    fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
9663        let manifest = RuntimeManifest::from_yaml_str(
9664            r#"
9665name: cap
9666version: 0.0.1
9667artifacts:
9668  - name: local/my-tool
9669    version: 1.0.0
9670    runtime: tool
9671    prompt_payload: true
9672  - name: murmur/driver-anthropic
9673    version: 0.1.0
9674    runtime: driver
9675    gateway:
9676      endpoint: http://127.0.0.1:8080
9677      keyless: true
9678inference:
9679  model: test-model
9680  system_prompt_artifact: local/my-tool
9681  driver:
9682    artifact: murmur/driver-anthropic
9683"#,
9684        )
9685        .unwrap();
9686        assert!(manifest.artifacts[0].prompt_payload);
9687    }
9688
9689    #[test]
9690    fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
9691        let err = RuntimeManifest::from_yaml_str(
9692            r#"
9693name: cap
9694version: 0.0.1
9695artifacts:
9696  - name: local/my-skill
9697    version: 1.0.0
9698    runtime: skill
9699    prompt_payload: false
9700  - name: murmur/driver-anthropic
9701    version: 0.1.0
9702    runtime: driver
9703    gateway:
9704      endpoint: http://127.0.0.1:8080
9705      keyless: true
9706inference:
9707  model: test-model
9708  system_prompt_artifact: local/my-skill
9709  driver:
9710    artifact: murmur/driver-anthropic
9711"#,
9712        )
9713        .unwrap_err();
9714        let msg = err.to_string();
9715        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
9716    }
9717
9718    #[test]
9719    fn prompt_payload_defaults_from_role_when_absent() {
9720        let manifest = RuntimeManifest::from_yaml_str(
9721            r#"
9722name: cap
9723version: 0.0.1
9724artifacts:
9725  - name: local/my-skill
9726    version: 1.0.0
9727    runtime: skill
9728  - name: local/my-tool
9729    version: 1.0.0
9730    runtime: tool
9731"#,
9732        )
9733        .unwrap();
9734        assert!(
9735            manifest.artifacts[0].prompt_payload,
9736            "skill defaults to true"
9737        );
9738        assert!(
9739            !manifest.artifacts[1].prompt_payload,
9740            "tool defaults to false"
9741        );
9742    }
9743
9744    #[test]
9745    fn source_on_non_skill_runtime_is_error() {
9746        for runtime in ["tool", "driver", "hook"] {
9747            let yaml = format!(
9748                r#"
9749name: cap
9750version: 0.0.1
9751artifacts:
9752  - name: my-thing
9753    source: ./local/path
9754    runtime: {runtime}
9755"#
9756            );
9757            let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
9758            let msg = err.to_string();
9759            assert!(
9760                msg.contains("'local_source: true'"),
9761                "runtime {runtime}: error was: {msg}"
9762            );
9763            assert!(
9764                msg.contains("my-thing"),
9765                "runtime {runtime}: error was: {msg}"
9766            );
9767        }
9768    }
9769
9770    #[test]
9771    fn registry_skill_still_requires_version() {
9772        let err = RuntimeManifest::from_yaml_str(
9773            r#"
9774name: cap
9775version: 0.0.1
9776artifacts:
9777  - name: local/my-skill
9778    runtime: skill
9779"#,
9780        )
9781        .unwrap_err();
9782
9783        let msg = err.to_string();
9784        assert!(
9785            msg.contains("missing required field 'version'"),
9786            "error was: {msg}"
9787        );
9788    }
9789
9790    #[test]
9791    fn rejects_unknown_inference_transport() {
9792        let err = RuntimeManifest::from_yaml_str(
9793            r#"
9794name: cap
9795version: 0.0.1
9796artifacts: []
9797inference:
9798  transport: grpc
9799  model: test-model
9800  driver:
9801    artifact: murmur/driver-anthropic
9802"#,
9803        )
9804        .unwrap_err();
9805
9806        let msg = err.to_string();
9807        assert!(msg.contains("inference.transport"), "error was: {msg}");
9808        assert!(msg.contains("unknown value"), "error was: {msg}");
9809        assert!(msg.contains("grpc"), "error was: {msg}");
9810    }
9811
9812    /// The driver is what knows a harness, so `command:` alone selects nothing: a manifest that
9813    /// names only a command is refused and told which field to add.
9814    #[test]
9815    fn process_transport_with_command_and_no_driver_is_refused() {
9816        let err = RuntimeManifest::from_yaml_str(
9817            r#"
9818name: cap
9819version: 0.0.1
9820artifacts: []
9821inference:
9822  transport: process
9823  command: some-cli
9824  model: some-model
9825  max_turns: 20
9826"#,
9827        )
9828        .unwrap_err();
9829
9830        let msg = err.to_string();
9831        assert!(
9832            msg.contains("inference.driver.artifact"),
9833            "error was: {msg}"
9834        );
9835        assert!(msg.contains("inference.command"), "error was: {msg}");
9836        assert!(
9837            msg.contains("murmur/driver-claude-code"),
9838            "error was: {msg}"
9839        );
9840    }
9841
9842    #[test]
9843    fn process_transport_with_neither_driver_nor_command_is_refused() {
9844        let err = RuntimeManifest::from_yaml_str(
9845            r#"
9846name: cap
9847version: 0.0.1
9848artifacts: []
9849inference:
9850  transport: process
9851  model: some-model
9852"#,
9853        )
9854        .unwrap_err();
9855
9856        let msg = err.to_string();
9857        assert!(
9858            msg.contains("inference.driver.artifact"),
9859            "error was: {msg}"
9860        );
9861        assert!(
9862            msg.contains("murmur/driver-claude-code"),
9863            "error was: {msg}"
9864        );
9865    }
9866
9867    #[test]
9868    fn process_transport_allows_absent_model() {
9869        // model is optional for transport: process — an absent model leaves the choice to the
9870        // harness, which has an account default of its own.
9871        let manifest =
9872            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
9873                .expect("process transport should parse without a model");
9874        let inference = manifest.inference.expect("inference present");
9875        assert_eq!(inference.transport, "process");
9876        assert!(inference.command.is_none());
9877        assert_eq!(
9878            inference.model, "",
9879            "absent model resolves to empty (harness default)"
9880        );
9881    }
9882
9883    fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
9884        format!(
9885            "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n  transport: process\n  \
9886             driver:\n    artifact: local/fixture-process-driver\n{inference_extra}"
9887        )
9888    }
9889
9890    const PROCESS_DRIVER_ENTRY: &str =
9891        "  - name: local/fixture-process-driver\n    version: 0.1.0\n    runtime: driver";
9892
9893    #[test]
9894    fn process_transport_accepts_driver() {
9895        let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9896            PROCESS_DRIVER_ENTRY,
9897            "    config:\n      profile: work\n  command: /usr/local/bin/fixture-cli\n",
9898        ))
9899        .unwrap();
9900
9901        let inference = manifest.inference.expect("inference should exist");
9902        assert_eq!(inference.transport, "process");
9903        let driver = inference.driver.expect("driver should be parsed");
9904        assert_eq!(driver.artifact, "local/fixture-process-driver");
9905        assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
9906        assert_eq!(
9907            inference.command.as_deref(),
9908            Some("/usr/local/bin/fixture-cli")
9909        );
9910    }
9911
9912    #[test]
9913    fn process_transport_accepts_driver_without_command() {
9914        let manifest =
9915            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
9916                .unwrap();
9917
9918        let inference = manifest.inference.expect("inference should exist");
9919        assert_eq!(
9920            inference.driver.map(|d| d.artifact).as_deref(),
9921            Some("local/fixture-process-driver")
9922        );
9923        assert!(inference.command.is_none());
9924        assert_eq!(inference.model, "");
9925    }
9926
9927    #[test]
9928    fn process_driver_entry_must_be_declared() {
9929        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9930            "  - name: local/other\n    version: 0.1.0\n    runtime: tool",
9931            "",
9932        ))
9933        .unwrap_err()
9934        .to_string();
9935        assert!(msg.contains("inference.driver.artifact"), "{msg}");
9936        assert!(
9937            msg.contains("artifact 'local/fixture-process-driver' is not declared in artifacts:"),
9938            "{msg}"
9939        );
9940    }
9941
9942    #[test]
9943    fn process_driver_entry_must_be_runtime_driver() {
9944        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9945            "  - name: local/fixture-process-driver\n    version: 0.1.0\n    runtime: tool",
9946            "",
9947        ))
9948        .unwrap_err()
9949        .to_string();
9950        assert!(
9951            msg.contains("must be declared with runtime: driver"),
9952            "{msg}"
9953        );
9954    }
9955
9956    #[test]
9957    fn process_driver_entry_refuses_capabilities() {
9958        let entry =
9959            format!("{PROCESS_DRIVER_ENTRY}\n    capabilities:\n      env:\n        allow: [HOME]");
9960        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
9961            .unwrap_err()
9962            .to_string();
9963        assert!(msg.contains("granted nothing"), "{msg}");
9964        assert!(msg.contains("inference.driver.config"), "{msg}");
9965    }
9966
9967    #[test]
9968    fn process_driver_entry_refuses_config() {
9969        let entry = format!("{PROCESS_DRIVER_ENTRY}\n    config:\n      profile: work");
9970        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
9971            .unwrap_err()
9972            .to_string();
9973        assert!(msg.contains("granted nothing"), "{msg}");
9974        assert!(msg.contains("remove capabilities:/config:"), "{msg}");
9975    }
9976
9977    #[test]
9978    fn process_transport_rejects_endpoint() {
9979        let err = RuntimeManifest::from_yaml_str(
9980            r#"
9981name: cap
9982version: 0.0.1
9983artifacts: []
9984inference:
9985  transport: process
9986  command: claude
9987  model: claude-haiku-4-5-20251001
9988  endpoint: http://localhost:8080
9989"#,
9990        )
9991        .unwrap_err();
9992
9993        let msg = err.to_string();
9994        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
9995        assert!(msg.contains("was removed"), "error was: {msg}");
9996    }
9997
9998    #[test]
9999    fn process_transport_rejects_api_key() {
10000        // Key assembled at runtime so the source never contains a
10001        // credential-shaped literal that secret scanners could flag.
10002        let key = ["sk-", "ant-", "secret"].concat();
10003        let err = RuntimeManifest::from_yaml_str(&format!(
10004            r#"
10005name: cap
10006version: 0.0.1
10007artifacts: []
10008inference:
10009  transport: process
10010  command: claude
10011  model: claude-haiku-4-5-20251001
10012  api_key: {key}
10013"#
10014        ))
10015        .unwrap_err();
10016
10017        let msg = err.to_string();
10018        assert!(msg.contains("inference.api_key"), "error was: {msg}");
10019        assert!(msg.contains("was removed"), "error was: {msg}");
10020        assert!(!msg.contains(&key), "error was: {msg}");
10021    }
10022
10023    #[test]
10024    fn process_transport_rejects_max_tokens() {
10025        // Rejected even at a perfectly valid value: the CLI subprocess path never builds a
10026        // driver payload, so accepting it would leave it silently inert.
10027        let err = RuntimeManifest::from_yaml_str(
10028            r#"
10029name: cap
10030version: 0.0.1
10031artifacts: []
10032inference:
10033  transport: process
10034  command: claude
10035  model: claude-haiku-4-5-20251001
10036  max_tokens: 4096
10037"#,
10038        )
10039        .unwrap_err();
10040
10041        let msg = err.to_string();
10042        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
10043        assert!(
10044            msg.contains("not valid with transport: process"),
10045            "error was: {msg}"
10046        );
10047    }
10048
10049    /// The harness's own reported counts are what this transport meters, so the ceiling parses
10050    /// here as it does under `http`. A driver that reports no counts is refused at load, not
10051    /// here: the manifest cannot see which driver it names.
10052    #[test]
10053    fn process_transport_carries_max_session_tokens() {
10054        let manifest = RuntimeManifest::from_yaml_str(
10055            r#"
10056name: cap
10057version: 0.0.1
10058artifacts:
10059  - name: murmur/driver-claude-code
10060    version: 0.1.0
10061    runtime: driver
10062inference:
10063  transport: process
10064  driver:
10065    artifact: murmur/driver-claude-code
10066  model: claude-haiku-4-5-20251001
10067  max_session_tokens: 200000
10068"#,
10069        )
10070        .unwrap();
10071
10072        assert_eq!(
10073            manifest.inference.unwrap().max_session_tokens,
10074            Some(200_000)
10075        );
10076    }
10077
10078    #[test]
10079    fn process_transport_rejects_a_zero_max_session_tokens() {
10080        let err = RuntimeManifest::from_yaml_str(
10081            r#"
10082name: cap
10083version: 0.0.1
10084artifacts:
10085  - name: murmur/driver-claude-code
10086    version: 0.1.0
10087    runtime: driver
10088inference:
10089  transport: process
10090  driver:
10091    artifact: murmur/driver-claude-code
10092  model: claude-haiku-4-5-20251001
10093  max_session_tokens: 0
10094"#,
10095        )
10096        .unwrap_err();
10097
10098        let msg = err.to_string();
10099        assert!(
10100            msg.contains("inference.max_session_tokens"),
10101            "error was: {msg}"
10102        );
10103        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
10104    }
10105
10106    #[test]
10107    fn inference_max_session_tokens_round_trips() {
10108        let manifest = RuntimeManifest::from_yaml_str(
10109            r#"
10110name: cap
10111version: 0.0.1
10112artifacts:
10113  - name: murmur/driver-anthropic
10114    version: 0.1.0
10115    runtime: driver
10116    gateway:
10117      endpoint: https://api.anthropic.com
10118      api_key: test-key
10119inference:
10120  transport: http
10121  model: claude-opus-4-5
10122  max_session_tokens: 5000000000
10123  driver:
10124    artifact: murmur/driver-anthropic
10125"#,
10126        )
10127        .unwrap();
10128
10129        assert_eq!(
10130            manifest.inference.unwrap().max_session_tokens,
10131            Some(5_000_000_000)
10132        );
10133    }
10134
10135    #[test]
10136    fn inference_max_session_tokens_absent_is_none() {
10137        let manifest = RuntimeManifest::from_yaml_str(
10138            r#"
10139name: cap
10140version: 0.0.1
10141artifacts:
10142  - name: murmur/driver-anthropic
10143    version: 0.1.0
10144    runtime: driver
10145    gateway:
10146      endpoint: https://api.anthropic.com
10147      api_key: test-key
10148inference:
10149  transport: http
10150  model: claude-opus-4-5
10151  driver:
10152    artifact: murmur/driver-anthropic
10153"#,
10154        )
10155        .unwrap();
10156
10157        assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
10158    }
10159
10160    #[test]
10161    fn inference_max_session_tokens_zero_is_rejected() {
10162        let err = RuntimeManifest::from_yaml_str(
10163            r#"
10164name: cap
10165version: 0.0.1
10166artifacts:
10167  - name: murmur/driver-anthropic
10168    version: 0.1.0
10169    runtime: driver
10170    gateway:
10171      endpoint: https://api.anthropic.com
10172      api_key: test-key
10173inference:
10174  transport: http
10175  model: claude-opus-4-5
10176  max_session_tokens: 0
10177  driver:
10178    artifact: murmur/driver-anthropic
10179"#,
10180        )
10181        .unwrap_err();
10182
10183        let msg = err.to_string();
10184        assert!(
10185            msg.contains("inference.max_session_tokens"),
10186            "error was: {msg}"
10187        );
10188        assert!(msg.contains("greater than 0"), "error was: {msg}");
10189    }
10190
10191    /// An http capsule manifest with `extra` appended to its `inference:` block.
10192    fn http_manifest_with_inference(extra: &str) -> String {
10193        format!(
10194            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    \
10195             version: 0.1.0\n    runtime: driver\n    gateway:\n      \
10196             endpoint: https://api.anthropic.com\n      api_key: test-key\n\
10197             inference:\n  transport: http\n  model: claude-opus-4-5\n  driver:\n    \
10198             artifact: murmur/driver-anthropic\n{extra}"
10199        )
10200    }
10201
10202    #[test]
10203    fn inference_tool_refresh_absent_is_compaction() {
10204        let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
10205        assert_eq!(
10206            manifest.inference.unwrap().tool_refresh,
10207            ToolRefresh::Compaction
10208        );
10209        assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
10210    }
10211
10212    #[test]
10213    fn inference_tool_refresh_parses_both_values() {
10214        for (value, expected) in [
10215            ("compaction", ToolRefresh::Compaction),
10216            ("immediate", ToolRefresh::Immediate),
10217        ] {
10218            let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
10219                "  tool_refresh: {value}\n"
10220            )))
10221            .unwrap();
10222            assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
10223            assert_eq!(expected.wire_name(), value);
10224            assert!(
10225                manifest.unknown_keys.is_empty(),
10226                "tool_refresh is a known key: {:?}",
10227                manifest.unknown_keys
10228            );
10229        }
10230    }
10231
10232    #[test]
10233    fn inference_tool_refresh_rejects_an_unknown_value() {
10234        let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
10235            "  tool_refresh: sometimes\n",
10236        ))
10237        .unwrap_err();
10238        match err {
10239            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10240                assert_eq!(field, "inference.tool_refresh");
10241                assert_eq!(
10242                    message,
10243                    "must be one of: compaction, immediate; got 'sometimes'"
10244                );
10245            }
10246            other => panic!("unexpected error: {other:?}"),
10247        }
10248    }
10249
10250    /// Refused whatever its value: the harness owns its tool list under `transport: process`,
10251    /// so the key would be silently inert there.
10252    #[test]
10253    fn process_transport_rejects_tool_refresh() {
10254        for value in ["compaction", "immediate"] {
10255            let err = RuntimeManifest::from_yaml_str(&format!(
10256                "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  \
10257                 command: claude\n  model: claude-haiku-4-5-20251001\n  tool_refresh: {value}\n"
10258            ))
10259            .unwrap_err();
10260            match err {
10261                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10262                    assert_eq!(field, "inference.tool_refresh");
10263                    assert_eq!(message, "is not valid with transport: process");
10264                }
10265                other => panic!("unexpected error for {value}: {other:?}"),
10266            }
10267        }
10268    }
10269
10270    #[test]
10271    fn inference_max_tokens_round_trips() {
10272        let manifest = RuntimeManifest::from_yaml_str(
10273            r#"
10274name: cap
10275version: 0.0.1
10276artifacts:
10277  - name: murmur/driver-anthropic
10278    version: 0.1.0
10279    runtime: driver
10280    gateway:
10281      endpoint: https://api.anthropic.com
10282      api_key: test-key
10283inference:
10284  transport: http
10285  model: claude-opus-4-5
10286  max_tokens: 4096
10287  driver:
10288    artifact: murmur/driver-anthropic
10289"#,
10290        )
10291        .unwrap();
10292
10293        let inference = manifest.inference.unwrap();
10294        assert_eq!(inference.max_tokens, Some(4096));
10295    }
10296
10297    #[test]
10298    fn inference_max_tokens_absent_is_none() {
10299        // Absent means "the manifest didn't set it" all the way down; the 8192 default is
10300        // applied once, by the runtime, not smuggled in here.
10301        let manifest = RuntimeManifest::from_yaml_str(
10302            r#"
10303name: cap
10304version: 0.0.1
10305artifacts:
10306  - name: murmur/driver-anthropic
10307    version: 0.1.0
10308    runtime: driver
10309    gateway:
10310      endpoint: https://api.anthropic.com
10311      api_key: test-key
10312inference:
10313  transport: http
10314  model: claude-opus-4-5
10315  driver:
10316    artifact: murmur/driver-anthropic
10317"#,
10318        )
10319        .unwrap();
10320
10321        assert_eq!(manifest.inference.unwrap().max_tokens, None);
10322    }
10323
10324    #[test]
10325    fn inference_max_tokens_zero_is_rejected() {
10326        let err = RuntimeManifest::from_yaml_str(
10327            r#"
10328name: cap
10329version: 0.0.1
10330artifacts:
10331  - name: murmur/driver-anthropic
10332    version: 0.1.0
10333    runtime: driver
10334    gateway:
10335      endpoint: https://api.anthropic.com
10336      api_key: test-key
10337inference:
10338  transport: http
10339  model: claude-opus-4-5
10340  max_tokens: 0
10341  driver:
10342    artifact: murmur/driver-anthropic
10343"#,
10344        )
10345        .unwrap_err();
10346
10347        let msg = err.to_string();
10348        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
10349        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
10350    }
10351
10352    #[test]
10353    fn inference_max_tokens_large_value_is_accepted() {
10354        // Manifest validation stays advisory: an over-large cap is the provider's to reject
10355        // at request time, not ours to clamp or block at load time.
10356        let manifest = RuntimeManifest::from_yaml_str(
10357            r#"
10358name: cap
10359version: 0.0.1
10360artifacts:
10361  - name: murmur/driver-anthropic
10362    version: 0.1.0
10363    runtime: driver
10364    gateway:
10365      endpoint: https://api.anthropic.com
10366      api_key: test-key
10367inference:
10368  transport: http
10369  model: claude-opus-4-5
10370  max_tokens: 999999
10371  driver:
10372    artifact: murmur/driver-anthropic
10373"#,
10374        )
10375        .unwrap();
10376
10377        assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
10378    }
10379
10380    #[test]
10381    fn inference_and_context_max_tokens_are_independent() {
10382        // Two unrelated concepts that happen to share a leaf name: per-turn output cap vs.
10383        // session compaction budget. Neither may leak into the other.
10384        let manifest = RuntimeManifest::from_yaml_str(
10385            r#"
10386name: cap
10387version: 0.0.1
10388artifacts:
10389  - name: murmur/driver-anthropic
10390    version: 0.1.0
10391    runtime: driver
10392    gateway:
10393      endpoint: https://api.anthropic.com
10394      api_key: test-key
10395inference:
10396  transport: http
10397  model: claude-opus-4-5
10398  max_tokens: 4096
10399  driver:
10400    artifact: murmur/driver-anthropic
10401context:
10402  max_tokens: 200000
10403"#,
10404        )
10405        .unwrap();
10406
10407        assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
10408        assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
10409    }
10410
10411    #[test]
10412    fn hook_config_defaults_when_fields_absent() {
10413        let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
10414        assert_eq!(config.binding, HookBinding::All);
10415        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
10416        assert_eq!(config.commit_policy, HookCommitPolicy::None);
10417    }
10418
10419    #[test]
10420    fn hook_config_parses_all_fields() {
10421        let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
10422        let config = parse_hook_config_from_yaml(yaml).unwrap();
10423        assert_eq!(config.binding, HookBinding::OnCompaction);
10424        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
10425        assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
10426    }
10427
10428    #[test]
10429    fn hook_config_parses_on_task_start() {
10430        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
10431        let config = parse_hook_config_from_yaml(yaml).unwrap();
10432        assert_eq!(config.binding, HookBinding::OnTaskStart);
10433    }
10434
10435    #[test]
10436    fn hook_config_parses_on_task_end() {
10437        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
10438        let config = parse_hook_config_from_yaml(yaml).unwrap();
10439        assert_eq!(config.binding, HookBinding::OnTaskEnd);
10440    }
10441
10442    #[test]
10443    fn hook_config_unknown_binding_lists_task_events() {
10444        let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
10445        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10446        assert!(err.contains("on-task-start"), "error was: {err}");
10447        assert!(err.contains("on-task-end"), "error was: {err}");
10448    }
10449
10450    #[test]
10451    fn hook_config_async_with_commit_is_rejected() {
10452        let yaml =
10453            "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
10454        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10455        assert!(err.contains("async-with-commit"), "error was: {err}");
10456    }
10457
10458    #[test]
10459    fn hook_config_on_stage_async_is_rejected() {
10460        let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
10461        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10462        assert!(err.contains("on-stage"), "error was: {err}");
10463        assert!(err.contains("blocking"), "error was: {err}");
10464    }
10465
10466    #[test]
10467    fn hook_config_async_none_is_valid() {
10468        let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
10469        let config = parse_hook_config_from_yaml(yaml).unwrap();
10470        assert_eq!(config.execution_mode, HookExecutionMode::Async);
10471        assert_eq!(config.commit_policy, HookCommitPolicy::None);
10472    }
10473
10474    #[test]
10475    fn hook_config_parses_reopen_task_commit_policy() {
10476        let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
10477        let config = parse_hook_config_from_yaml(yaml).unwrap();
10478        assert_eq!(config.binding, HookBinding::OnTaskEnd);
10479        assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
10480    }
10481
10482    #[test]
10483    fn hook_config_unknown_commit_policy_lists_reopen_task() {
10484        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
10485        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10486        assert!(err.contains("reopen-task"), "error was: {err}");
10487    }
10488
10489    #[test]
10490    fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
10491        for binding in ["on-shell", "on-tool-call"] {
10492            let yaml =
10493                format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
10494            let config = parse_hook_config_from_yaml(&yaml).unwrap();
10495            assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
10496        }
10497        assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
10498    }
10499
10500    /// `deny` is the one policy an omitted `binding:` cannot carry: a hook that does not name
10501    /// which of the two gated events it decides on would be asked at both.
10502    #[test]
10503    fn hook_config_deny_without_a_binding_is_rejected() {
10504        let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
10505        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10506        assert!(
10507            err.contains("requires an explicit binding"),
10508            "error was: {err}"
10509        );
10510        assert!(err.contains("on-shell"), "error was: {err}");
10511        assert!(err.contains("on-tool-call"), "error was: {err}");
10512    }
10513
10514    /// Every other binding refuses `deny` through the existing binding-mismatch check.
10515    #[test]
10516    fn hook_config_deny_on_an_ungated_binding_is_rejected() {
10517        let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
10518        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10519        assert!(err.contains("is not valid for binding"), "error was: {err}");
10520        assert!(err.contains("replace-context"), "error was: {err}");
10521    }
10522
10523    /// The unchanged async/commit check covers `deny` for free; this pins that it does.
10524    #[test]
10525    fn hook_config_async_deny_is_rejected() {
10526        let yaml =
10527            "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
10528        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10529        assert!(err.contains("async-with-commit"), "error was: {err}");
10530        assert!(err.contains("deny"), "error was: {err}");
10531    }
10532
10533    #[test]
10534    fn hook_config_unknown_commit_policy_lists_deny() {
10535        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
10536        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10537        assert!(err.contains("deny"), "error was: {err}");
10538    }
10539
10540    /// The existing, unmodified async/commit validation must also reject
10541    /// `commit_policy: reopen-task` combined with `execution_mode: async`.
10542    #[test]
10543    fn hook_config_async_reopen_task_is_rejected() {
10544        let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
10545        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
10546        assert!(err.contains("async-with-commit"), "error was: {err}");
10547        assert!(err.contains("reopen-task"), "error was: {err}");
10548    }
10549
10550    // ── binding is the single source of truth for what a hook commits ─────────────
10551
10552    /// The two bindings whose events honor no `hook-output` arm at all cannot declare
10553    /// any non-`none` `commit_policy`. The error names the binding, the declared policy,
10554    /// and `none` as what the binding honors.
10555    #[test]
10556    fn hook_config_non_committing_binding_rejects_any_commit_policy() {
10557        for binding in ["on-session-start", "on-session-end"] {
10558            let yaml = format!(
10559                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
10560            );
10561            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
10562            assert!(err.contains(binding), "error was: {err}");
10563            assert!(err.contains("replace-context"), "error was: {err}");
10564            assert!(
10565                err.contains("honors commit_policy 'none'"),
10566                "error was: {err}"
10567            );
10568        }
10569    }
10570
10571    /// A binding that honors one arm rejects every *other* policy, naming the one it does
10572    /// honor so the fix is obvious from the message alone.
10573    #[test]
10574    fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
10575        for (binding, declared, honored) in [
10576            ("on-stage", "reopen-task", "write-manifests"),
10577            ("on-compaction", "write-manifests", "replace-context"),
10578            ("on-task-end", "replace-context", "reopen-task"),
10579            ("on-task-start", "replace-context", "seed-context"),
10580            ("on-shell", "replace-context", "deny"),
10581            ("on-tool-call", "replace-context", "deny"),
10582        ] {
10583            let yaml = format!(
10584                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
10585            );
10586            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
10587            assert!(err.contains(binding), "error was: {err}");
10588            assert!(
10589                err.contains(&format!("commit_policy '{declared}' is not valid")),
10590                "error was: {err}"
10591            );
10592            assert!(
10593                err.contains(&format!("honors commit_policy '{honored}'")),
10594                "error was: {err}"
10595            );
10596        }
10597    }
10598
10599    /// `on-inference` honors the `artifact` arm, which has no `commit_policy` spelling —
10600    /// so *no* non-`none` policy is ever valid for it, including `write-manifests`.
10601    #[test]
10602    fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
10603        for declared in ["replace-context", "write-manifests", "reopen-task"] {
10604            let yaml = format!(
10605                "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
10606            );
10607            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
10608            assert!(err.contains("on-inference"), "error was: {err}");
10609            assert!(err.contains(declared), "error was: {err}");
10610            assert!(
10611                err.contains("honors commit_policy 'none'"),
10612                "error was: {err}"
10613            );
10614            assert!(err.contains("artifact"), "error was: {err}");
10615        }
10616    }
10617
10618    /// A binding declaring exactly the policy it honors is accepted, for every
10619    /// representable pair.
10620    #[test]
10621    fn hook_config_matching_binding_and_commit_policy_is_valid() {
10622        for (binding, policy, expected) in [
10623            (
10624                "on-stage",
10625                "write-manifests",
10626                HookCommitPolicy::WriteManifests,
10627            ),
10628            (
10629                "on-compaction",
10630                "replace-context",
10631                HookCommitPolicy::ReplaceContext,
10632            ),
10633            ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
10634            ("on-shell", "deny", HookCommitPolicy::Deny),
10635            ("on-tool-call", "deny", HookCommitPolicy::Deny),
10636        ] {
10637            let yaml =
10638                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
10639            let config = parse_hook_config_from_yaml(&yaml).unwrap();
10640            assert_eq!(config.commit_policy, expected);
10641        }
10642    }
10643
10644    /// Every binding may declare `commit_policy: none` — including the ones that honor
10645    /// an arm, which simply means the hook opts out of committing.
10646    #[test]
10647    fn hook_config_commit_policy_none_is_valid_for_every_binding() {
10648        for binding in [
10649            "on-stage",
10650            "on-session-start",
10651            "on-task-start",
10652            "on-inference",
10653            "on-tool-call",
10654            "on-shell",
10655            "on-compaction",
10656            "on-task-end",
10657            "on-session-end",
10658        ] {
10659            let yaml =
10660                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
10661            let config = parse_hook_config_from_yaml(&yaml).unwrap();
10662            assert_eq!(config.commit_policy, HookCommitPolicy::None);
10663        }
10664    }
10665
10666    /// Resolved ambiguity: an omitted `binding:` is `HookBinding::All`, which is dispatched
10667    /// to every event — including every one that honors an arm — so every `commit_policy` is
10668    /// accepted for it. Deliberate: no narrowing for `All`. `deny` is the one exception,
10669    /// covered by [`hook_config_deny_without_a_binding_is_rejected`].
10670    #[test]
10671    fn hook_config_omitted_binding_accepts_every_commit_policy() {
10672        for (policy, expected) in [
10673            ("replace-context", HookCommitPolicy::ReplaceContext),
10674            ("write-manifests", HookCommitPolicy::WriteManifests),
10675            ("reopen-task", HookCommitPolicy::ReopenTask),
10676            ("none", HookCommitPolicy::None),
10677        ] {
10678            let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
10679            let config = parse_hook_config_from_yaml(&yaml).unwrap();
10680            assert_eq!(config.binding, HookBinding::All);
10681            assert_eq!(config.commit_policy, expected);
10682        }
10683    }
10684
10685    /// Every hook artifact shipped in `default-artifacts` today must keep parsing after the
10686    /// binding/commit_policy check exists — this change is non-breaking for all eight. The
10687    /// field combinations are reproduced literally rather than read from that repo, so this
10688    /// suite stays self-contained.
10689    #[test]
10690    fn hook_config_shipped_default_artifact_manifests_still_parse() {
10691        let shipped: [(&str, &str, HookConfig); 8] = [
10692            (
10693                "murmur-hook-compact",
10694                "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
10695                HookConfig {
10696                    binding: HookBinding::OnCompaction,
10697                    execution_mode: HookExecutionMode::Blocking,
10698                    commit_policy: HookCommitPolicy::ReplaceContext,
10699                },
10700            ),
10701            (
10702                "murmur-hook-shell-desc",
10703                "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
10704                HookConfig {
10705                    binding: HookBinding::OnStage,
10706                    execution_mode: HookExecutionMode::Blocking,
10707                    commit_policy: HookCommitPolicy::WriteManifests,
10708                },
10709            ),
10710            (
10711                // No `binding:` line in the shipped manifest — `All`, not `on-task-end`.
10712                "murmur-hook-regression-verifier",
10713                "execution_mode: blocking\ncommit_policy: reopen-task\n",
10714                HookConfig {
10715                    binding: HookBinding::All,
10716                    execution_mode: HookExecutionMode::Blocking,
10717                    commit_policy: HookCommitPolicy::ReopenTask,
10718                },
10719            ),
10720            (
10721                "murmur-hook-memory",
10722                "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
10723                HookConfig {
10724                    binding: HookBinding::OnTaskStart,
10725                    execution_mode: HookExecutionMode::Blocking,
10726                    commit_policy: HookCommitPolicy::SeedContext,
10727                },
10728            ),
10729            (
10730                "murmur-hook-debug",
10731                "execution_mode: async\ncommit_policy: none\n",
10732                HookConfig {
10733                    binding: HookBinding::All,
10734                    execution_mode: HookExecutionMode::Async,
10735                    commit_policy: HookCommitPolicy::None,
10736                },
10737            ),
10738            (
10739                "murmur-hook-diff-summary",
10740                "execution_mode: blocking\ncommit_policy: none\n",
10741                HookConfig {
10742                    binding: HookBinding::All,
10743                    execution_mode: HookExecutionMode::Blocking,
10744                    commit_policy: HookCommitPolicy::None,
10745                },
10746            ),
10747            ("murmur-hook-eval", "", HookConfig::default()),
10748            ("murmur-hook-grafana", "", HookConfig::default()),
10749        ];
10750
10751        for (name, fields, expected) in shipped {
10752            let yaml = format!("name: {name}\nruntime: hook\n{fields}");
10753            let config = parse_hook_config_from_yaml(&yaml)
10754                .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
10755            assert_eq!(config, expected, "shipped hook {name}");
10756        }
10757    }
10758
10759    /// The manifest-side honored-policy table agrees with the manifest spellings the
10760    /// parser accepts; `capsule-runtime` separately cross-checks it against its own
10761    /// `HONORED_OUTPUT_ARM` dispatch table.
10762    #[test]
10763    fn commit_policy_for_binding_matches_the_declarable_pairs() {
10764        assert_eq!(
10765            commit_policy_for_binding(&HookBinding::OnStage),
10766            Some(HookCommitPolicy::WriteManifests)
10767        );
10768        assert_eq!(
10769            commit_policy_for_binding(&HookBinding::OnCompaction),
10770            Some(HookCommitPolicy::ReplaceContext)
10771        );
10772        assert_eq!(
10773            commit_policy_for_binding(&HookBinding::OnTaskEnd),
10774            Some(HookCommitPolicy::ReopenTask)
10775        );
10776        assert_eq!(
10777            commit_policy_for_binding(&HookBinding::OnTaskStart),
10778            Some(HookCommitPolicy::SeedContext)
10779        );
10780        assert_eq!(
10781            commit_policy_for_binding(&HookBinding::OnToolCall),
10782            Some(HookCommitPolicy::Deny)
10783        );
10784        assert_eq!(
10785            commit_policy_for_binding(&HookBinding::OnShell),
10786            Some(HookCommitPolicy::Deny)
10787        );
10788        // `on-inference` honors `artifact`, which has no `commit_policy` spelling; the
10789        // other two honor nothing. `All` is unconstrained by design, apart from the `deny`
10790        // carve-out the parser applies.
10791        for binding in [
10792            HookBinding::OnSessionStart,
10793            HookBinding::OnInference,
10794            HookBinding::OnSessionEnd,
10795            HookBinding::All,
10796        ] {
10797            assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
10798        }
10799    }
10800
10801    /// The `on-task-start` pairing is declarable end to end: the parser accepts it, and
10802    /// refuses the same policy on any other binding by name.
10803    #[test]
10804    fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
10805        let config = parse_hook_config_from_yaml(
10806            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
10807             commit_policy: seed-context\n",
10808        )
10809        .expect("on-task-start + seed-context is the declarable pairing");
10810        assert_eq!(config.binding, HookBinding::OnTaskStart);
10811        assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
10812
10813        let error = parse_hook_config_from_yaml(
10814            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
10815             commit_policy: seed-context\n",
10816        )
10817        .expect_err("on-compaction cannot commit a seed");
10818        assert!(error.contains("on-compaction"), "{error}");
10819        assert!(error.contains("seed-context"), "{error}");
10820    }
10821
10822    /// The unknown-value diagnostic lists every accepted spelling, so an operator who
10823    /// mistypes one reads the whole set rather than guessing.
10824    #[test]
10825    fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
10826        let error = parse_hook_config_from_yaml(
10827            "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
10828        )
10829        .expect_err("a misspelled policy is rejected");
10830        assert!(error.contains("seed-context"), "{error}");
10831    }
10832
10833    // ── context.seed_budget / context.seed_overflow_margin ───────────────────
10834
10835    fn context_of(yaml_block: &str) -> Option<ContextConfig> {
10836        RuntimeManifest::from_yaml_str(&format!(
10837            "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
10838        ))
10839        .expect("the manifest under test parses")
10840        .context
10841    }
10842
10843    #[test]
10844    fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
10845        let context = context_of(
10846            "context:\n  max_tokens: 200000\n  seed_budget: 0.10\n  seed_overflow_margin: 0.10\n",
10847        )
10848        .expect("a declared context block parses to Some");
10849        assert_eq!(context.max_tokens, Some(200_000));
10850        assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
10851        assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
10852    }
10853
10854    #[test]
10855    fn seed_budget_and_overflow_margin_default_when_omitted() {
10856        let context = context_of("context:\n  max_tokens: 200000\n")
10857            .expect("a declared context block parses to Some");
10858        assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
10859        assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
10860    }
10861
10862    /// No `context:` block at all stays `None` — the two fractions default *within* a
10863    /// declared block, and never conjure one that the operator did not write.
10864    #[test]
10865    fn seed_budget_defaults_do_not_create_a_context_block() {
10866        assert_eq!(context_of(""), None);
10867    }
10868
10869    #[test]
10870    fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
10871        for (block, field) in [
10872            ("context:\n  seed_budget: 1.5\n", "context.seed_budget"),
10873            ("context:\n  seed_budget: -0.1\n", "context.seed_budget"),
10874            (
10875                "context:\n  seed_overflow_margin: 2.0\n",
10876                "context.seed_overflow_margin",
10877            ),
10878        ] {
10879            let error = RuntimeManifest::from_yaml_str(&format!(
10880                "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
10881            ))
10882            .expect_err("an out-of-range fraction is rejected");
10883            match error {
10884                RuntimeManifestError::InvalidInferenceConfig {
10885                    field: got,
10886                    ref message,
10887                } => {
10888                    assert_eq!(got, field);
10889                    assert!(message.contains("0.0 and 1.0"), "{message}");
10890                }
10891                other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
10892            }
10893        }
10894    }
10895
10896    #[test]
10897    fn parse_tool_implementation_defaults_to_wasm() {
10898        let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
10899        assert_eq!(impl_, ArtifactImplementation::Wasm);
10900    }
10901
10902    #[test]
10903    fn parse_tool_implementation_native() {
10904        let impl_ = parse_tool_implementation_from_yaml(
10905            "name: my-tool\nruntime: tool\nimplementation: native\n",
10906        );
10907        assert_eq!(impl_, ArtifactImplementation::Native);
10908    }
10909
10910    #[test]
10911    fn deploy_block_mur_version_parses() {
10912        let manifest = RuntimeManifest::from_yaml_str(
10913            "name: cap\nversion: 0.1.0\nartifacts: []\ndeploy:\n  mur_version: \"0.4.5\"\n",
10914        )
10915        .unwrap();
10916        assert_eq!(manifest.deploy_mur_version(), Some("0.4.5"));
10917        assert_eq!(
10918            manifest.deploy,
10919            Some(DeployConfig {
10920                mur_version: Some("0.4.5".to_string())
10921            })
10922        );
10923    }
10924
10925    #[test]
10926    fn deploy_block_absent_is_none() {
10927        let manifest =
10928            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
10929        assert_eq!(manifest.deploy, None);
10930        assert_eq!(manifest.deploy_mur_version(), None);
10931    }
10932
10933    /// A blank pin and an empty block both mean "install the deploying mur's own version".
10934    #[test]
10935    fn deploy_block_blank_mur_version_is_none() {
10936        for deploy in [
10937            "deploy:\n  mur_version: \"\"\n",
10938            "deploy:\n  mur_version: \"  \"\n",
10939        ] {
10940            let manifest = RuntimeManifest::from_yaml_str(&format!(
10941                "name: cap\nversion: 0.1.0\nartifacts: []\n{deploy}"
10942            ))
10943            .unwrap();
10944            assert_eq!(manifest.deploy_mur_version(), None, "{deploy:?}");
10945        }
10946        let manifest =
10947            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\ndeploy: {}\n").unwrap();
10948        assert_eq!(manifest.deploy, Some(DeployConfig::default()));
10949        assert_eq!(manifest.deploy_mur_version(), None);
10950    }
10951
10952    #[test]
10953    fn deploy_block_misspelled_key_is_reported_under_deploy() {
10954        assert_eq!(
10955            unknown_keys_of("name: cap\nversion: 0.1.0\ndeploy:\n  mur_versoin: \"0.4.5\"\n"),
10956            vec![(
10957                "mur_versoin".to_string(),
10958                "deploy".to_string(),
10959                Some("mur_version".to_string())
10960            )]
10961        );
10962    }
10963
10964    #[test]
10965    fn a_top_level_mur_version_is_refused_naming_deploy_mur_version() {
10966        for pin in [
10967            "mur_version: \"0.4.5\"\n",
10968            "mur_version: 0.4.0\n",
10969            "mur_version: \"\"\n",
10970            "mur_version:\n",
10971        ] {
10972            let error = RuntimeManifest::from_yaml_str(&format!(
10973                "name: cap\nversion: 0.1.0\nartifacts: []\n{pin}"
10974            ))
10975            .expect_err(pin);
10976            assert!(
10977                matches!(error, RuntimeManifestError::TopLevelMurVersion),
10978                "{pin:?}: {error:?}"
10979            );
10980            assert!(error.to_string().contains("deploy.mur_version"), "{error}");
10981        }
10982        assert_eq!(
10983            RuntimeManifestError::TopLevelMurVersion.to_string(),
10984            "murmur.yaml: 'mur_version' is not a top-level key — the mur release a deploy \
10985             installs is pinned as 'deploy.mur_version'"
10986        );
10987    }
10988
10989    #[test]
10990    fn a_top_level_mur_version_is_refused_beside_a_deploy_block() {
10991        let yaml = "name: cap\nversion: 0.1.0\nmur_version: \"0.4.5\"\ndeploy:\n  mur_version: \"0.4.5\"\n";
10992        assert!(matches!(
10993            RuntimeManifest::from_yaml_str(yaml),
10994            Err(RuntimeManifestError::TopLevelMurVersion)
10995        ));
10996        assert!(matches!(
10997            RuntimeManifest::from_yaml_str_without_secrets(yaml),
10998            Err(RuntimeManifestError::TopLevelMurVersion)
10999        ));
11000    }
11001
11002    #[test]
11003    fn inference_max_turns_defaults_to_10() {
11004        let manifest = RuntimeManifest::from_yaml_str(
11005            "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",
11006        ).unwrap();
11007        assert_eq!(manifest.inference.unwrap().max_turns, 10);
11008    }
11009
11010    #[test]
11011    fn inference_max_turns_explicit_value() {
11012        let manifest = RuntimeManifest::from_yaml_str(
11013            "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",
11014        ).unwrap();
11015        assert_eq!(manifest.inference.unwrap().max_turns, 20);
11016    }
11017
11018    #[test]
11019    fn inference_max_turns_zero_is_rejected() {
11020        let err = RuntimeManifest::from_yaml_str(
11021            "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",
11022        ).unwrap_err();
11023        let msg = err.to_string();
11024        assert!(msg.contains("inference.max_turns"), "error was: {msg}");
11025        assert!(msg.contains("greater than 0"), "error was: {msg}");
11026    }
11027
11028    /// A `lifecycle:` block that says nothing about the budget still permits one reopen.
11029    #[test]
11030    fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
11031        let manifest = RuntimeManifest::from_yaml_str(
11032            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 45\n",
11033        )
11034        .unwrap();
11035        let lifecycle = manifest.lifecycle.unwrap();
11036        assert_eq!(lifecycle.shell_grace_secs, 45);
11037        // Every other lifecycle field is untouched by declaring this one.
11038        assert_eq!(
11039            lifecycle.task_acceptance,
11040            LifecycleConfig::default().task_acceptance
11041        );
11042        assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
11043        assert_eq!(
11044            lifecycle.queue_depth,
11045            LifecycleConfig::default().queue_depth
11046        );
11047        assert_eq!(lifecycle.input_timeout_secs, None);
11048        assert_eq!(
11049            lifecycle.conversation_mode,
11050            LifecycleConfig::default().conversation_mode
11051        );
11052        assert_eq!(
11053            lifecycle.max_task_reopens,
11054            LifecycleConfig::default().max_task_reopens
11055        );
11056    }
11057
11058    #[test]
11059    fn lifecycle_shell_grace_secs_defaults_to_10() {
11060        let manifest = RuntimeManifest::from_yaml_str(
11061            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
11062        )
11063        .unwrap();
11064        let lifecycle = manifest.lifecycle.unwrap();
11065        assert_eq!(lifecycle.shell_grace_secs, 10);
11066        assert_eq!(lifecycle.queue_depth, 3);
11067
11068        let without_block =
11069            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11070        assert_eq!(without_block.effective_lifecycle().shell_grace_secs, 10);
11071    }
11072
11073    /// The bound a capsule delegates under is declared, and a capsule that declares nothing is
11074    /// still bounded: absent means a ceiling, never "wait forever".
11075    #[test]
11076    fn lifecycle_delegation_deadline_secs_defaults_to_600() {
11077        let without_block =
11078            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11079        assert_eq!(
11080            without_block.effective_lifecycle().delegation_deadline_secs,
11081            600
11082        );
11083
11084        let without_key = RuntimeManifest::from_yaml_str(
11085            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
11086        )
11087        .unwrap();
11088        let lifecycle = without_key.lifecycle.unwrap();
11089        assert_eq!(lifecycle.delegation_deadline_secs, 600);
11090        assert_eq!(lifecycle.queue_depth, 3);
11091    }
11092
11093    #[test]
11094    fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
11095        let manifest = RuntimeManifest::from_yaml_str(
11096            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
11097             delegation_deadline_secs: 30\n",
11098        )
11099        .unwrap();
11100        let lifecycle = manifest.lifecycle.unwrap();
11101        assert_eq!(lifecycle.delegation_deadline_secs, 30);
11102        // Every other lifecycle field is untouched by declaring this one.
11103        assert_eq!(
11104            lifecycle.shell_grace_secs,
11105            LifecycleConfig::default().shell_grace_secs
11106        );
11107        assert_eq!(
11108            lifecycle.max_task_reopens,
11109            LifecycleConfig::default().max_task_reopens
11110        );
11111    }
11112
11113    /// Declaring the key produces no unknown-key report, and a misspelling of it still does.
11114    #[test]
11115    fn lifecycle_delegation_deadline_secs_is_a_known_key() {
11116        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
11117        let manifest = RuntimeManifest::from_yaml_str(
11118            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
11119             delegation_deadline_secs: 30\n  delegation_deadline: 30\n",
11120        )
11121        .unwrap();
11122        let reported: Vec<(String, String)> = manifest
11123            .unknown_keys
11124            .iter()
11125            .map(|key| (key.key.clone(), key.block_path.clone()))
11126            .collect();
11127        assert_eq!(
11128            reported,
11129            vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
11130            "the declared key is known and only the misspelt neighbour is reported"
11131        );
11132        assert_eq!(
11133            manifest.lifecycle.unwrap().delegation_deadline_secs,
11134            30,
11135            "the misspelt neighbour does not disturb the declared value"
11136        );
11137    }
11138
11139    /// `0` is a valid explicit value, not an absent one: it demotes at the first poll after the
11140    /// spawn, so effectively every command detaches.
11141    #[test]
11142    fn lifecycle_shell_grace_secs_zero_is_accepted() {
11143        let manifest = RuntimeManifest::from_yaml_str(
11144            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 0\n",
11145        )
11146        .unwrap();
11147        let lifecycle = manifest.lifecycle.unwrap();
11148        assert_eq!(lifecycle.shell_grace_secs, 0);
11149        assert_eq!(
11150            lifecycle.max_task_reopens,
11151            LifecycleConfig::default().max_task_reopens
11152        );
11153    }
11154
11155    #[test]
11156    fn lifecycle_max_task_reopens_defaults_to_1() {
11157        let manifest = RuntimeManifest::from_yaml_str(
11158            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  after_task: sleep\n",
11159        )
11160        .unwrap();
11161        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
11162    }
11163
11164    /// No `lifecycle:` block at all lands on the same default through `effective_lifecycle`.
11165    #[test]
11166    fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
11167        let manifest =
11168            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
11169        assert!(manifest.lifecycle.is_none());
11170        assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
11171    }
11172
11173    #[test]
11174    fn lifecycle_max_task_reopens_explicit_value() {
11175        let manifest = RuntimeManifest::from_yaml_str(
11176            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 3\n",
11177        )
11178        .unwrap();
11179        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
11180    }
11181
11182    /// Unlike `inference.max_turns`, `0` is a valid explicit value — it disables reopening.
11183    #[test]
11184    fn lifecycle_max_task_reopens_zero_is_accepted() {
11185        let manifest = RuntimeManifest::from_yaml_str(
11186            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 0\n",
11187        )
11188        .unwrap();
11189        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
11190    }
11191
11192    /// The budget is independent of the inference transport in play.
11193    #[test]
11194    fn lifecycle_max_task_reopens_with_process_transport() {
11195        let manifest = RuntimeManifest::from_yaml_str(
11196            "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",
11197        ).unwrap();
11198        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
11199    }
11200
11201    #[test]
11202    fn inference_max_task_reopens_is_rejected_under_http_transport() {
11203        let err = RuntimeManifest::from_yaml_str(
11204            "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",
11205        ).unwrap_err();
11206        assert!(
11207            matches!(
11208                &err,
11209                RuntimeManifestError::InvalidInferenceConfig { field, .. }
11210                    if field == "inference.max_task_reopens"
11211            ),
11212            "error was: {err:?}"
11213        );
11214        let msg = err.to_string();
11215        assert!(
11216            msg.contains("lifecycle.max_task_reopens"),
11217            "error was: {msg}"
11218        );
11219    }
11220
11221    #[test]
11222    fn inference_max_task_reopens_is_rejected_under_process_transport() {
11223        let err = RuntimeManifest::from_yaml_str(
11224            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  max_task_reopens: 2\n",
11225        ).unwrap_err();
11226        assert!(
11227            matches!(
11228                &err,
11229                RuntimeManifestError::InvalidInferenceConfig { field, .. }
11230                    if field == "inference.max_task_reopens"
11231            ),
11232            "error was: {err:?}"
11233        );
11234        let msg = err.to_string();
11235        assert!(
11236            msg.contains("lifecycle.max_task_reopens"),
11237            "error was: {msg}"
11238        );
11239    }
11240
11241    /// The rejection is unconditional: the new key being present alongside it changes nothing.
11242    #[test]
11243    fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
11244        let err = RuntimeManifest::from_yaml_str(
11245            "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",
11246        ).unwrap_err();
11247        assert!(
11248            matches!(
11249                &err,
11250                RuntimeManifestError::InvalidInferenceConfig { field, .. }
11251                    if field == "inference.max_task_reopens"
11252            ),
11253            "error was: {err:?}"
11254        );
11255    }
11256
11257    #[test]
11258    fn skill_is_llm_visible() {
11259        assert!(ArtifactRuntime::Skill.is_llm_visible());
11260    }
11261
11262    #[test]
11263    fn driver_and_hook_are_not_llm_visible() {
11264        assert!(!ArtifactRuntime::Driver.is_llm_visible());
11265        assert!(!ArtifactRuntime::Hook.is_llm_visible());
11266    }
11267
11268    #[test]
11269    fn parses_system_prompt_artifact() {
11270        let manifest = RuntimeManifest::from_yaml_str(
11271            r#"
11272name: cap
11273version: 0.0.1
11274artifacts:
11275  - name: local/my-skill
11276    version: 1.0.0
11277    runtime: skill
11278  - name: murmur/driver-anthropic
11279    version: 0.1.0
11280    runtime: driver
11281    gateway:
11282      endpoint: http://127.0.0.1:8080
11283      keyless: true
11284inference:
11285  model: test-model
11286  system_prompt_artifact: local/my-skill
11287  driver:
11288    artifact: murmur/driver-anthropic
11289"#,
11290        )
11291        .unwrap();
11292
11293        let inference = manifest.inference.unwrap();
11294        assert_eq!(
11295            inference.system_prompt_artifact,
11296            Some("local/my-skill".to_string())
11297        );
11298        assert!(inference.system_prompt.is_none());
11299        assert!(inference.system_prompt_file.is_none());
11300    }
11301
11302    #[test]
11303    fn rejects_system_prompt_artifact_with_inline_prompt() {
11304        let err = RuntimeManifest::from_yaml_str(
11305            r#"
11306name: cap
11307version: 0.0.1
11308artifacts:
11309  - name: local/my-skill
11310    version: 1.0.0
11311    runtime: skill
11312  - name: murmur/driver-anthropic
11313    version: 0.1.0
11314    runtime: driver
11315    gateway:
11316      endpoint: http://127.0.0.1:8080
11317      keyless: true
11318inference:
11319  model: test-model
11320  system_prompt: "inline"
11321  system_prompt_artifact: local/my-skill
11322  driver:
11323    artifact: murmur/driver-anthropic
11324"#,
11325        )
11326        .unwrap_err();
11327
11328        let msg = err.to_string();
11329        assert!(msg.contains("at most one"), "error was: {msg}");
11330    }
11331
11332    #[test]
11333    fn rejects_system_prompt_artifact_naming_non_skill() {
11334        let err = RuntimeManifest::from_yaml_str(
11335            r#"
11336name: cap
11337version: 0.0.1
11338artifacts:
11339  - name: local/my-tool
11340    version: 1.0.0
11341    runtime: tool
11342  - name: murmur/driver-anthropic
11343    version: 0.1.0
11344    runtime: driver
11345    gateway:
11346      endpoint: http://127.0.0.1:8080
11347      keyless: true
11348inference:
11349  model: test-model
11350  system_prompt_artifact: local/my-tool
11351  driver:
11352    artifact: murmur/driver-anthropic
11353"#,
11354        )
11355        .unwrap_err();
11356
11357        let msg = err.to_string();
11358        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
11359        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
11360    }
11361
11362    #[test]
11363    fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
11364        let err = RuntimeManifest::from_yaml_str(
11365            r#"
11366name: cap
11367version: 0.0.1
11368artifacts:
11369  - name: murmur/driver-anthropic
11370    version: 0.1.0
11371    runtime: driver
11372    gateway:
11373      endpoint: http://127.0.0.1:8080
11374      keyless: true
11375inference:
11376  model: test-model
11377  system_prompt_artifact: missing-skill
11378  driver:
11379    artifact: murmur/driver-anthropic
11380"#,
11381        )
11382        .unwrap_err();
11383
11384        let msg = err.to_string();
11385        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
11386        assert!(msg.contains("not declared"), "error was: {msg}");
11387    }
11388
11389    #[test]
11390    fn rejects_all_three_system_prompt_sources() {
11391        let err = RuntimeManifest::from_yaml_str(
11392            r#"
11393name: cap
11394version: 0.0.1
11395artifacts:
11396  - name: local/my-skill
11397    version: 1.0.0
11398    runtime: skill
11399  - name: murmur/driver-anthropic
11400    version: 0.1.0
11401    runtime: driver
11402    gateway:
11403      endpoint: http://127.0.0.1:8080
11404      keyless: true
11405inference:
11406  model: test-model
11407  system_prompt: "inline"
11408  system_prompt_file: "conventions.md"
11409  system_prompt_artifact: local/my-skill
11410  driver:
11411    artifact: murmur/driver-anthropic
11412"#,
11413        )
11414        .unwrap_err();
11415
11416        let msg = err.to_string();
11417        assert!(msg.contains("at most one"), "error was: {msg}");
11418    }
11419
11420    // ── Per-artifact capability grants ───────────────────────────────────────
11421
11422    #[test]
11423    fn hook_artifact_capabilities_parse_network_and_filesystem() {
11424        let manifest = RuntimeManifest::from_yaml_str(
11425            r#"
11426name: cap
11427version: 0.0.1
11428artifacts:
11429  - name: local/telemetry-hook
11430    version: 1.0.0
11431    runtime: hook
11432    capabilities:
11433      network:
11434        allow:
11435          - https://telemetry.example.com
11436      filesystem:
11437        scope: hook-state
11438"#,
11439        )
11440        .unwrap();
11441
11442        let caps = manifest.artifacts[0]
11443            .capabilities
11444            .as_ref()
11445            .expect("hook entry carries a capability grant");
11446        assert_eq!(
11447            caps.network.as_ref().unwrap().allow,
11448            vec!["https://telemetry.example.com".to_string()]
11449        );
11450        assert_eq!(
11451            caps.filesystem.as_ref().unwrap().scope,
11452            Some("hook-state".to_string())
11453        );
11454    }
11455
11456    /// Default-deny: a hook entry with no `capabilities:` key parses to `None`, which the
11457    /// runtime lowers to zero network and zero preopened directories.
11458    #[test]
11459    fn hook_artifact_without_capabilities_is_none() {
11460        let manifest = RuntimeManifest::from_yaml_str(
11461            r#"
11462name: cap
11463version: 0.0.1
11464artifacts:
11465  - name: local/quiet-hook
11466    version: 1.0.0
11467    runtime: hook
11468"#,
11469        )
11470        .unwrap();
11471
11472        assert!(manifest.artifacts[0].capabilities.is_none());
11473    }
11474
11475    /// Tools and drivers execute, so they may carry a grant — the runtime reads it as a
11476    /// narrowing of the capsule-wide ceiling rather than as a default-deny baseline.
11477    #[test]
11478    fn tool_and_driver_artifact_capabilities_parse() {
11479        for role in ["tool", "driver"] {
11480            let yaml = format!(
11481                r#"
11482name: cap
11483version: 0.0.1
11484artifacts:
11485  - name: local/scoped
11486    version: 1.0.0
11487    runtime: {role}
11488    capabilities:
11489      network:
11490        allow:
11491          - https://api.example.com
11492      filesystem:
11493        scope: cache
11494"#
11495            );
11496            let manifest = RuntimeManifest::from_yaml_str(&yaml)
11497                .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
11498
11499            let caps = manifest.artifacts[0]
11500                .capabilities
11501                .as_ref()
11502                .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
11503            assert_eq!(
11504                caps.network.as_ref().unwrap().allow,
11505                vec!["https://api.example.com".to_string()]
11506            );
11507            assert_eq!(
11508                caps.filesystem.as_ref().unwrap().scope,
11509                Some("cache".to_string())
11510            );
11511        }
11512    }
11513
11514    /// A tool/driver entry with no `capabilities:` key parses to `None`, which the runtime
11515    /// lowers to the unchanged capsule ceiling.
11516    #[test]
11517    fn tool_artifact_without_capabilities_is_none() {
11518        let manifest = RuntimeManifest::from_yaml_str(
11519            r#"
11520name: cap
11521version: 0.0.1
11522artifacts:
11523  - name: local/plain-tool
11524    version: 1.0.0
11525    runtime: tool
11526"#,
11527        )
11528        .unwrap();
11529
11530        assert!(manifest.artifacts[0].capabilities.is_none());
11531    }
11532
11533    /// A skill has no execution surface, so a grant on one would be silently unenforced —
11534    /// rejected at parse time, with a message naming the roles that do work.
11535    #[test]
11536    fn skill_artifact_capabilities_are_rejected() {
11537        let yaml = r#"
11538name: cap
11539version: 0.0.1
11540artifacts:
11541  - name: local/sneaky
11542    version: 1.0.0
11543    runtime: skill
11544    capabilities:
11545      network:
11546        allow:
11547          - https://evil.example.com
11548"#;
11549        let err = match RuntimeManifest::from_yaml_str(yaml) {
11550            Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
11551            Err(err) => err,
11552        };
11553        let msg = err.to_string();
11554        assert!(msg.contains("sneaky"), "error was: {msg}");
11555        assert!(
11556            msg.contains(
11557                "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
11558                 entries"
11559            ),
11560            "error was: {msg}"
11561        );
11562        assert!(msg.contains("runtime: skill"), "error was: {msg}");
11563    }
11564
11565    // ── Per-artifact `config:` (operator-authored artifact configuration) ────
11566
11567    /// The block is carried verbatim as YAML. This crate accepts any mapping; the runtime is
11568    /// what lowers it to JSON and refuses a shape that cannot travel that way.
11569    #[test]
11570    fn artifact_config_is_carried_verbatim() {
11571        let manifest = RuntimeManifest::from_yaml_str(
11572            r#"
11573name: cap
11574version: 0.0.1
11575artifacts:
11576  - name: murmur/tool-corpus
11577    version: 0.1.0
11578    runtime: tool
11579    config:
11580      read_recent: { default: 20, max: 100 }
11581"#,
11582        )
11583        .unwrap();
11584
11585        let config = manifest.artifacts[0]
11586            .config
11587            .as_ref()
11588            .expect("a declared block parses");
11589        assert_eq!(
11590            config["read_recent"]["max"],
11591            serde_yaml::Value::Number(100.into())
11592        );
11593    }
11594
11595    /// Absent means absent: no key, no variable, and nothing for the runtime to lower.
11596    #[test]
11597    fn an_undeclared_config_is_none() {
11598        let manifest = RuntimeManifest::from_yaml_str(
11599            r#"
11600name: cap
11601version: 0.0.1
11602artifacts:
11603  - name: local/git
11604    version: 1.0.0
11605    runtime: tool
11606"#,
11607        )
11608        .unwrap();
11609
11610        assert!(manifest.artifacts[0].config.is_none());
11611    }
11612
11613    /// `config:` with nothing under it is a written declaration that carries nothing, and must
11614    /// stay distinguishable from the key being absent — the runtime refuses it, and can only do
11615    /// so if the parser does not collapse YAML null into `None`.
11616    #[test]
11617    fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
11618        let manifest = RuntimeManifest::from_yaml_str(
11619            r#"
11620name: cap
11621version: 0.0.1
11622artifacts:
11623  - name: local/git
11624    version: 1.0.0
11625    runtime: tool
11626    config:
11627"#,
11628        )
11629        .unwrap();
11630
11631        assert_eq!(
11632            manifest.artifacts[0].config,
11633            Some(serde_yaml::Value::Null),
11634            "an empty block must survive parsing as a declaration, not vanish into None"
11635        );
11636    }
11637
11638    /// A skill runs no component and holds no grant, so nothing would ever deliver a config
11639    /// block to one — rejected at parse time, with a message naming the roles that do.
11640    #[test]
11641    fn skill_artifact_config_is_rejected() {
11642        let yaml = r#"
11643name: cap
11644version: 0.0.1
11645artifacts:
11646  - name: local/sneaky
11647    version: 1.0.0
11648    runtime: skill
11649    config:
11650      who: me
11651"#;
11652        let err = match RuntimeManifest::from_yaml_str(yaml) {
11653            Ok(_) => panic!("config: must be rejected for runtime: skill"),
11654            Err(err) => err,
11655        };
11656        assert!(matches!(
11657            err,
11658            RuntimeManifestError::InvalidArtifact { index: 0, .. }
11659        ));
11660        let msg = err.to_string();
11661        assert!(msg.contains("sneaky"), "error was: {msg}");
11662        assert!(msg.contains("runtime: skill"), "error was: {msg}");
11663        assert!(
11664            msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
11665            "error was: {msg}"
11666        );
11667    }
11668
11669    /// Config is delivered on one artifact's own grant, so a capsule-wide block reaches nothing.
11670    /// Refused rather than silently ignored, and the message points at the artifact entry.
11671    #[test]
11672    fn a_capsule_wide_config_block_is_rejected() {
11673        let yaml = r#"
11674name: cap
11675version: 0.0.1
11676config:
11677  who: capsule
11678artifacts:
11679  - name: local/git
11680    version: 1.0.0
11681    runtime: tool
11682"#;
11683        let err = match RuntimeManifest::from_yaml_str(yaml) {
11684            Ok(_) => panic!("a capsule-wide config block must be rejected"),
11685            Err(err) => err,
11686        };
11687        let msg = err.to_string();
11688        assert!(msg.contains("config"), "error was: {msg}");
11689        assert!(msg.contains("declared per artifact"), "error was: {msg}");
11690    }
11691
11692    // ── Per-artifact `on_overflow:` (async hook queue policy) ────────────────
11693
11694    /// `on_overflow: block` on a hook entry parses to the blocking policy.
11695    #[test]
11696    fn hook_on_overflow_block_parses() {
11697        let manifest = RuntimeManifest::from_yaml_str(
11698            r#"
11699name: cap
11700version: 0.0.1
11701artifacts:
11702  - name: murmur/hook-grafana
11703    version: 1.0.0
11704    runtime: hook
11705    on_overflow: block
11706"#,
11707        )
11708        .unwrap();
11709
11710        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
11711    }
11712
11713    /// Omitting the key means `drop`: an operator who says nothing gets the loop kept moving,
11714    /// not backpressure they never asked for.
11715    #[test]
11716    fn hook_on_overflow_defaults_to_drop() {
11717        let manifest = RuntimeManifest::from_yaml_str(
11718            r#"
11719name: cap
11720version: 0.0.1
11721artifacts:
11722  - name: murmur/hook-grafana
11723    version: 1.0.0
11724    runtime: hook
11725"#,
11726        )
11727        .unwrap();
11728
11729        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
11730        assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
11731    }
11732
11733    /// Only a hook has a job queue, so the key is rejected on every other role rather than
11734    /// silently ignored — the same rule per-artifact `capabilities:` follows on a skill.
11735    #[test]
11736    fn on_overflow_on_a_non_hook_artifact_is_rejected() {
11737        let yaml = r#"
11738name: cap
11739version: 0.0.1
11740artifacts:
11741  - name: local/git
11742    version: 1.0.0
11743    runtime: tool
11744    on_overflow: block
11745"#;
11746        let err = match RuntimeManifest::from_yaml_str(yaml) {
11747            Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
11748            Err(err) => err,
11749        };
11750        assert!(matches!(
11751            err,
11752            RuntimeManifestError::InvalidArtifact { index: 0, .. }
11753        ));
11754        let msg = err.to_string();
11755        assert!(msg.contains("git"), "error was: {msg}");
11756        assert!(
11757            msg.contains("only recognized on 'runtime: hook' entries"),
11758            "error was: {msg}"
11759        );
11760    }
11761
11762    /// An unknown value is a typo, not a policy — rejected with both spellings named.
11763    #[test]
11764    fn unknown_on_overflow_value_is_rejected() {
11765        let yaml = r#"
11766name: cap
11767version: 0.0.1
11768artifacts:
11769  - name: murmur/hook-grafana
11770    version: 1.0.0
11771    runtime: hook
11772    on_overflow: spill
11773"#;
11774        let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
11775        let msg = err.to_string();
11776        assert!(msg.contains("spill"), "error was: {msg}");
11777        assert!(msg.contains("expected: drop, block"), "error was: {msg}");
11778    }
11779
11780    /// The key is legal on a *blocking* hook entry and simply inert: the operator manifest
11781    /// cannot know a hook's execution mode, which lives in the artifact's own manifest.
11782    #[test]
11783    fn on_overflow_is_accepted_on_any_hook_entry() {
11784        let manifest = RuntimeManifest::from_yaml_str(
11785            r#"
11786name: cap
11787version: 0.0.1
11788artifacts:
11789  - name: murmur/hook-eval
11790    version: 1.0.0
11791    runtime: hook
11792    on_overflow: drop
11793    capabilities:
11794      network:
11795        allow:
11796          - https://telemetry.example.com
11797"#,
11798        )
11799        .unwrap();
11800
11801        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
11802        assert!(manifest.artifacts[0].capabilities.is_some());
11803    }
11804
11805    /// The capsule-wide top-level block is untouched by the per-artifact field: both may be
11806    /// present in one manifest and neither shadows the other.
11807    #[test]
11808    fn capsule_wide_and_hook_capabilities_coexist() {
11809        let manifest = RuntimeManifest::from_yaml_str(
11810            r#"
11811name: cap
11812version: 0.0.1
11813artifacts:
11814  - name: local/telemetry-hook
11815    version: 1.0.0
11816    runtime: hook
11817    capabilities:
11818      network:
11819        allow:
11820          - https://telemetry.example.com
11821capabilities:
11822  network:
11823    allow:
11824      - https://api.anthropic.com
11825"#,
11826        )
11827        .unwrap();
11828
11829        assert_eq!(
11830            manifest.capabilities.unwrap().network.unwrap().allow,
11831            vec!["https://api.anthropic.com".to_string()]
11832        );
11833        assert_eq!(
11834            manifest.artifacts[0]
11835                .capabilities
11836                .as_ref()
11837                .unwrap()
11838                .network
11839                .as_ref()
11840                .unwrap()
11841                .allow,
11842            vec!["https://telemetry.example.com".to_string()]
11843        );
11844    }
11845
11846    /// No self-escalation: the hook artifact's *own* bundled murmur.yaml is parsed only by
11847    /// `parse_hook_config_from_yaml`, which has no capabilities field and no code path that
11848    /// reads one. A self-declared `capabilities:` block there is inert.
11849    #[test]
11850    fn hook_own_manifest_capabilities_are_not_read_into_config() {
11851        let config = parse_hook_config_from_yaml(
11852            r#"
11853name: telemetry-hook
11854version: 1.0.0
11855binding: on-inference
11856execution_mode: blocking
11857capabilities:
11858  network:
11859    allow:
11860      - https://evil.example.com
11861  filesystem:
11862    scope: /
11863"#,
11864        )
11865        .expect("a self-declared capabilities block is ignored, not an error");
11866
11867        assert_eq!(
11868            config,
11869            HookConfig {
11870                binding: HookBinding::OnInference,
11871                execution_mode: HookExecutionMode::Blocking,
11872                commit_policy: HookCommitPolicy::None,
11873            }
11874        );
11875    }
11876
11877    // ── Containment class ────────────────────────────────────────────────────
11878
11879    #[test]
11880    fn containment_classes_order_weakest_to_strongest() {
11881        assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
11882        assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
11883        assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
11884        assert_eq!(
11885            ContainmentClass::ALL.iter().copied().max(),
11886            Some(ContainmentClass::Sealed)
11887        );
11888    }
11889
11890    #[test]
11891    fn containment_class_defaults_to_advisory() {
11892        assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
11893    }
11894
11895    #[test]
11896    fn containment_class_round_trips_through_string() {
11897        for class in ContainmentClass::ALL {
11898            assert_eq!(
11899                class.to_string().parse::<ContainmentClass>(),
11900                Ok(class),
11901                "{class} must survive Display -> FromStr"
11902            );
11903        }
11904        assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
11905        assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
11906        assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
11907    }
11908
11909    #[test]
11910    fn containment_class_rejects_unknown_names() {
11911        let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
11912        assert_eq!(err.value, "paranoid");
11913        assert_eq!(
11914            err.to_string(),
11915            "must be one of: advisory, scoped, sealed; got 'paranoid'"
11916        );
11917        // Case-sensitive by design: the wire form is lowercase everywhere.
11918        assert!("Sealed".parse::<ContainmentClass>().is_err());
11919    }
11920
11921    #[test]
11922    fn containment_class_serializes_as_its_wire_name() {
11923        assert_eq!(
11924            serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
11925            "\"scoped\""
11926        );
11927        assert_eq!(
11928            serde_yaml::from_str::<ContainmentClass>("sealed").unwrap(),
11929            ContainmentClass::Sealed
11930        );
11931    }
11932
11933    #[test]
11934    fn effective_floor_is_advisory_when_nothing_is_declared() {
11935        assert_eq!(
11936            effective_containment_floor(None, None, None),
11937            ContainmentClass::Advisory
11938        );
11939    }
11940
11941    #[test]
11942    fn effective_floor_takes_the_strongest_across_every_presence_combination() {
11943        use ContainmentClass::{Advisory, Scoped, Sealed};
11944
11945        // Exactly one source present: that source wins outright.
11946        assert_eq!(
11947            effective_containment_floor(Some(Scoped), None, None),
11948            Scoped
11949        );
11950        assert_eq!(
11951            effective_containment_floor(None, Some(Scoped), None),
11952            Scoped
11953        );
11954        assert_eq!(
11955            effective_containment_floor(None, None, Some(Scoped)),
11956            Scoped
11957        );
11958        assert_eq!(
11959            effective_containment_floor(Some(Sealed), None, None),
11960            Sealed
11961        );
11962        assert_eq!(
11963            effective_containment_floor(None, Some(Sealed), None),
11964            Sealed
11965        );
11966        assert_eq!(
11967            effective_containment_floor(None, None, Some(Sealed)),
11968            Sealed
11969        );
11970
11971        // Two sources present: the stronger wins regardless of which slot holds it, and a
11972        // weaker source can never pull the stronger one down.
11973        assert_eq!(
11974            effective_containment_floor(Some(Advisory), Some(Sealed), None),
11975            Sealed
11976        );
11977        assert_eq!(
11978            effective_containment_floor(Some(Sealed), Some(Advisory), None),
11979            Sealed
11980        );
11981        assert_eq!(
11982            effective_containment_floor(Some(Advisory), None, Some(Scoped)),
11983            Scoped
11984        );
11985        assert_eq!(
11986            effective_containment_floor(Some(Scoped), None, Some(Advisory)),
11987            Scoped
11988        );
11989        assert_eq!(
11990            effective_containment_floor(None, Some(Advisory), Some(Scoped)),
11991            Scoped
11992        );
11993        assert_eq!(
11994            effective_containment_floor(None, Some(Scoped), Some(Advisory)),
11995            Scoped
11996        );
11997
11998        // All three present: max wins from every slot, and all-equal is a fixed point.
11999        assert_eq!(
12000            effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
12001            Sealed
12002        );
12003        assert_eq!(
12004            effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
12005            Sealed
12006        );
12007        assert_eq!(
12008            effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
12009            Sealed
12010        );
12011        assert_eq!(
12012            effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
12013            Scoped
12014        );
12015        assert_eq!(
12016            effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
12017            Advisory
12018        );
12019    }
12020
12021    #[test]
12022    fn manifest_parses_each_declared_containment_class() {
12023        for class in ContainmentClass::ALL {
12024            let manifest = RuntimeManifest::from_yaml_str(&format!(
12025                r#"
12026name: cap
12027version: 0.0.1
12028capabilities:
12029  containment: {class}
12030"#
12031            ))
12032            .expect("a valid containment class parses");
12033
12034            assert_eq!(
12035                manifest.capabilities.unwrap().containment,
12036                Some(class),
12037                "capabilities.containment: {class}"
12038            );
12039        }
12040    }
12041
12042    #[test]
12043    fn manifest_without_containment_key_declares_nothing() {
12044        // Absent `capabilities:` block entirely.
12045        let manifest = RuntimeManifest::from_yaml_str(
12046            r#"
12047name: cap
12048version: 0.0.1
12049"#,
12050        )
12051        .unwrap();
12052        assert!(manifest.capabilities.is_none());
12053
12054        // Present `capabilities:` block that simply omits the key: still None, never a
12055        // silently-defaulted Advisory floor stored as if the operator had written it.
12056        let manifest = RuntimeManifest::from_yaml_str(
12057            r#"
12058name: cap
12059version: 0.0.1
12060capabilities:
12061  network:
12062    allow:
12063      - https://api.example.com
12064"#,
12065        )
12066        .unwrap();
12067        assert_eq!(manifest.capabilities.unwrap().containment, None);
12068    }
12069
12070    #[test]
12071    fn manifest_rejects_an_unknown_containment_class() {
12072        let err = RuntimeManifest::from_yaml_str(
12073            r#"
12074name: cap
12075version: 0.0.1
12076capabilities:
12077  containment: paranoid
12078"#,
12079        )
12080        .expect_err("an unknown containment class is rejected at parse time");
12081
12082        match err {
12083            RuntimeManifestError::InvalidCapabilities { field, message } => {
12084                assert_eq!(field, "capabilities.containment");
12085                assert_eq!(
12086                    message,
12087                    "must be one of: advisory, scoped, sealed; got 'paranoid'"
12088                );
12089            }
12090            other => panic!("expected InvalidCapabilities, got {other:?}"),
12091        }
12092    }
12093
12094    // ── exports.files ────────────────────────────────────────────────────────
12095
12096    fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
12097        RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
12098    }
12099
12100    fn export_error(block: &str) -> (String, String) {
12101        match exports_manifest(block).expect_err("this exports block must not parse") {
12102            RuntimeManifestError::InvalidExports { field, message } => (field, message),
12103            other => panic!("expected InvalidExports, got {other:?}"),
12104        }
12105    }
12106
12107    fn peer_fetch_error(block: &str) -> (String, String) {
12108        match exports_manifest(block).expect_err("this capabilities block must not parse") {
12109            RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
12110            other => panic!("expected InvalidCapabilities, got {other:?}"),
12111        }
12112    }
12113
12114    // ── exports.peer_files ───────────────────────────────────────────────────
12115
12116    #[test]
12117    fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
12118        assert_eq!(parse_duration_secs("90").unwrap(), 90);
12119        assert_eq!(parse_duration_secs("30s").unwrap(), 30);
12120        assert_eq!(parse_duration_secs("15m").unwrap(), 900);
12121        assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
12122        assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
12123        assert_eq!(parse_duration_secs("  2h  ").unwrap(), 7200);
12124        assert_eq!(parse_duration_secs("0").unwrap(), 0);
12125    }
12126
12127    /// Every spelling that is not the one accepted form, refused rather than guessed at: a
12128    /// manifest meaning 5 seconds and one meaning 5 minutes must not both parse.
12129    #[test]
12130    fn parse_duration_secs_rejects_every_other_spelling() {
12131        for input in [
12132            "",
12133            "  ",
12134            "5 minutes",
12135            "30S",
12136            "1H",
12137            "5min",
12138            "1.5h",
12139            "-1",
12140            "m",
12141            "h",
12142            "1h30m",
12143            "0x10",
12144            "30 s",
12145        ] {
12146            let error = parse_duration_secs(input)
12147                .expect_err(&format!("'{input}' must not parse as a duration"));
12148            assert!(
12149                error.contains(DURATION_ACCEPTED_FORM),
12150                "'{input}' must be refused with the accepted form; got: {error}"
12151            );
12152        }
12153    }
12154
12155    #[test]
12156    fn parse_duration_secs_refuses_to_overflow() {
12157        assert!(parse_duration_secs("18446744073709551615h")
12158            .unwrap_err()
12159            .contains("overflows"));
12160    }
12161
12162    #[test]
12163    fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
12164        let manifest = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
12165            .expect("a minimal peer_files block parses");
12166        let peer = manifest.exports.unwrap().peer_files.unwrap();
12167        assert_eq!(peer.root, "out/");
12168        assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
12169        assert_eq!(peer.max_bytes, 10_485_760);
12170        // Undeclared, not defaulted: the two ephemerality cases answer an absent value
12171        // differently, and substituting here would erase the difference.
12172        assert_eq!(peer.max_ttl_secs, None);
12173        assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
12174        assert_eq!(peer.effective_max_ttl_secs(), 3600);
12175    }
12176
12177    #[test]
12178    fn peer_files_accepts_every_duration_spelling() {
12179        for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
12180            let manifest = exports_manifest(&format!(
12181                "exports:\n  peer_files:\n    root: out/\n    max_ttl: {declared}\n"
12182            ))
12183            .expect("a declared max_ttl parses");
12184            let peer = manifest.exports.unwrap().peer_files.unwrap();
12185            assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
12186            assert_eq!(peer.effective_max_ttl_secs(), expected);
12187        }
12188    }
12189
12190    /// The two export blocks are separate authorisers: declaring one says nothing about the
12191    /// other, and both may be declared side by side over different subtrees.
12192    #[test]
12193    fn the_two_export_blocks_are_independent() {
12194        let both = exports_manifest(
12195            "exports:\n  files:\n    root: out/\n    mode: read-only\n  \
12196             peer_files:\n    root: out/handoff/\n",
12197        )
12198        .expect("both blocks parse together")
12199        .exports
12200        .unwrap();
12201        assert_eq!(both.files.unwrap().root, "out/");
12202        assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
12203
12204        let only_files =
12205            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
12206                .unwrap()
12207                .exports
12208                .unwrap();
12209        assert!(only_files.peer_files.is_none());
12210
12211        let only_peer = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
12212            .unwrap()
12213            .exports
12214            .unwrap();
12215        assert!(only_peer.files.is_none());
12216
12217        let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
12218        assert!(neither.files.is_none() && neither.peer_files.is_none());
12219    }
12220
12221    #[test]
12222    fn peer_files_root_must_be_relative_and_inside_the_workdir() {
12223        for root in ["../out", "/etc", "out/../../etc"] {
12224            let (field, message) =
12225                export_error(&format!("exports:\n  peer_files:\n    root: {root}\n"));
12226            assert_eq!(field, "exports.peer_files.root", "root: {root}");
12227            assert!(
12228                message.contains(EXPORT_ROOT_ACCEPTED_FORM),
12229                "root: {root}; got: {message}"
12230            );
12231        }
12232    }
12233
12234    #[test]
12235    fn peer_files_root_is_required() {
12236        let (field, message) = export_error("exports:\n  peer_files: {}\n");
12237        assert_eq!(field, "exports.peer_files.root");
12238        assert!(message.contains("is required"));
12239        assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
12240    }
12241
12242    #[test]
12243    fn peer_files_max_ttl_states_its_accepted_form() {
12244        for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
12245            let (field, message) = export_error(&format!(
12246                "exports:\n  peer_files:\n    root: out/\n    max_ttl: \"{declared}\"\n"
12247            ));
12248            assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
12249            assert!(
12250                message.contains(DURATION_ACCEPTED_FORM),
12251                "max_ttl: {declared}; got: {message}"
12252            );
12253        }
12254    }
12255
12256    /// A zero ceiling reads like a declared surface and refuses every mint. Better discovered at
12257    /// the line that wrote it than at the first `share-file` call.
12258    #[test]
12259    fn peer_files_max_ttl_rejects_zero() {
12260        let (field, message) =
12261            export_error("exports:\n  peer_files:\n    root: out/\n    max_ttl: 0\n");
12262        assert_eq!(field, "exports.peer_files.max_ttl");
12263        assert!(message.contains("greater than zero"));
12264    }
12265
12266    #[test]
12267    fn peer_files_max_bytes_states_its_accepted_form() {
12268        let (field, message) =
12269            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 10MB\n");
12270        assert_eq!(field, "exports.peer_files.max_bytes");
12271        assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
12272    }
12273
12274    #[test]
12275    fn peer_files_max_bytes_rejects_zero() {
12276        let (field, message) =
12277            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 0\n");
12278        assert_eq!(field, "exports.peer_files.max_bytes");
12279        assert!(message.contains("greater than zero"));
12280    }
12281
12282    // ── exports.peer_tasks ───────────────────────────────────────────────────
12283
12284    /// Absent and `accept: false` are the same posture; only an explicit `true` consents.
12285    #[test]
12286    fn peer_tasks_consents_only_on_an_explicit_true() {
12287        let absent = exports_manifest("artifacts: []\n").expect("no exports block parses");
12288        assert!(absent.exports.is_none());
12289        assert!(!absent.accepts_peer_tasks());
12290
12291        let refused = exports_manifest("exports:\n  peer_tasks:\n    accept: false\n")
12292            .expect("an explicit refusal parses");
12293        assert_eq!(
12294            refused.exports.as_ref().unwrap().peer_tasks,
12295            Some(PeerTasksExport { accept: false })
12296        );
12297        assert!(!refused.accepts_peer_tasks());
12298        assert!(!refused.exports.as_ref().unwrap().accepts_peer_tasks());
12299
12300        let accepted = exports_manifest("exports:\n  peer_tasks:\n    accept: true\n")
12301            .expect("consent parses");
12302        assert_eq!(
12303            accepted.exports.as_ref().unwrap().peer_tasks,
12304            Some(PeerTasksExport { accept: true })
12305        );
12306        assert!(accepted.accepts_peer_tasks());
12307        assert!(accepted.exports.as_ref().unwrap().accepts_peer_tasks());
12308    }
12309
12310    /// An `exports:` block declaring only `peer_tasks` opens neither file plane.
12311    #[test]
12312    fn peer_tasks_alone_declares_no_file_plane() {
12313        let manifest = exports_manifest("exports:\n  peer_tasks:\n    accept: true\n")
12314            .expect("a peer_tasks-only exports block parses");
12315        let exports = manifest.exports.unwrap();
12316        assert_eq!(exports.files, None);
12317        assert_eq!(exports.peer_files, None);
12318    }
12319
12320    /// A file export says nothing about peer tasks.
12321    #[test]
12322    fn a_file_export_does_not_consent_to_peer_tasks() {
12323        let manifest =
12324            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
12325                .expect("a files export parses");
12326        assert_eq!(manifest.exports.as_ref().unwrap().peer_tasks, None);
12327        assert!(!manifest.accepts_peer_tasks());
12328    }
12329
12330    /// Consent is never inferred: a `peer_tasks:` block without `accept:` is refused naming the
12331    /// key, as `capabilities.plan` without `submit:` is.
12332    #[test]
12333    fn peer_tasks_without_accept_is_refused() {
12334        let (field, message) = export_error("exports:\n  peer_tasks: {}\n");
12335        assert_eq!(field, "exports.peer_tasks.accept");
12336        assert_eq!(
12337            message,
12338            "must be set explicitly to true or false — consent to peer tasks is never inferred"
12339        );
12340    }
12341
12342    /// A value of the wrong type is refused at parse, and the refusal names where it is.
12343    #[test]
12344    fn peer_tasks_of_the_wrong_type_is_refused_naming_the_field() {
12345        for (block, field) in [
12346            (
12347                "exports:\n  peer_tasks:\n    accept: \"yes\"\n",
12348                "exports.peer_tasks.accept",
12349            ),
12350            (
12351                "exports:\n  peer_tasks:\n    accept: 1\n",
12352                "exports.peer_tasks.accept",
12353            ),
12354            ("exports:\n  peer_tasks: true\n", "exports.peer_tasks"),
12355            ("exports:\n  peer_tasks: [peer-a]\n", "exports.peer_tasks"),
12356        ] {
12357            let message = exports_manifest(block)
12358                .expect_err(&format!("{block:?} must not parse"))
12359                .to_string();
12360            assert!(message.contains(field), "{block:?}: {message}");
12361        }
12362    }
12363
12364    /// An unknown key under the block is a warning reported by path, not a refusal — and naming
12365    /// a peer there grants nothing.
12366    #[test]
12367    fn an_unknown_peer_tasks_key_is_reported_by_path() {
12368        let manifest =
12369            exports_manifest("exports:\n  peer_tasks:\n    accept: false\n    allow: [peer-a]\n")
12370                .expect("an unknown key is a warning, not a parse error");
12371        assert!(
12372            manifest
12373                .unknown_keys
12374                .iter()
12375                .any(|key| key.block_path == "exports.peer_tasks" && key.key == "allow"),
12376            "{:?}",
12377            manifest.unknown_keys
12378        );
12379        assert!(!manifest.accepts_peer_tasks());
12380    }
12381
12382    // ── capabilities.peer_fetch ──────────────────────────────────────────────
12383
12384    #[test]
12385    fn peer_fetch_allow_carries_its_declared_destinations() {
12386        let manifest = exports_manifest(
12387            "capabilities:\n  peer_fetch:\n    allow:\n      - localhost:41234\n      \
12388             - reporting.internal\n      - https://gateway.example.com\n",
12389        )
12390        .expect("a peer_fetch block parses");
12391        assert_eq!(
12392            manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
12393            vec![
12394                "localhost:41234".to_string(),
12395                "reporting.internal".to_string(),
12396                "https://gateway.example.com".to_string(),
12397            ]
12398        );
12399    }
12400
12401    /// An empty list is a parse error rather than a silent deny: `allow: []` reads as a declared
12402    /// grant and would otherwise refuse every fetch with no line to point at.
12403    #[test]
12404    fn peer_fetch_allow_must_not_be_empty() {
12405        let (field, message) = peer_fetch_error("capabilities:\n  peer_fetch:\n    allow: []\n");
12406        assert_eq!(field, "capabilities.peer_fetch.allow");
12407        assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
12408    }
12409
12410    #[test]
12411    fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
12412        for entry in [
12413            "http://[not a host",
12414            "\"\"",
12415            "localhost:notaport",
12416            "http://example.com/path",
12417            "ftp://example.com",
12418        ] {
12419            let (field, message) = peer_fetch_error(&format!(
12420                "capabilities:\n  peer_fetch:\n    allow:\n      - {entry}\n"
12421            ));
12422            assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
12423            assert!(
12424                message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
12425                "entry: {entry}; got: {message}"
12426            );
12427        }
12428    }
12429
12430    /// `peer_fetch` is a separate authoriser and never an alias: declaring it leaves
12431    /// `network.allow` exactly as the manifest wrote it, and vice versa.
12432    #[test]
12433    fn peer_fetch_does_not_widen_network_allow() {
12434        let capabilities = exports_manifest(
12435            "capabilities:\n  network:\n    allow:\n      - api.example.com\n  \
12436             peer_fetch:\n    allow:\n      - localhost:41234\n",
12437        )
12438        .unwrap()
12439        .capabilities
12440        .unwrap();
12441        assert_eq!(
12442            capabilities.network.unwrap().allow,
12443            vec!["api.example.com".to_string()]
12444        );
12445        assert_eq!(
12446            capabilities.peer_fetch.unwrap().allow,
12447            vec!["localhost:41234".to_string()]
12448        );
12449    }
12450
12451    #[test]
12452    fn an_absent_peer_fetch_block_is_absent() {
12453        assert!(exports_manifest("capabilities: {}\n")
12454            .unwrap()
12455            .capabilities
12456            .unwrap()
12457            .peer_fetch
12458            .is_none());
12459    }
12460
12461    #[test]
12462    fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
12463        assert_eq!(parse_byte_size("0").unwrap(), 0);
12464        assert_eq!(parse_byte_size("4096").unwrap(), 4096);
12465        assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
12466        assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
12467        assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
12468        assert_eq!(parse_byte_size("  10Mi  ").unwrap(), 10 * 1024 * 1024);
12469    }
12470
12471    /// Decimal suffixes are refused rather than guessed at: a manifest meaning 10 000 000 and one
12472    /// meaning 10 485 760 must not both parse.
12473    #[test]
12474    fn parse_byte_size_rejects_every_other_spelling() {
12475        for input in [
12476            "", "  ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
12477            "1Ki1",
12478        ] {
12479            let error = parse_byte_size(input)
12480                .expect_err(&format!("'{input}' must not parse as a byte size"));
12481            assert!(
12482                error.contains(BYTE_SIZE_ACCEPTED_FORM),
12483                "'{input}' must be refused with the accepted form; got: {error}"
12484            );
12485        }
12486    }
12487
12488    #[test]
12489    fn parse_byte_size_refuses_to_overflow() {
12490        assert!(parse_byte_size("18446744073709551615Gi")
12491            .unwrap_err()
12492            .contains("overflows"));
12493    }
12494
12495    #[test]
12496    fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
12497        let manifest =
12498            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
12499                .expect("a minimal exports block parses");
12500        let files = manifest.exports.unwrap().files.unwrap();
12501        assert_eq!(files.root, "out/");
12502        assert_eq!(files.mode, ExportMode::ReadOnly);
12503        assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
12504        assert_eq!(files.max_bytes, 10_485_760);
12505    }
12506
12507    #[test]
12508    fn exports_files_accepts_both_byte_spellings() {
12509        for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
12510            let manifest = exports_manifest(&format!(
12511                "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: {declared}\n"
12512            ))
12513            .expect("a declared max_bytes parses");
12514            assert_eq!(
12515                manifest.exports.unwrap().files.unwrap().max_bytes,
12516                expected,
12517                "max_bytes: {declared}"
12518            );
12519        }
12520    }
12521
12522    /// An `exports:` block with no `files:` is not a resource plane. It parses, and the plane
12523    /// stays undeclared — which is the deny case, not a defaulted-open one.
12524    #[test]
12525    fn an_exports_block_without_files_declares_no_plane() {
12526        let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
12527        assert!(manifest.exports.unwrap().files.is_none());
12528    }
12529
12530    #[test]
12531    fn an_absent_exports_block_is_absent() {
12532        let manifest = exports_manifest("").expect("a manifest without exports parses");
12533        assert!(manifest.exports.is_none());
12534    }
12535
12536    #[test]
12537    fn exports_files_root_must_be_relative_and_inside_the_workdir() {
12538        for root in ["../out", "/etc", "out/../../etc", "''"] {
12539            let (field, message) = export_error(&format!(
12540                "exports:\n  files:\n    root: {root}\n    mode: read-only\n"
12541            ));
12542            assert_eq!(field, "exports.files.root", "root: {root}");
12543            assert!(
12544                message.contains("must be a relative path inside the workdir"),
12545                "root: {root}; got: {message}"
12546            );
12547        }
12548    }
12549
12550    #[test]
12551    fn exports_files_root_is_required() {
12552        let (field, message) = export_error("exports:\n  files:\n    mode: read-only\n");
12553        assert_eq!(field, "exports.files.root");
12554        assert_eq!(
12555            message,
12556            "is required and must be a relative path inside the workdir"
12557        );
12558    }
12559
12560    #[test]
12561    fn exports_files_mode_is_required_and_read_only() {
12562        let (field, message) = export_error("exports:\n  files:\n    root: out/\n");
12563        assert_eq!(field, "exports.files.mode");
12564        assert_eq!(message, "is required and must be 'read-only'");
12565
12566        let (field, message) =
12567            export_error("exports:\n  files:\n    root: out/\n    mode: read-write\n");
12568        assert_eq!(field, "exports.files.mode");
12569        assert_eq!(message, "'read-write' must be 'read-only'");
12570    }
12571
12572    #[test]
12573    fn exports_files_max_bytes_states_its_accepted_form() {
12574        let (field, message) = export_error(
12575            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 10MB\n",
12576        );
12577        assert_eq!(field, "exports.files.max_bytes");
12578        assert_eq!(
12579            message,
12580            "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
12581        );
12582    }
12583
12584    /// A zero ceiling would refuse every read while reading like a declared export, so it is
12585    /// refused where it is written rather than at the first request.
12586    #[test]
12587    fn exports_files_max_bytes_rejects_zero() {
12588        let (field, message) = export_error(
12589            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 0\n",
12590        );
12591        assert_eq!(field, "exports.files.max_bytes");
12592        assert!(message.contains("must be greater than zero"), "{message}");
12593    }
12594
12595    /// A leading `./` is a relative path like any other and is not an escape.
12596    #[test]
12597    fn exports_files_root_accepts_a_current_dir_prefix() {
12598        let manifest =
12599            exports_manifest("exports:\n  files:\n    root: ./out\n    mode: read-only\n")
12600                .expect("./out is a legal root");
12601        assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
12602    }
12603
12604    // ── Unrecognized manifest keys (W-SEC-019) ────────────────────────────────
12605
12606    fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
12607        RuntimeManifest::from_yaml_str(yaml)
12608            .expect("fixture must parse")
12609            .unknown_keys
12610            .into_iter()
12611            .map(|key| (key.key, key.block_path, key.nearest_known))
12612            .collect()
12613    }
12614
12615    /// The typo the whole warning exists for: a hyphen where the key takes an underscore, named
12616    /// with the block that held it and with the key it should have been.
12617    #[test]
12618    fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
12619        assert_eq!(
12620            unknown_keys_of(
12621                "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    read-only:\n      - tests\n"
12622            ),
12623            vec![(
12624                "read-only".to_string(),
12625                "capabilities.filesystem".to_string(),
12626                Some("read_only".to_string())
12627            )]
12628        );
12629    }
12630
12631    /// A manifest exercising every layer the walk descends through — capsule-wide capabilities,
12632    /// a per-artifact block, shell, network and an artifact entry — reports nothing. A warning
12633    /// that fires on correct input is noise, and noise is ignored.
12634    #[test]
12635    fn a_manifest_of_recognized_keys_reports_nothing() {
12636        assert!(unknown_keys_of(
12637            r#"
12638name: cap
12639version: 0.1.0
12640deploy:
12641  mur_version: "0.2.0"
12642artifacts:
12643  - name: local/notes-tool
12644    version: 0.1.0
12645    runtime: tool
12646    capabilities:
12647      filesystem:
12648        scope: notes
12649      network:
12650        allow:
12651          - example.com:443
12652capabilities:
12653  filesystem:
12654    read_only:
12655      - tests
12656      - bench/fixtures
12657    workdir_exec: false
12658  shell:
12659    allow:
12660      - git
12661      - python3
12662  network:
12663    allow:
12664      - example.com:443
12665    unix_sockets: false
12666"#
12667        )
12668        .is_empty());
12669    }
12670
12671    /// A per-artifact capability block reports under its artifact's index, so an operator is not
12672    /// sent to the capsule-wide block for a key they wrote on one entry.
12673    #[test]
12674    fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
12675        assert_eq!(
12676            unknown_keys_of(
12677                "name: cap\nversion: 0.1.0\nartifacts:\n  - name: local/t\n    version: 0.1.0\n    \
12678                 runtime: tool\n    capabilities:\n      shell:\n        allow:\n          - git\n        \
12679                 allwo:\n          - git\n"
12680            ),
12681            vec![(
12682                "allwo".to_string(),
12683                "artifacts[0].capabilities.shell".to_string(),
12684                Some("allow".to_string())
12685            )]
12686        );
12687    }
12688
12689    /// A top-level key with no near neighbour reports an empty path and no suggestion — the two
12690    /// facts the emitter needs to word it as a possibly-newer key rather than a misspelling.
12691    #[test]
12692    fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
12693        assert_eq!(
12694            unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
12695            vec![("quantum_teleport".to_string(), String::new(), None)]
12696        );
12697    }
12698
12699    /// The minimum publishable delegated sub-capsule — `runtime:` plus `execution:` — reports
12700    /// nothing. Every registrant of a delegating capsule must be published, so every one of them
12701    /// carries these keys; warning on them would make the diagnostic unreadable exactly where it
12702    /// is needed most.
12703    #[test]
12704    fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
12705        assert!(unknown_keys_of(
12706            "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
12707        )
12708        .is_empty());
12709    }
12710
12711    /// All four keys `mur build` and `mur publish` read beyond `name:` and `version:`, together.
12712    #[test]
12713    fn every_build_side_key_is_recognized() {
12714        assert!(unknown_keys_of(
12715            "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
12716             wasm\nrequires_files:\n  - tool.wasm\n  - assets/logo.png\n"
12717        )
12718        .is_empty());
12719    }
12720
12721    /// `W-SEC-019` still covers every key no command reads: one sitting beside the four
12722    /// build-side keys reports, and reports alone.
12723    #[test]
12724    fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
12725        assert_eq!(
12726            unknown_keys_of(
12727                "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
12728                 static\nrequires_files:\n  - capsule.wasm\nquantum_teleport: true\n"
12729            ),
12730            vec![("quantum_teleport".to_string(), String::new(), None)]
12731        );
12732    }
12733
12734    /// The four keys are recognized and then dropped: they add no refusal and no run-time meaning,
12735    /// so a manifest carrying them parses to what it parses to without them. A wrong-shaped value
12736    /// is build's business to reject, not the run path's.
12737    #[test]
12738    fn the_build_side_keys_change_nothing_the_run_path_reads() {
12739        let body = "artifacts:\n  - name: local/notes-tool\n    version: 0.1.0\n    runtime: tool\n\
12740                    \x20 - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: \
12741                    driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\n\
12742                    capabilities:\n  filesystem:\n    read_only:\n      - tests\n\
12743                    inference:\n  transport: http\n  model: test-model\n  driver:\n    \
12744                    artifact: murmur/driver-anthropic\n";
12745        let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
12746            .expect("fixture must parse");
12747        let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
12748            "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n  \
12749             any: shape\nrequires_files: not-a-list\n{body}"
12750        ))
12751        .expect("a build-side key of any shape never refuses a manifest");
12752
12753        assert!(with_build_keys.unknown_keys.is_empty());
12754        assert!(
12755            !plain.artifacts.is_empty()
12756                && plain.capabilities.is_some()
12757                && plain.inference.is_some(),
12758            "the fixture must populate the blocks the comparison below rests on, or it \
12759             compares absence against absence"
12760        );
12761        assert_eq!(with_build_keys.name, plain.name);
12762        assert_eq!(with_build_keys.version, plain.version);
12763        assert_eq!(with_build_keys.artifacts, plain.artifacts);
12764        assert_eq!(with_build_keys.capabilities, plain.capabilities);
12765        assert_eq!(with_build_keys.inference, plain.inference);
12766    }
12767
12768    /// The overflow map captures the key without consuming the block: the rest of it still parses
12769    /// and still applies. A capsule with an unrecognized key launches.
12770    #[test]
12771    fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
12772        let manifest = RuntimeManifest::from_yaml_str(
12773            "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    scope: notes\n    \
12774             read-only:\n      - tests\n",
12775        )
12776        .expect("an unrecognized key never refuses a manifest");
12777        assert_eq!(
12778            manifest
12779                .capabilities
12780                .as_ref()
12781                .and_then(|caps| caps.filesystem.as_ref())
12782                .and_then(|fs| fs.scope.as_deref()),
12783            Some("notes")
12784        );
12785        assert_eq!(manifest.unknown_keys.len(), 1);
12786    }
12787
12788    /// Every block on the way down is walked, not just the capability sub-blocks.
12789    #[test]
12790    fn nested_blocks_outside_capabilities_are_walked_too() {
12791        let keys = unknown_keys_of(
12792            "name: cap\nversion: 0.1.0\ncontext:\n  max_tokns: 100\nexports:\n  files:\n    \
12793             root: out\n    mode: read-only\n    max_byts: 10\n",
12794        );
12795        assert_eq!(
12796            keys,
12797            vec![
12798                (
12799                    "max_tokns".to_string(),
12800                    "context".to_string(),
12801                    Some("max_tokens".to_string())
12802                ),
12803                (
12804                    "max_byts".to_string(),
12805                    "exports.files".to_string(),
12806                    Some("max_bytes".to_string())
12807                ),
12808            ]
12809        );
12810    }
12811
12812    /// Source-derived, so a `Raw*` struct added later cannot skip the overflow map, and a field
12813    /// added to one cannot skip its `KNOWN_KEYS`: the suggester would then never propose it.
12814    ///
12815    /// Reads this file's own text rather than a hand-written list for the same reason the
12816    /// `CapabilityPolicy` coverage test in `capsule-runtime` does — a list maintained by hand
12817    /// records what someone remembered, not what the code says.
12818    #[test]
12819    fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
12820        let source = include_str!("runtime_manifest.rs");
12821        let lines: Vec<&str> = source.lines().collect();
12822
12823        let mut checked = 0usize;
12824        for (start, line) in lines.iter().enumerate() {
12825            let Some(name) = line
12826                .strip_prefix("struct Raw")
12827                .and_then(|rest| rest.strip_suffix(" {"))
12828            else {
12829                continue;
12830            };
12831            let name = format!("Raw{name}");
12832            let end = (start + 1..lines.len())
12833                .find(|index| lines[*index] == "}")
12834                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
12835            let body = &lines[start + 1..end];
12836
12837            assert!(
12838                body.contains(&"    #[serde(flatten)]")
12839                    && body.contains(&"    unknown: UnknownKeys,"),
12840                "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
12841                 so a key it does not recognize would be dropped instead of reported"
12842            );
12843
12844            let declared = serde_field_names(body);
12845            let known = known_keys_of(source, &name);
12846            assert_eq!(
12847                declared, known,
12848                "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
12849                 suggester would propose the wrong set for that block"
12850            );
12851            checked += 1;
12852        }
12853        assert!(
12854            checked >= 30,
12855            "expected every Raw* struct to be scanned, found only {checked}"
12856        );
12857    }
12858
12859    /// Source-derived for the same reason its sibling above is: a `Raw*`-typed field added to a
12860    /// block and left unwalked reports none of the keys inside that block, and reports nothing
12861    /// about having done so. The scan is keyed on the *parent's field*, not on the child's type,
12862    /// because one type can sit under several parents — `RawInferenceDriver` is both
12863    /// `inference.driver` and each alternate's `driver` — and a check keyed on the type would call
12864    /// one covered because the other is walked.
12865    #[test]
12866    fn every_raw_struct_descends_into_the_blocks_it_owns() {
12867        let source = include_str!("runtime_manifest.rs");
12868        let lines: Vec<&str> = source.lines().collect();
12869
12870        let mut structs = 0usize;
12871        let mut children = 0usize;
12872        for (start, line) in lines.iter().enumerate() {
12873            let Some(name) = line
12874                .strip_prefix("struct Raw")
12875                .and_then(|rest| rest.strip_suffix(" {"))
12876            else {
12877                continue;
12878            };
12879            let name = format!("Raw{name}");
12880            let end = (start + 1..lines.len())
12881                .find(|index| lines[*index] == "}")
12882                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
12883            let owned = block_typed_fields(&lines[start + 1..end]);
12884            structs += 1;
12885
12886            if owned.is_empty() {
12887                continue;
12888            }
12889            let block = raw_block_impl_of(source, &name);
12890            assert!(
12891                block.contains("fn walk_children"),
12892                "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
12893                 `walk_children`, so every key written inside {} would go unreported",
12894                if owned.len() == 1 { "" } else { "s" },
12895                owned.join(", "),
12896                if owned.len() == 1 { "it" } else { "them" }
12897            );
12898            for field in &owned {
12899                assert!(
12900                    mentions_field(&block, field),
12901                    "{name}'s `walk_children` never touches `self.{field}`, so every key written \
12902                     inside that block would go unreported — descend into it with \
12903                     `collect_block(.., &child_path(path, \"{field}\"), out)`"
12904                );
12905                children += 1;
12906            }
12907        }
12908        assert!(
12909            structs >= 30,
12910            "expected every Raw* struct to be scanned, found only {structs}"
12911        );
12912        assert!(
12913            children >= 31,
12914            "expected every nested block field to be scanned, found only {children}"
12915        );
12916    }
12917
12918    /// Whether `impl` reads `self.<field>` as that whole field, not as the head of a longer one:
12919    /// a plain substring test lets a struct owning both `foo` and `foo_bar` walk only `foo_bar`
12920    /// and still be called covered, which is the silent pass this scan exists to deny.
12921    fn mentions_field(impl_body: &str, field: &str) -> bool {
12922        let needle = format!("self.{field}");
12923        impl_body.match_indices(&needle).any(|(at, _)| {
12924            impl_body[at + needle.len()..]
12925                .chars()
12926                .next()
12927                .is_none_or(|next| !next.is_alphanumeric() && next != '_')
12928        })
12929    }
12930
12931    /// The fields of one struct body whose declared type names another `Raw*` type, in
12932    /// declaration order. Comment and attribute lines are skipped, so a doc comment that mentions
12933    /// a `Raw*` type by name is not read as a field.
12934    fn block_typed_fields(body: &[&str]) -> Vec<String> {
12935        let mut fields = Vec::new();
12936        for line in body {
12937            let trimmed = line.trim();
12938            if trimmed.starts_with("//") || trimmed.starts_with("#[") {
12939                continue;
12940            }
12941            let Some((field, declared)) = line
12942                .strip_prefix("    ")
12943                .and_then(|rest| rest.split_once(':'))
12944            else {
12945                continue;
12946            };
12947            if field.is_empty()
12948                || !field
12949                    .chars()
12950                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
12951            {
12952                continue;
12953            }
12954            if declared.contains("Raw") {
12955                fields.push(field.to_string());
12956            }
12957        }
12958        fields
12959    }
12960
12961    /// The text of `impl RawBlock for <name>`, cut at the next line that is exactly `}` at column
12962    /// 0 so one impl's body cannot be read as the next one's.
12963    fn raw_block_impl_of(source: &str, name: &str) -> String {
12964        let header = format!("impl RawBlock for {name} {{");
12965        let body = source
12966            .split_once(&header)
12967            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
12968            .1;
12969        body.split_once("\n}\n")
12970            .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
12971            .0
12972            .to_string()
12973    }
12974
12975    /// The walk's own result, taken straight off a deserialized `RawRuntimeManifest`.
12976    ///
12977    /// Deliberately not routed through [`RuntimeManifest::from_yaml_str`]: that validates, and a
12978    /// fixture that names every block at once would have to satisfy every semantic rule as well
12979    /// to reach the walk. What is under test here is which blocks are descended into, which the
12980    /// validator has no part in.
12981    fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
12982        let raw: RawRuntimeManifest = serde_yaml::from_str(yaml).expect("fixture must parse");
12983        collect_unknown_keys(&raw)
12984            .into_iter()
12985            .map(|key| (key.key, key.block_path))
12986            .collect()
12987    }
12988
12989    /// Every block the manifest type owns, each carrying one key no field of it claims, reported
12990    /// once and at its own dotted path. A block added after this one is held to the same rule by
12991    /// `every_raw_struct_descends_into_the_blocks_it_owns`; this is what "descended into" means.
12992    #[test]
12993    fn every_block_reports_its_own_unknown_key_at_its_own_path() {
12994        let keys = raw_unknown_keys(
12995            r#"
12996name: every-block
12997version: 0.1.0
12998probe_root: 1
12999artifacts:
13000  - name: local/t
13001    version: 0.1.0
13002    runtime: tool
13003    probe_artifact: 1
13004    capabilities:
13005      probe_artifact_capabilities: 1
13006      shell:
13007        probe_artifact_shell: 1
13008capabilities:
13009  probe_capabilities: 1
13010  network:
13011    probe_network: 1
13012  peer_fetch:
13013    probe_peer_fetch: 1
13014  filesystem:
13015    probe_filesystem: 1
13016  shell:
13017    probe_shell: 1
13018    interpreter_runtime:
13019      - binary: python3
13020        probe_interpreter_runtime: 1
13021        dirs:
13022          - path: /usr/lib/python3
13023            probe_interpreter_runtime_dir: 1
13024    staged_runtime:
13025      - binary: rg
13026        probe_staged_runtime: 1
13027  spawn:
13028    probe_spawn: 1
13029  env:
13030    probe_env: 1
13031  limits:
13032    probe_limits: 1
13033  resources:
13034    probe_resources: 1
13035  state:
13036    probe_state: 1
13037  task_io:
13038    probe_task_io: 1
13039  conversation:
13040    probe_conversation: 1
13041  install:
13042    probe_install: 1
13043inference:
13044  probe_inference: 1
13045  driver:
13046    probe_driver: 1
13047  compaction:
13048    probe_compaction: 1
13049context:
13050  probe_context: 1
13051observability:
13052  probe_observability: 1
13053  eval:
13054    probe_eval: 1
13055    scorers:
13056      - type: exact_match
13057        probe_scorer: 1
13058trace:
13059  probe_trace: 1
13060network:
13061  probe_network_config: 1
13062lifecycle:
13063  probe_lifecycle: 1
13064exports:
13065  probe_exports: 1
13066  files:
13067    probe_files: 1
13068  peer_files:
13069    probe_peer_files: 1
13070control:
13071  probe_control: 1
13072"#,
13073        );
13074
13075        let expected: Vec<(String, String)> = [
13076            ("probe_root", ""),
13077            ("probe_artifact", "artifacts[0]"),
13078            ("probe_artifact_capabilities", "artifacts[0].capabilities"),
13079            ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
13080            ("probe_capabilities", "capabilities"),
13081            ("probe_network", "capabilities.network"),
13082            ("probe_peer_fetch", "capabilities.peer_fetch"),
13083            ("probe_filesystem", "capabilities.filesystem"),
13084            ("probe_shell", "capabilities.shell"),
13085            (
13086                "probe_interpreter_runtime",
13087                "capabilities.shell.interpreter_runtime[0]",
13088            ),
13089            (
13090                "probe_interpreter_runtime_dir",
13091                "capabilities.shell.interpreter_runtime[0].dirs[0]",
13092            ),
13093            (
13094                "probe_staged_runtime",
13095                "capabilities.shell.staged_runtime[0]",
13096            ),
13097            ("probe_spawn", "capabilities.spawn"),
13098            ("probe_env", "capabilities.env"),
13099            ("probe_limits", "capabilities.limits"),
13100            ("probe_resources", "capabilities.resources"),
13101            ("probe_state", "capabilities.state"),
13102            ("probe_task_io", "capabilities.task_io"),
13103            ("probe_conversation", "capabilities.conversation"),
13104            ("probe_install", "capabilities.install"),
13105            ("probe_inference", "inference"),
13106            ("probe_driver", "inference.driver"),
13107            ("probe_compaction", "inference.compaction"),
13108            ("probe_context", "context"),
13109            ("probe_observability", "observability"),
13110            ("probe_eval", "observability.eval"),
13111            ("probe_scorer", "observability.eval.scorers[0]"),
13112            ("probe_trace", "trace"),
13113            ("probe_network_config", "network"),
13114            ("probe_lifecycle", "lifecycle"),
13115            ("probe_exports", "exports"),
13116            ("probe_files", "exports.files"),
13117            ("probe_peer_files", "exports.peer_files"),
13118            ("probe_control", "control"),
13119        ]
13120        .into_iter()
13121        .map(|(key, path)| (key.to_string(), path.to_string()))
13122        .collect();
13123
13124        assert_eq!(keys, expected);
13125    }
13126
13127    /// The names serde matches on for one struct body: field names in declaration order, with a
13128    /// `#[serde(rename = "...")]` field under its renamed spelling, and the overflow map itself
13129    /// excluded because it claims no name of its own.
13130    fn serde_field_names(body: &[&str]) -> Vec<String> {
13131        let mut names = Vec::new();
13132        let mut rename: Option<String> = None;
13133        for line in body {
13134            let trimmed = line.trim();
13135            if trimmed.starts_with("#[") {
13136                if let Some(rest) = trimmed.split("rename = \"").nth(1) {
13137                    if !trimmed.contains("rename_all") {
13138                        rename = rest.split('"').next().map(str::to_string);
13139                    }
13140                }
13141                continue;
13142            }
13143            if trimmed.starts_with("//") {
13144                continue;
13145            }
13146            let Some(field) = line
13147                .strip_prefix("    ")
13148                .and_then(|rest| rest.split(':').next())
13149            else {
13150                continue;
13151            };
13152            if field.is_empty()
13153                || !field
13154                    .chars()
13155                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
13156            {
13157                continue;
13158            }
13159            if field == "unknown" {
13160                rename = None;
13161                continue;
13162            }
13163            names.push(rename.take().unwrap_or_else(|| field.to_string()));
13164        }
13165        names
13166    }
13167
13168    /// The `KNOWN_KEYS` array `impl RawBlock for <name>` declares, read out of the source text so
13169    /// the assertion compares two independent readings of the same file rather than one value
13170    /// against itself.
13171    fn known_keys_of(source: &str, name: &str) -> Vec<String> {
13172        let header = format!("impl RawBlock for {name} {{");
13173        let body = source
13174            .split_once(&header)
13175            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
13176            .1;
13177        let array = body
13178            .split_once("KNOWN_KEYS: &'static [&'static str] = &[")
13179            .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
13180            .1
13181            .split_once("];")
13182            .expect("KNOWN_KEYS array is unterminated")
13183            .0;
13184        array
13185            .split('"')
13186            .skip(1)
13187            .step_by(2)
13188            .map(str::to_string)
13189            .collect()
13190    }
13191}
13192
13193#[cfg(test)]
13194mod control_tests {
13195    use super::*;
13196
13197    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";
13198
13199    /// An `http` agent capsule whose driver is keyed by `${DRIVER_KEY}` and whose `card-api` tool
13200    /// is keyed by `${CARD_TOKEN}`, with `control` spliced in at the top level.
13201    fn agent(transport: &str, control: &str) -> String {
13202        let driver = if transport == "http" {
13203            "  - 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"
13204        } else {
13205            "  - name: local/drv\n    version: 0.1.0\n    runtime: driver\n"
13206        };
13207        format!(
13208            "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}"
13209        )
13210    }
13211
13212    fn refusal(yaml: &str) -> (String, String) {
13213        match RuntimeManifest::from_yaml_str(yaml) {
13214            Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
13215            other => panic!("expected InvalidControl, got {other:?}"),
13216        }
13217    }
13218
13219    #[test]
13220    fn control_absent_empty_and_empty_lists_load_as_none() {
13221        for control in [
13222            "",
13223            "control: {}\n",
13224            "control:\n",
13225            "control:\n  settings: []\n  secrets: []\n",
13226        ] {
13227            let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
13228                .unwrap_or_else(|err| panic!("{control:?}: {err}"));
13229            assert_eq!(manifest.control, None, "{control:?}");
13230        }
13231    }
13232
13233    #[test]
13234    fn control_parses_settings_and_secrets_in_manifest_order() {
13235        let yaml = agent(
13236            "http",
13237            "control:\n  settings: [inference.max_tokens]\n  secrets: [CARD_TOKEN]\n",
13238        );
13239        for manifest in [
13240            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
13241            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
13242        ] {
13243            let control = manifest.control.unwrap();
13244            assert_eq!(
13245                control.settings,
13246                vec![ControllableSetting::InferenceMaxTokens]
13247            );
13248            assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
13249            assert!(control.declares_secret("CARD_TOKEN"));
13250            assert!(!control.declares_secret("DRIVER_KEY"));
13251        }
13252    }
13253
13254    #[test]
13255    fn control_setting_wire_names_round_trip() {
13256        for setting in ControllableSetting::ALL {
13257            assert_eq!(
13258                ControllableSetting::from_wire_name(setting.wire_name()),
13259                Some(*setting)
13260            );
13261        }
13262        assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
13263    }
13264
13265    /// Every refusal names `control.settings` or `control.secrets` as its field and quotes the
13266    /// entry it refused.
13267    #[test]
13268    fn control_manifest_refusals_name_the_field() {
13269        let cases: &[(&str, &str, &str, &str)] = &[
13270            (
13271                "http",
13272                "control:\n  settings: [inference.model]\n",
13273                "control.settings",
13274                "'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
13275            ),
13276            (
13277                "process",
13278                "control:\n  settings: [inference.max_tokens]\n",
13279                "control.settings",
13280                "'inference.max_tokens' is controllable only under inference.transport: http",
13281            ),
13282            (
13283                "http",
13284                "control:\n  settings: [inference.max_tokens, inference.max_tokens]\n",
13285                "control.settings",
13286                "'inference.max_tokens' is listed more than once",
13287            ),
13288            (
13289                "http",
13290                "control:\n  secrets: [CARD_TOKEN, CARD_TOKEN]\n",
13291                "control.secrets",
13292                "'CARD_TOKEN' is listed more than once",
13293            ),
13294            (
13295                "http",
13296                "control:\n  secrets: [card-token]\n",
13297                "control.secrets",
13298                "'card-token' is not a credential name",
13299            ),
13300            (
13301                "http",
13302                "control:\n  secrets: [OTHER_TOKEN]\n",
13303                "control.secrets",
13304                "'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
13305            ),
13306            (
13307                "http",
13308                "control:\n  secrets: [DRIVER_KEY]\n",
13309                "control.secrets",
13310                "'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.local/drv.gateway.api_key)",
13311            ),
13312            (
13313                "http",
13314                "control:\n  settings: inference.max_tokens\n",
13315                "control.settings",
13316                "must be a list of names",
13317            ),
13318            (
13319                "http",
13320                "control:\n  secrets: [1]\n",
13321                "control.secrets",
13322                "must be a list of names",
13323            ),
13324        ];
13325        for (transport, control, field, message) in cases {
13326            let (got_field, got_message) = refusal(&agent(transport, control));
13327            assert_eq!(&got_field, field, "{control:?}");
13328            assert!(
13329                got_message.starts_with(message),
13330                "{control:?}: {got_message}"
13331            );
13332            let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
13333                .unwrap_err()
13334                .to_string();
13335            assert!(
13336                rendered.contains(&format!("invalid control config for '{field}'")),
13337                "{rendered}"
13338            );
13339        }
13340    }
13341
13342    #[test]
13343    fn control_on_a_capsule_with_no_inference_is_refused() {
13344        let yaml = format!(
13345            "name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n  secrets: [CARD_TOKEN]\n"
13346        );
13347        let (field, message) = refusal(&yaml);
13348        assert_eq!(field, "control");
13349        assert!(message.contains("no inference: block"), "{message}");
13350    }
13351
13352    #[test]
13353    fn control_unknown_key_is_reported_once_at_its_block() {
13354        let manifest = RuntimeManifest::from_yaml_str(&agent(
13355            "http",
13356            "control:\n  settings: [inference.max_tokens]\n  agent_may_call: true\n",
13357        ))
13358        .unwrap();
13359        let keys: Vec<(&str, &str)> = manifest
13360            .unknown_keys
13361            .iter()
13362            .map(|key| (key.key.as_str(), key.block_path.as_str()))
13363            .collect();
13364        assert_eq!(keys, vec![("agent_may_call", "control")]);
13365        assert_eq!(
13366            manifest.control.unwrap().settings,
13367            vec![ControllableSetting::InferenceMaxTokens]
13368        );
13369    }
13370}
13371
13372#[cfg(test)]
13373mod driver_choice_tests {
13374    use super::*;
13375
13376    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";
13377    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";
13378    const ALT_NO_GATEWAY: &str = "  - name: local/alt\n    version: 0.1.0\n    runtime: driver\n";
13379    const TOOL: &str = "  - name: local/card-api\n    version: 0.1.0\n    runtime: tool\n";
13380    const GPT: &str =
13381        "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: local/alt\n";
13382    const HAIKU: &str =
13383        "    - name: haiku\n      model: haiku\n      driver:\n        artifact: local/drv\n";
13384    const SWITCHABLE: &str = "control:\n  settings: [inference.driver]\n";
13385
13386    /// An `http` capsule on `drv` with model `m`, `artifacts` after `drv`, `alternates` as the
13387    /// `inference.alternates` list body (empty for no key), and `control` at the top level.
13388    fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
13389        let alternates = if alternates.is_empty() {
13390            String::new()
13391        } else {
13392            format!("  alternates:\n{alternates}")
13393        };
13394        format!(
13395            "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}"
13396        )
13397    }
13398
13399    fn refusal(yaml: &str) -> (String, String) {
13400        match RuntimeManifest::from_yaml_str(yaml) {
13401            Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
13402            | Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
13403            other => panic!("expected an inference or control refusal, got {other:?}"),
13404        }
13405    }
13406
13407    #[test]
13408    fn driver_choice_alternates_parse_in_manifest_order() {
13409        let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
13410        for manifest in [
13411            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
13412            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
13413        ] {
13414            let inference = manifest.inference.unwrap();
13415            assert_eq!(
13416                inference.alternates,
13417                vec![
13418                    InferenceAlternate {
13419                        name: "gpt".to_string(),
13420                        model: "gpt-5".to_string(),
13421                        driver: "local/alt".to_string(),
13422                    },
13423                    InferenceAlternate {
13424                        name: "haiku".to_string(),
13425                        model: "haiku".to_string(),
13426                        driver: "local/drv".to_string(),
13427                    },
13428                ]
13429            );
13430            let control = manifest.control.unwrap();
13431            assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
13432            assert!(control.agent_settings.is_empty());
13433        }
13434    }
13435
13436    #[test]
13437    fn driver_choice_absent_alternates_are_empty() {
13438        let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
13439        assert!(manifest.inference.unwrap().alternates.is_empty());
13440    }
13441
13442    #[test]
13443    fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
13444        let manifest = RuntimeManifest::from_yaml_str(&capsule(
13445            ALT,
13446            GPT,
13447            "control:\n  agent_settings: [inference.driver]\n",
13448        ))
13449        .unwrap();
13450        let control = manifest.control.unwrap();
13451        assert!(control.settings.is_empty());
13452        assert_eq!(
13453            control.agent_settings,
13454            vec![ControllableSetting::InferenceDriver]
13455        );
13456        assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
13457        assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
13458        assert!(control.secrets.is_empty());
13459    }
13460
13461    #[test]
13462    fn driver_choice_wire_name_round_trips() {
13463        assert_eq!(
13464            ControllableSetting::from_wire_name("inference.driver"),
13465            Some(ControllableSetting::InferenceDriver)
13466        );
13467        assert_eq!(
13468            ControllableSetting::InferenceDriver.wire_name(),
13469            "inference.driver"
13470        );
13471        assert_eq!(
13472            ControllableSetting::AGENT_SETTABLE,
13473            &[ControllableSetting::InferenceDriver]
13474        );
13475    }
13476
13477    /// An alternate's driver may carry `gateway:`, which a driver entry nothing names may not.
13478    #[test]
13479    fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
13480        RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
13481            .expect("an alternate's driver carries its own gateway");
13482        match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
13483            Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
13484                assert!(message.contains("inference.alternates"), "{message}")
13485            }
13486            other => panic!("expected InvalidArtifact, got {other:?}"),
13487        }
13488    }
13489
13490    /// Two models of one provider: the alternate shares the primary's driver and gateway.
13491    #[test]
13492    fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
13493        let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
13494        assert_eq!(
13495            manifest.inference.unwrap().alternates[0].driver,
13496            "local/drv"
13497        );
13498    }
13499
13500    #[test]
13501    fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
13502        let manifest = RuntimeManifest::from_yaml_str(&capsule(
13503            ALT,
13504            GPT,
13505            "control:\n  settings: [inference.driver]\n  secrets: [ALT_KEY]\n",
13506        ))
13507        .unwrap();
13508        assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
13509        let (field, message) = refusal(&capsule(
13510            ALT,
13511            GPT,
13512            "control:\n  settings: [inference.driver]\n  secrets: [DRIVER_KEY]\n",
13513        ));
13514        assert_eq!(field, "control.secrets");
13515        assert!(
13516            message.contains("keys the configured inference driver's gateway"),
13517            "{message}"
13518        );
13519    }
13520
13521    #[test]
13522    fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
13523        let alternate = "    - name: gpt\n      model: gpt-5\n      temperature: 1\n      driver:\n        artifact: local/alt\n";
13524        let manifest =
13525            RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
13526        let keys: Vec<(&str, &str)> = manifest
13527            .unknown_keys
13528            .iter()
13529            .map(|key| (key.key.as_str(), key.block_path.as_str()))
13530            .collect();
13531        assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
13532    }
13533
13534    /// Every refusal the alternates and the driver setting can draw, each naming its field and
13535    /// rendering as the manifest error `E-MAN-003` maps from.
13536    #[test]
13537    fn driver_choice_manifest_refusals_name_the_field() {
13538        let alternate = |name: &str, model: &str, driver: &str| {
13539            format!("    - name: {name}\n      model: {model}\n      driver:\n        artifact: {driver}\n")
13540        };
13541        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";
13542        let cases: Vec<(String, &str, &str)> = vec![
13543            (
13544                format!("{process}  alternates:\n{GPT}"),
13545                "inference.alternates",
13546                "is not valid with transport: process",
13547            ),
13548            (
13549                format!("{process}control:\n  settings: [inference.driver]\n"),
13550                "control.settings",
13551                "'inference.driver' is controllable only under inference.transport: http",
13552            ),
13553            (
13554                capsule(ALT, &alternate("primary", "gpt-5", "local/alt"), SWITCHABLE),
13555                "inference.alternates[0].name",
13556                "'primary' is reserved",
13557            ),
13558            (
13559                capsule(
13560                    ALT,
13561                    &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt", "gpt-4", "local/alt")),
13562                    SWITCHABLE,
13563                ),
13564                "inference.alternates[1].name",
13565                "'gpt' names more than one alternate",
13566            ),
13567            (
13568                capsule(ALT, &alternate("GPT", "gpt-5", "local/alt"), SWITCHABLE),
13569                "inference.alternates[0].name",
13570                "'GPT' is not a driver choice name",
13571            ),
13572            (
13573                capsule(ALT, &alternate("5gpt", "gpt-5", "local/alt"), SWITCHABLE),
13574                "inference.alternates[0].name",
13575                "'5gpt' is not a driver choice name",
13576            ),
13577            (
13578                capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "local/alt"), SWITCHABLE),
13579                "inference.alternates[0].name",
13580                "is not a driver choice name",
13581            ),
13582            (
13583                capsule(ALT, "    - name: gpt\n      model: \"\"\n      driver:\n        artifact: local/alt\n", SWITCHABLE),
13584                "inference.alternates[0].model",
13585                "missing required field",
13586            ),
13587            (
13588                capsule(ALT, "    - name: gpt\n      model: gpt-5\n", SWITCHABLE),
13589                "inference.alternates[0].driver.artifact",
13590                "missing required field",
13591            ),
13592            (
13593                capsule("", &alternate("gpt", "gpt-5", "local/nowhere"), SWITCHABLE),
13594                "inference.alternates[0].driver.artifact",
13595                "artifact 'local/nowhere' is not declared in artifacts:",
13596            ),
13597            (
13598                capsule(TOOL, &alternate("gpt", "gpt-5", "local/card-api"), SWITCHABLE),
13599                "inference.alternates[0].driver.artifact",
13600                "artifact 'local/card-api' is declared with runtime: tool",
13601            ),
13602            (
13603                capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "local/alt"), SWITCHABLE),
13604                "inference.alternates[0].driver.artifact",
13605                "artifact 'local/alt' declares no gateway.endpoint",
13606            ),
13607            (
13608                capsule(
13609                    ALT,
13610                    "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: local/alt\n        config:\n          thinking: enabled\n",
13611                    SWITCHABLE,
13612                ),
13613                "inference.alternates[0].driver.config",
13614                "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",
13615            ),
13616            (
13617                capsule("", &alternate("same", "m", "local/drv"), SWITCHABLE),
13618                "inference.alternates[0]",
13619                "'same' is driver 'local/drv' with model 'm', which is the primary choice",
13620            ),
13621            (
13622                capsule(
13623                    ALT,
13624                    &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt-too", "gpt-5", "local/alt")),
13625                    SWITCHABLE,
13626                ),
13627                "inference.alternates[1]",
13628                "'gpt-too' is driver 'local/alt' with model 'gpt-5', which alternate 'gpt' already is",
13629            ),
13630            (
13631                capsule(ALT, GPT, ""),
13632                "inference.alternates",
13633                "declares alternates that nothing could ever select",
13634            ),
13635            (
13636                capsule(ALT, GPT, "control:\n  settings: [inference.max_tokens]\n"),
13637                "inference.alternates",
13638                "declares alternates that nothing could ever select",
13639            ),
13640            (
13641                capsule("", "", SWITCHABLE),
13642                "control.settings",
13643                "'inference.driver' selects among inference.alternates, and this capsule declares none",
13644            ),
13645            (
13646                capsule("", "", "control:\n  agent_settings: [inference.driver]\n"),
13647                "control.agent_settings",
13648                "'inference.driver' selects among inference.alternates, and this capsule declares none",
13649            ),
13650            (
13651                capsule(ALT, GPT, "control:\n  agent_settings: [inference.max_tokens]\n"),
13652                "control.agent_settings",
13653                "'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
13654            ),
13655            (
13656                capsule(
13657                    ALT,
13658                    GPT,
13659                    "control:\n  agent_settings: [inference.driver, inference.driver]\n",
13660                ),
13661                "control.agent_settings",
13662                "'inference.driver' is listed more than once",
13663            ),
13664            (
13665                capsule(ALT, GPT, "control:\n  agent_settings: inference.driver\n"),
13666                "control.agent_settings",
13667                "must be a list of names",
13668            ),
13669        ];
13670        for (yaml, field, message) in &cases {
13671            let (got_field, got_message) = refusal(yaml);
13672            assert_eq!(&got_field, field, "{yaml}");
13673            assert!(got_message.contains(message), "{yaml}\n{got_message}");
13674            let rendered = RuntimeManifest::from_yaml_str(yaml)
13675                .unwrap_err()
13676                .to_string();
13677            assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
13678        }
13679    }
13680}
13681
13682#[cfg(test)]
13683mod network_authentication_tests {
13684    use super::*;
13685
13686    fn network_authentication_of(
13687        block: &str,
13688    ) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
13689        RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
13690            .map(|manifest| manifest.network.and_then(|network| network.authentication))
13691    }
13692
13693    fn network_authentication_refusal(block: &str) -> (String, String) {
13694        match network_authentication_of(block) {
13695            Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
13696            other => panic!("expected InvalidNetworkConfig, got {other:?}"),
13697        }
13698    }
13699
13700    #[test]
13701    fn network_authentication_with_credentials_parses_sorted_by_name() {
13702        let auth = network_authentication_of(
13703            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
13704             watcher:\n        scopes: [GetTask, murmur.WatchSession]\n      \
13705             reader:\n        scopes: [resources/files]\n",
13706        )
13707        .unwrap()
13708        .expect("declared");
13709        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
13710        assert_eq!(
13711            auth.credentials,
13712            vec![
13713                DoorCredential {
13714                    name: "reader".to_string(),
13715                    scopes: vec!["resources/files".to_string()],
13716                },
13717                DoorCredential {
13718                    name: "watcher".to_string(),
13719                    // Declared order, not sorted.
13720                    scopes: vec!["GetTask".to_string(), "murmur.WatchSession".to_string()],
13721                },
13722            ]
13723        );
13724    }
13725
13726    #[test]
13727    fn network_authentication_scheme_alone_parses() {
13728        let auth = network_authentication_of("network:\n  authentication:\n    scheme: bearer\n")
13729            .unwrap()
13730            .expect("declared");
13731        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
13732        assert!(auth.credentials.is_empty());
13733    }
13734
13735    #[test]
13736    fn network_authentication_absent_is_a_public_door() {
13737        assert_eq!(network_authentication_of("").unwrap(), None);
13738        assert_eq!(
13739            network_authentication_of("network:\n  internal_port: 8080\n").unwrap(),
13740            None
13741        );
13742        let manifest = RuntimeManifest::from_yaml_str(
13743            "name: cap\nversion: 0.1.0\nnetwork:\n  internal_port: 8080\n",
13744        )
13745        .unwrap();
13746        assert_eq!(
13747            manifest.network,
13748            Some(NetworkConfig {
13749                internal_port: Some(8080),
13750                authentication: None,
13751            })
13752        );
13753    }
13754
13755    #[test]
13756    fn network_authentication_without_a_scheme_is_refused() {
13757        let (field, message) = network_authentication_refusal(
13758            "network:\n  authentication:\n    credentials:\n      w:\n        scopes: [GetTask]\n",
13759        );
13760        assert_eq!(field, "network.authentication.scheme");
13761        assert!(message.contains("required"), "{message}");
13762    }
13763
13764    #[test]
13765    fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
13766        for scheme in ["mtls", "Bearer", "basic"] {
13767            let (field, message) = network_authentication_refusal(&format!(
13768                "network:\n  authentication:\n    scheme: {scheme}\n"
13769            ));
13770            assert_eq!(field, "network.authentication.scheme");
13771            assert!(message.contains("bearer"), "{message}");
13772            assert!(message.contains(scheme), "{message}");
13773        }
13774    }
13775
13776    #[test]
13777    fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
13778        let long = format!("a{}", "b".repeat(32));
13779        for name in [
13780            "operator",
13781            "Watcher",
13782            "1st",
13783            "_x",
13784            "-x",
13785            "wat.cher",
13786            long.as_str(),
13787        ] {
13788            let (field, message) = network_authentication_refusal(&format!(
13789                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
13790                 '{name}':\n        scopes: [GetTask]\n"
13791            ));
13792            assert_eq!(field, "network.authentication.credentials", "{name}");
13793            assert!(message.contains(name), "{message}");
13794        }
13795        let longest = format!("a{}", "b".repeat(31));
13796        for name in ["w", "a-b_c9", longest.as_str()] {
13797            network_authentication_of(&format!(
13798                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
13799                 {name}:\n        scopes: [GetTask]\n"
13800            ))
13801            .unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
13802        }
13803    }
13804
13805    #[test]
13806    fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
13807        let credential = |body: &str| {
13808            format!(
13809                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
13810                 watcher:\n{body}"
13811            )
13812        };
13813        let field = "network.authentication.credentials.watcher.scopes";
13814
13815        let (got, message) = network_authentication_refusal(&credential("        {}\n"));
13816        assert_eq!(got, field);
13817        assert!(message.contains("required"), "{message}");
13818
13819        let (got, message) = network_authentication_refusal(&credential("        scopes: []\n"));
13820        assert_eq!(got, field);
13821        assert!(message.contains("no scope"), "{message}");
13822
13823        let (got, message) =
13824            network_authentication_refusal(&credential("        scopes: [tasks/list]\n"));
13825        assert_eq!(got, field);
13826        assert!(message.contains("tasks/list"), "{message}");
13827        assert!(message.contains("resources/files"), "{message}");
13828
13829        let (got, message) =
13830            network_authentication_refusal(&credential("        scopes: [GetExtendedAgentCard]\n"));
13831        assert_eq!(got, field);
13832        assert!(message.contains("GetExtendedAgentCard"), "{message}");
13833
13834        let (got, message) = network_authentication_refusal(&credential(
13835            "        scopes: [GetTask, murmur.WatchSession, GetTask]\n",
13836        ));
13837        assert_eq!(got, field);
13838        assert!(message.contains("more than once"), "{message}");
13839    }
13840
13841    #[test]
13842    fn network_authentication_refuses_a_retired_scope_naming_its_replacement() {
13843        let field = "network.authentication.credentials.watcher.scopes";
13844        for (retired, replacement) in [
13845            ("message/send", "SendMessage"),
13846            ("message/stream", "SendStreamingMessage"),
13847            ("tasks/get", "GetTask"),
13848            ("tasks/cancel", "CancelTask"),
13849            ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
13850            ("stream/watch", "murmur.WatchSession"),
13851            ("session/stop", "murmur.StopSession"),
13852        ] {
13853            let error = network_authentication_of(&format!(
13854                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
13855                 watcher:\n        scopes: [murmur.WatchSession, {retired}]\n"
13856            ))
13857            .unwrap_err();
13858            let rendered = error.to_string();
13859            match error {
13860                RuntimeManifestError::RetiredDoorScope {
13861                    field: got_field,
13862                    scope,
13863                    replacement: got_replacement,
13864                } => {
13865                    assert_eq!(got_field, field);
13866                    assert_eq!(scope, retired);
13867                    assert_eq!(got_replacement, replacement);
13868                }
13869                other => panic!("expected RetiredDoorScope for {retired}, got {other:?}"),
13870            }
13871            assert!(
13872                rendered.contains(&format!(
13873                    "'{retired}' is a retired name of a door method, and the door answers only \
13874                     the names its card lists"
13875                )),
13876                "{rendered}"
13877            );
13878            assert!(rendered.contains(field), "{rendered}");
13879        }
13880    }
13881
13882    #[test]
13883    fn network_authentication_accepts_the_v1_scopes() {
13884        let auth = network_authentication_of(
13885            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
13886             watcher:\n        scopes: [GetTask, murmur.WatchSession, murmur.StopSession]\n",
13887        )
13888        .unwrap()
13889        .unwrap();
13890        assert_eq!(
13891            auth.credentials[0].scopes,
13892            ["GetTask", "murmur.WatchSession", "murmur.StopSession"]
13893        );
13894    }
13895
13896    /// The two task methods beside `GetTask` are scopes a credential may list, in the method
13897    /// table's order.
13898    #[test]
13899    fn door_scope_list_tasks_and_subscribe_to_task_load_on_a_credential() {
13900        let auth = network_authentication_of(
13901            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
13902             alice:\n        scopes: [SendMessage, ListTasks, SubscribeToTask]\n",
13903        )
13904        .unwrap()
13905        .unwrap();
13906        assert_eq!(
13907            auth.credentials[0].scopes,
13908            ["SendMessage", "ListTasks", "SubscribeToTask"]
13909        );
13910        assert_eq!(
13911            DOOR_SCOPES,
13912            [
13913                "SendMessage",
13914                "SendStreamingMessage",
13915                "murmur.WatchSession",
13916                "GetTask",
13917                "ListTasks",
13918                "CancelTask",
13919                "SubscribeToTask",
13920                "murmur.StopSession",
13921                "resources/files",
13922            ]
13923        );
13924    }
13925
13926    #[test]
13927    fn network_authentication_accepts_every_door_scope() {
13928        let auth = network_authentication_of(&format!(
13929            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
13930             everything:\n        scopes: [{}]\n",
13931            DOOR_SCOPES.join(", ")
13932        ))
13933        .unwrap()
13934        .unwrap();
13935        assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
13936        assert_eq!(OPERATOR_CREDENTIAL, "operator");
13937    }
13938
13939    #[test]
13940    fn network_authentication_is_refused_beside_spawn_allow() {
13941        let (field, message) = network_authentication_refusal(
13942            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
13943             allow: [local/worker]\n",
13944        );
13945        assert_eq!(field, "network.authentication");
13946        assert!(message.contains("capabilities.spawn.allow"), "{message}");
13947        assert!(message.contains("token"), "{message}");
13948        let error = RuntimeManifest::from_yaml_str(
13949            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n\
13950             capabilities:\n  spawn:\n    allow: [local/worker]\n",
13951        )
13952        .unwrap_err()
13953        .to_string();
13954        assert!(error.contains("network.authentication"), "{error}");
13955        assert!(error.contains("capabilities.spawn.allow"), "{error}");
13956
13957        // An empty allow-list delegates nothing, so it does not conflict.
13958        network_authentication_of(
13959            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
13960             allow: []\n",
13961        )
13962        .unwrap();
13963    }
13964
13965    #[test]
13966    fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
13967        let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
13968            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n    \
13969             schemes: x\n    credentials:\n      watcher:\n        scopes: [GetTask]\n        \
13970             scope: [GetTask]\n",
13971        )
13972        .unwrap()
13973        .unknown_keys
13974        .into_iter()
13975        .map(|key| (key.key, key.block_path, key.nearest_known))
13976        .collect();
13977        assert_eq!(
13978            keys,
13979            vec![
13980                (
13981                    "schemes".to_string(),
13982                    "network.authentication".to_string(),
13983                    Some("scheme".to_string())
13984                ),
13985                (
13986                    "scope".to_string(),
13987                    "network.authentication.credentials.watcher".to_string(),
13988                    Some("scopes".to_string())
13989                ),
13990            ]
13991        );
13992    }
13993}
13994
13995#[cfg(test)]
13996mod registry_block_tests {
13997    use super::*;
13998
13999    const REGISTRY_BLOCK: &str = "registry:\n  default: local\n";
14000
14001    #[test]
14002    fn registry_block_is_refused_by_both_entry_points() {
14003        let input = format!("name: cap\nversion: 0.0.1\nartifacts: []\n{REGISTRY_BLOCK}");
14004        for result in [
14005            RuntimeManifest::from_yaml_str(&input),
14006            RuntimeManifest::from_yaml_str_without_secrets(&input),
14007        ] {
14008            let err = result.unwrap_err();
14009            assert!(
14010                matches!(err, RuntimeManifestError::RegistryBlock),
14011                "{err:?}"
14012            );
14013            assert_eq!(err.to_string(), REGISTRY_BLOCK_MESSAGE);
14014        }
14015    }
14016
14017    #[test]
14018    fn registry_block_is_refused_before_any_other_field() {
14019        // `name` is missing and `lifecycle.queue_depth: 0` under `queue` is refused on its own;
14020        // the registry refusal still wins.
14021        let input = format!(
14022            "version: 0.0.1\nlifecycle:\n  task_acceptance: queue\n  queue_depth: 0\n\
14023             {REGISTRY_BLOCK}"
14024        );
14025        let err = RuntimeManifest::from_yaml_str(&input).unwrap_err();
14026        assert!(
14027            matches!(err, RuntimeManifestError::RegistryBlock),
14028            "{err:?}"
14029        );
14030    }
14031
14032    #[test]
14033    fn registry_block_refusal_applies_whatever_the_block_holds() {
14034        for block in ["registry: local\n", "registry: {}\n", "registry: []\n"] {
14035            let input = format!("name: cap\nversion: 0.0.1\n{block}");
14036            assert!(
14037                matches!(
14038                    RuntimeManifest::from_yaml_str(&input),
14039                    Err(RuntimeManifestError::RegistryBlock)
14040                ),
14041                "{block}"
14042            );
14043        }
14044    }
14045
14046    #[test]
14047    fn a_manifest_without_a_registry_block_parses_unchanged() {
14048        let manifest =
14049            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
14050        assert_eq!(manifest.name, "cap");
14051        assert_eq!(manifest.version, "0.0.1");
14052    }
14053}
14054
14055#[cfg(test)]
14056mod scoped_name_tests {
14057    use super::*;
14058
14059    fn name_refusal(yaml: &str) -> (String, String, Option<String>) {
14060        match RuntimeManifest::from_yaml_str(yaml) {
14061            Err(RuntimeManifestError::InvalidArtifactName {
14062                field,
14063                message,
14064                hint,
14065            }) => (field, message, hint),
14066            other => panic!("expected InvalidArtifactName, got {other:?}"),
14067        }
14068    }
14069
14070    #[test]
14071    fn scoped_name_a_bare_artifact_entry_is_refused_with_the_scoped_forms() {
14072        let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: tool-editor\n    \
14073                    version: 0.1.0\n    runtime: tool\n";
14074        let (field, message, hint) = name_refusal(yaml);
14075        assert_eq!(field, "artifacts[0].name");
14076        assert_eq!(
14077            message,
14078            "'tool-editor' is not a full artifact name: an artifact is named <scope>/<name>"
14079        );
14080        assert_eq!(
14081            hint.as_deref(),
14082            Some(
14083                "name it with its scope: 'murmur/tool-editor' for a Murmur default artifact, \
14084                 '<owner>/tool-editor' for one published from github.com/<owner>, or \
14085                 'local/tool-editor' for one installed from a .mur.zip file"
14086            )
14087        );
14088        let rendered = RuntimeManifest::from_yaml_str(yaml)
14089            .unwrap_err()
14090            .to_string();
14091        assert!(
14092            rendered.starts_with("murmur.yaml: invalid artifact name in 'artifacts[0].name': "),
14093            "{rendered}"
14094        );
14095    }
14096
14097    #[test]
14098    fn scoped_name_a_source_entry_keeps_its_bare_name() {
14099        let bare = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: style\n    \
14100                    source: ./skills/style\n    runtime: skill\n";
14101        RuntimeManifest::from_yaml_str(bare).expect("a source: entry is named by its bare name");
14102
14103        let (field, message, hint) = name_refusal(&bare.replace("name: style", "name: ana/style"));
14104        assert_eq!(field, "artifacts[0].name");
14105        assert!(
14106            message
14107                .starts_with("'ana/style' declares 'source:', so it is named by its bare name; "),
14108            "{message}"
14109        );
14110        assert_eq!(hint.as_deref(), Some("drop the scope: name it 'style'"));
14111    }
14112
14113    #[test]
14114    fn scoped_name_spawn_allow_refuses_a_bare_entry_and_a_shared_bare_part() {
14115        let with_allow = |allow: &str| {
14116            format!("name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    allow: [{allow}]\n")
14117        };
14118        let manifest = RuntimeManifest::from_yaml_str(&with_allow("local/worker, ana/reviewer"))
14119            .expect("full names in two scopes with distinct bare parts parse");
14120        assert_eq!(
14121            manifest.capabilities.unwrap().spawn.unwrap().allow,
14122            vec!["local/worker".to_string(), "ana/reviewer".to_string()]
14123        );
14124
14125        let (field, message, hint) = name_refusal(&with_allow("worker"));
14126        assert_eq!(field, "capabilities.spawn.allow[0]");
14127        assert!(
14128            message.contains("'worker' is not a full artifact name"),
14129            "{message}"
14130        );
14131        assert!(hint.unwrap().contains("local/worker"));
14132
14133        let (field, message, hint) = name_refusal(&with_allow("local/worker, ana/worker"));
14134        assert_eq!(field, "capabilities.spawn.allow[1]");
14135        assert_eq!(
14136            message,
14137            "'local/worker' and 'ana/worker' share the name 'worker', which is how delegate-task \
14138             offers a capsule to the model"
14139        );
14140        assert_eq!(
14141            hint.as_deref(),
14142            Some("keep one of 'local/worker' and 'ana/worker' in capabilities.spawn.allow")
14143        );
14144    }
14145
14146    #[test]
14147    fn scoped_name_a_reference_by_bare_part_names_the_declared_entry() {
14148        let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    \
14149                    version: 0.1.0\n    runtime: driver\n    gateway:\n      \
14150                    endpoint: https://api.anthropic.com\n      api_key: k\ninference:\n  \
14151                    transport: http\n  model: m\n  driver:\n    artifact: driver-anthropic\n";
14152        let message = RuntimeManifest::from_yaml_str(yaml)
14153            .unwrap_err()
14154            .to_string();
14155        assert!(
14156            message.contains(
14157                "artifact 'driver-anthropic' is not declared in artifacts: (the declared \
14158                 'murmur/driver-anthropic' has that name; refer to it as \
14159                 'murmur/driver-anthropic')"
14160            ),
14161            "{message}"
14162        );
14163    }
14164}