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    manifest_path::MANIFEST_FILENAME,
9    trace_capture::{resolve_trace_capture, TraceCapture},
10    unknown_manifest_keys::{nearest_known_key, UnknownManifestKey},
11};
12
13// ── Lifecycle types ───────────────────────────────────────────────────────────
14
15#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum TaskAcceptance {
18    None,
19    #[default]
20    Single,
21    Queue,
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum AfterTask {
27    #[default]
28    Exit,
29    Sleep,
30}
31
32#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum ConversationMode {
35    #[default]
36    Stateless,
37    Threaded,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct LifecycleConfig {
42    pub task_acceptance: TaskAcceptance,
43    pub after_task: AfterTask,
44    pub queue_depth: usize,
45    /// If set, request-input calls that receive no follow-up within this many seconds
46    /// transition the task to "failed" with reason "input-timeout". Absent = wait indefinitely.
47    pub input_timeout_secs: Option<u64>,
48    /// Whether tasks sharing a contextId accumulate conversation history across turns.
49    /// Stateless (default): each task is fully independent; Threaded: history is loaded
50    /// and persisted per contextId within a session.
51    pub conversation_mode: ConversationMode,
52    /// Maximum times an `on-task-end` hook may reopen a single task (re-run its agent
53    /// loop with injected feedback). Defaults to 1 when absent. `0` disables reopening
54    /// entirely. Unlike `inference.max_turns`, `0` is a valid explicit value. Reopening
55    /// never grants turns past `inference.max_turns` — the two budgets share one
56    /// cumulative turn count.
57    pub max_task_reopens: u32,
58    /// How long a shell command runs in the foreground before it is demoted to the background.
59    /// Defaults to 10 seconds. A command that exits inside this window returns its output to the
60    /// turn as it always has; one that outruns it hands the turn a handle, keeps running, and
61    /// reports back later as a `completion`-origin task. `0` demotes at the first poll after the
62    /// spawn, so effectively every command detaches.
63    pub shell_grace_secs: u64,
64    /// The single bound on a delegation, covering both waits one can have: how long a sub-capsule
65    /// started by `delegate-task` is watched, and how long a plan `capsule` step waits for its
66    /// answer. Defaults to 600 seconds. Absent is a ceiling rather than an absence: a capsule that
67    /// never declares this one is still bounded, because an unbounded delegation leaves a wedged
68    /// sub-capsule running for as long as the capsule that started it. `0` gives up at the first
69    /// poll after the task is delivered, the same reading `shell_grace_secs: 0` has.
70    ///
71    /// Reaching the deadline ends the sub-capsule. A `delegate-task` caller is told in a
72    /// `completion`-origin task carrying `status: terminated` and a detail naming the bound; a plan
73    /// step's call returns `timed_out`. The clock starts when the sub-capsule reports itself ready,
74    /// not when its process is spawned, so a slow host does not spend this window on staging.
75    /// `MURMUR_DELEGATION_TIMEOUT_SECS` overrides this value when it names a positive number of
76    /// seconds.
77    pub delegation_deadline_secs: u64,
78}
79
80impl LifecycleConfig {
81    /// Whether a session under this lifecycle is still there to receive work that arrives after
82    /// the task which started it ended — a demoted shell command's completion, a delegation's
83    /// outcome.
84    ///
85    /// `queue` + `sleep` is the only combination that qualifies, and the delegation lane and the
86    /// shell lane must agree on it: a capsule warned about one and not the other would be told its
87    /// lifecycle is both adequate and inadequate for the same reason.
88    pub fn can_receive_background_tasks(&self) -> bool {
89        self.task_acceptance == TaskAcceptance::Queue && self.after_task == AfterTask::Sleep
90    }
91
92    /// How many tasks a capsule under this lifecycle holds at once: one running task plus
93    /// `queue_depth` waiting ones under `queue`, a single task under `single`, none under `none`.
94    ///
95    /// A `queue` lifecycle with `queue_depth: 0` holds nothing, because the A2A door admits a
96    /// task under `queue` only while fewer than `queue_depth` tasks are waiting. That door is the
97    /// authority this mirrors: the task registry in `capsule-runtime` (`TaskRegistry::can_accept`)
98    /// decides what is rejected, and a test there fails if the two drift apart.
99    pub fn tasks_held_at_once(&self) -> usize {
100        match self.task_acceptance {
101            TaskAcceptance::None => 0,
102            TaskAcceptance::Single => 1,
103            TaskAcceptance::Queue if self.queue_depth == 0 => 0,
104            TaskAcceptance::Queue => self.queue_depth + 1,
105        }
106    }
107}
108
109impl Default for LifecycleConfig {
110    fn default() -> Self {
111        Self {
112            task_acceptance: TaskAcceptance::Single,
113            after_task: AfterTask::Exit,
114            queue_depth: 1,
115            input_timeout_secs: None,
116            conversation_mode: ConversationMode::Stateless,
117            max_task_reopens: 1,
118            shell_grace_secs: 10,
119            delegation_deadline_secs: 600,
120        }
121    }
122}
123
124/// Lifecycle override: allows a parent (mur-roost) or CLI flag to constrain
125/// the lifecycle down from what the manifest declares.
126#[derive(Debug, Clone, Default)]
127pub struct LifecycleOverride {
128    pub task_acceptance: Option<TaskAcceptance>,
129    pub after_task: Option<AfterTask>,
130}
131
132/// Artifact taxonomy for entries declared in a capsule manifest.
133///
134/// Variant meanings:
135///
136/// - `Tool`: agent-callable tool (WASM component or native binary). Visible in MURMUR.md and the
137///   LLM tool list. Implementation details (wasm vs native) are declared in the artifact's own
138///   murmur.yaml via `implementation:`.
139/// - `Driver`: inference driver implemented as a WASM component. The runtime calls it as a
140///   processor. Hidden from the LLM.
141/// - `Hook`: event-triggered artifact implemented as a WASM component. The runtime calls it at
142///   lifecycle points; behavioral contract (binding, execution_mode, commit_policy) is declared
143///   in the artifact's own murmur.yaml. Hidden from the LLM.
144/// - `Skill`: documentation artifact; no executable binary. Visible in the LLM tool inventory.
145///   Invoking a skill by name returns its `skill.md` content as the tool result (just-in-time
146///   injection). No WASM/native dispatch occurs. A skill bound via
147///   `inference.system_prompt_artifact` is excluded from the callable inventory because it is
148///   already part of the system prompt.
149#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
150#[serde(rename_all = "lowercase")]
151pub enum ArtifactRuntime {
152    Tool,
153    Driver,
154    Hook,
155    Skill,
156}
157
158impl ArtifactRuntime {
159    /// Whether artifacts of this role appear in the LLM's tool inventory.
160    ///
161    /// Deliberately an exhaustive match with one arm per variant and no wildcard: a new
162    /// `ArtifactRuntime` variant must state its own visibility here, so adding a role that
163    /// should be LLM-visible cannot silently default to hidden.
164    pub fn is_llm_visible(&self) -> bool {
165        match self {
166            ArtifactRuntime::Tool => true,
167            ArtifactRuntime::Skill => true,
168            ArtifactRuntime::Driver => false,
169            ArtifactRuntime::Hook => false,
170        }
171    }
172
173    pub fn as_str(&self) -> &'static str {
174        match self {
175            ArtifactRuntime::Tool => "tool",
176            ArtifactRuntime::Driver => "driver",
177            ArtifactRuntime::Hook => "hook",
178            ArtifactRuntime::Skill => "skill",
179        }
180    }
181}
182
183/// How a `runtime: tool` artifact is implemented.
184///
185/// Declared via `implementation:` in the artifact's own murmur.yaml.
186/// Defaults to `Wasm` when the field is absent.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum ArtifactImplementation {
189    Wasm,
190    Native,
191}
192
193/// Read the `implementation:` field from an artifact's murmur.yaml.
194/// Defaults to `Wasm` when the field is absent or unparseable.
195pub fn read_tool_implementation(
196    artifact_manifest_path: &Path,
197) -> Result<ArtifactImplementation, RuntimeManifestError> {
198    let content =
199        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
200            path: artifact_manifest_path.display().to_string(),
201            source,
202        })?;
203    Ok(parse_tool_implementation_from_yaml(&content))
204}
205
206/// Parse `implementation:` from an in-memory manifest YAML string.
207/// Defaults to `Wasm` when the field is absent or unparseable.
208pub fn parse_tool_implementation_from_yaml(yaml: &str) -> ArtifactImplementation {
209    serde_yaml::from_str::<serde_yaml::Value>(yaml)
210        .map(|v| {
211            v.as_mapping()
212                .and_then(|m| m.get(serde_yaml::Value::String("implementation".to_string())))
213                .and_then(serde_yaml::Value::as_str)
214                .map(|s| match s {
215                    "native" => ArtifactImplementation::Native,
216                    _ => ArtifactImplementation::Wasm,
217                })
218                .unwrap_or(ArtifactImplementation::Wasm)
219        })
220        .unwrap_or(ArtifactImplementation::Wasm)
221}
222
223// ── Hook configuration types ──────────────────────────────────────────────────
224
225/// Which lifecycle event(s) a hook binds to.
226///
227/// Declared via `binding:` in the artifact's own murmur.yaml.
228/// Defaults to `All` when the field is absent (receives all session events).
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum HookBinding {
231    /// `"on-stage"` — fires during `stage_session`; always blocking.
232    OnStage,
233    /// `"on-session-start"` — fires once before the first inference turn.
234    OnSessionStart,
235    /// `"on-task-start"` — fires once per task, before that task's first inference turn.
236    OnTaskStart,
237    /// `"on-inference"` — fires after each inference response is parsed.
238    OnInference,
239    /// `"on-tool-call"` — fires after each model-requested tool invocation.
240    OnToolCall,
241    /// `"on-shell"` — fires after each shell command returns.
242    OnShell,
243    /// `"on-compaction"` — fires when the session token threshold is reached.
244    OnCompaction,
245    /// `"on-task-end"` — fires once per task, after that task's agent loop returns.
246    OnTaskEnd,
247    /// `"on-session-end"` — fires at session teardown.
248    OnSessionEnd,
249    /// No `binding:` field — receives all session events (not on-stage).
250    All,
251}
252
253impl HookBinding {
254    /// The manifest spelling of this binding, which is also the WIT lifecycle function
255    /// name the runtime dispatches to — the key `capsule-runtime`'s honored-arm table is
256    /// written against. [`HookBinding::All`] has no spelling of its own (it is what an
257    /// omitted `binding:` means), so it renders as `"all"` for diagnostics only.
258    #[must_use]
259    pub fn as_str(&self) -> &'static str {
260        match self {
261            Self::OnStage => "on-stage",
262            Self::OnSessionStart => "on-session-start",
263            Self::OnTaskStart => "on-task-start",
264            Self::OnInference => "on-inference",
265            Self::OnToolCall => "on-tool-call",
266            Self::OnShell => "on-shell",
267            Self::OnCompaction => "on-compaction",
268            Self::OnTaskEnd => "on-task-end",
269            Self::OnSessionEnd => "on-session-end",
270            Self::All => "all",
271        }
272    }
273}
274
275/// Whether the runtime waits for the hook result.
276///
277/// Declared via `execution_mode:` in the artifact's own murmur.yaml.
278/// Defaults to `Blocking` when the field is absent.
279#[derive(Debug, Clone, PartialEq, Eq)]
280pub enum HookExecutionMode {
281    /// `"blocking"` — runtime waits for result before proceeding.
282    Blocking,
283    /// `"async"` — runtime proceeds immediately; result not committed.
284    Async,
285}
286
287/// What the runtime does with a successful hook output.
288///
289/// Declared via `commit_policy:` in the artifact's own murmur.yaml.
290/// Defaults to `None` when the field is absent.
291#[derive(Debug, Clone, PartialEq, Eq)]
292pub enum HookCommitPolicy {
293    /// `"none"` — output discarded.
294    None,
295    /// `"replace-context"` — runtime replaces conversation history. Valid only with on-compaction.
296    ReplaceContext,
297    /// `"write-manifests"` — runtime writes tool manifests to workdir/tools/. Valid only with on-stage.
298    WriteManifests,
299    /// `"reopen-task"` — runtime re-runs the task's agent loop with the hook's feedback.
300    /// Valid only with on-task-end.
301    ReopenTask,
302    /// `"seed-context"` — runtime places the hook's messages at the head of the task's
303    /// first message list, under the `context.seed_budget` ceiling. Valid only with
304    /// on-task-start.
305    SeedContext,
306    /// `"deny"` — the hook decides, immediately before the call is dispatched, whether the
307    /// call happens at all. Valid only with on-shell and on-tool-call, and only with an
308    /// explicit `binding:` naming one of them.
309    ///
310    /// The only policy that *subtracts*: every other value commits something the hook
311    /// produced, this one refuses something the capsule asked for. It narrows the manifest's
312    /// grant and can never widen it.
313    Deny,
314}
315
316impl HookCommitPolicy {
317    /// The manifest spelling of this policy, for diagnostics.
318    #[must_use]
319    pub fn as_str(&self) -> &'static str {
320        match self {
321            Self::None => "none",
322            Self::ReplaceContext => "replace-context",
323            Self::WriteManifests => "write-manifests",
324            Self::ReopenTask => "reopen-task",
325            Self::SeedContext => "seed-context",
326            Self::Deny => "deny",
327        }
328    }
329}
330
331/// What the runtime does with a lifecycle event for an `execution_mode: async` hook
332/// whose job queue is already full.
333///
334/// Declared via `on_overflow:` on the hook's entry in the **capsule operator's own**
335/// manifest — not in the hook artifact's bundled murmur.yaml, because keeping up with
336/// the agent loop is the operator's trade-off, not the hook author's. Inert on a hook
337/// that turns out to be `execution_mode: blocking` (a blocking hook is never queued);
338/// the operator-manifest parser cannot tell the two apart, since the mode lives in the
339/// artifact's own manifest and is only known at staging time.
340#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
341#[serde(rename_all = "snake_case")]
342pub enum HookOverflowPolicy {
343    /// `"drop"` (the default) — discard the event and count it. The agent loop never
344    /// waits on a slow hook; telemetry is lossy under sustained overload, and the loss
345    /// is reported rather than silent.
346    #[default]
347    Drop,
348    /// `"block"` — the agent loop waits for the hook's worker to make room. No event is
349    /// lost, at the cost of putting a slow hook back on the critical path.
350    Block,
351}
352
353impl HookOverflowPolicy {
354    /// The manifest spelling of this policy, for diagnostics.
355    #[must_use]
356    pub fn as_str(&self) -> &'static str {
357        match self {
358            Self::Drop => "drop",
359            Self::Block => "block",
360        }
361    }
362}
363
364/// Behavioral contract for a hook artifact, read from its own murmur.yaml.
365#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct HookConfig {
367    pub binding: HookBinding,
368    pub execution_mode: HookExecutionMode,
369    pub commit_policy: HookCommitPolicy,
370}
371
372impl Default for HookConfig {
373    fn default() -> Self {
374        Self {
375            binding: HookBinding::All,
376            execution_mode: HookExecutionMode::Blocking,
377            commit_policy: HookCommitPolicy::None,
378        }
379    }
380}
381
382/// The one `commit_policy` a hook bound to `binding` can actually have committed, or
383/// `None` when no `commit_policy` value other than `none` is ever honored for it.
384///
385/// This is the manifest-side mirror of `capsule-runtime`'s `HONORED_OUTPUT_ARM` table,
386/// which decides — keyed on the same lifecycle function names [`HookBinding::as_str`]
387/// produces — which `hook-output` arm the runtime commits for each event. Two bindings
388/// return `None` for different reasons, and the distinction matters:
389///
390/// - `on-session-start` and `on-session-end` honor no arm at all; every output from them
391///   is discarded.
392/// - `on-tool-call` and `on-shell` honor `deny`, and only at their decision-point dispatch
393///   — the one that runs before the call. Their post-call observation dispatch honors
394///   nothing, which is why the runtime's table is keyed on the dispatch phase as well as
395///   the event name.
396/// - `on-inference` *does* honor an arm (`artifact`), but that arm has no
397///   `commit_policy` spelling — [`HookCommitPolicy`] has no `Artifact` variant — so no
398///   non-`none` policy is declarable for it either.
399///
400/// [`HookBinding::All`] (an omitted `binding:`) also returns `None`, and that is a third
401/// reason again: an `All`-bound hook is dispatched to *every* event, so there is no single
402/// honored policy to name. It is deliberately **not** validated against this function —
403/// [`parse_hook_config_from_yaml`] accepts every `commit_policy` for it, with the single
404/// exception of [`HookCommitPolicy::Deny`], which that function rejects outright for `All`.
405#[must_use]
406pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
407    match binding {
408        HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
409        HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
410        HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
411        HookBinding::OnTaskStart => Some(HookCommitPolicy::SeedContext),
412        HookBinding::OnToolCall | HookBinding::OnShell => Some(HookCommitPolicy::Deny),
413        HookBinding::OnSessionStart
414        | HookBinding::OnInference
415        | HookBinding::OnSessionEnd
416        | HookBinding::All => None,
417    }
418}
419
420/// Read a hook's behavioral contract from its murmur.yaml at the given path.
421pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
422    let content =
423        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
424            path: artifact_manifest_path.display().to_string(),
425            source,
426        })?;
427    parse_hook_config_from_yaml(&content).map_err(|msg| {
428        RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
429    })
430}
431
432/// Parse hook behavioral contract from an in-memory manifest YAML string.
433///
434/// `commit_policy: deny` is the one policy an omitted `binding:` cannot carry. Every other
435/// policy is accepted for [`HookBinding::All`] because an `All`-bound hook reaches every
436/// event and the runtime simply discards what a given event does not honor. `deny` is not
437/// discardable in that way: it is answered at a *decision point* standing in front of a call,
438/// and an `All`-bound hook would be asked to decide on shell and tool calls it was never
439/// written to judge — with every failure to answer, including a trap or a timeout, refusing
440/// the call. The binding is therefore required to name which of the two events the hook gates.
441pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
442    let v: serde_yaml::Value =
443        serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
444
445    let binding = v
446        .as_mapping()
447        .and_then(|m| m.get(serde_yaml::Value::String("binding".to_string())))
448        .and_then(serde_yaml::Value::as_str)
449        .map(|s| match s {
450            "on-stage" => Ok(HookBinding::OnStage),
451            "on-session-start" => Ok(HookBinding::OnSessionStart),
452            "on-task-start" => Ok(HookBinding::OnTaskStart),
453            "on-inference" => Ok(HookBinding::OnInference),
454            "on-tool-call" => Ok(HookBinding::OnToolCall),
455            "on-shell" => Ok(HookBinding::OnShell),
456            "on-compaction" => Ok(HookBinding::OnCompaction),
457            "on-task-end" => Ok(HookBinding::OnTaskEnd),
458            "on-session-end" => Ok(HookBinding::OnSessionEnd),
459            other => Err(format!(
460                "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
461                 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
462            )),
463        })
464        .transpose()?
465        .unwrap_or(HookBinding::All);
466
467    let execution_mode = v
468        .as_mapping()
469        .and_then(|m| m.get(serde_yaml::Value::String("execution_mode".to_string())))
470        .and_then(serde_yaml::Value::as_str)
471        .map(|s| match s {
472            "blocking" => Ok(HookExecutionMode::Blocking),
473            "async" => Ok(HookExecutionMode::Async),
474            other => Err(format!(
475                "unknown execution_mode '{other}'; expected: blocking, async"
476            )),
477        })
478        .transpose()?
479        .unwrap_or(HookExecutionMode::Blocking);
480
481    let commit_policy = v
482        .as_mapping()
483        .and_then(|m| m.get(serde_yaml::Value::String("commit_policy".to_string())))
484        .and_then(serde_yaml::Value::as_str)
485        .map(|s| match s {
486            "none" => Ok(HookCommitPolicy::None),
487            "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
488            "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
489            "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
490            "seed-context" => Ok(HookCommitPolicy::SeedContext),
491            "deny" => Ok(HookCommitPolicy::Deny),
492            other => Err(format!(
493                "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
494                 reopen-task, seed-context, deny"
495            )),
496        })
497        .transpose()?
498        .unwrap_or(HookCommitPolicy::None);
499
500    // Validation
501    if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
502        return Err(format!(
503            "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
504             (got '{}')",
505            commit_policy.as_str()
506        ));
507    }
508    if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
509        return Err(
510            "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
511             'on-stage'"
512                .to_string(),
513        );
514    }
515    // The one policy `All` cannot carry: a hook that does not name which of the two gated
516    // events it decides on would be dispatched at decision points it was never written for,
517    // and every non-`none` answer there refuses a call.
518    if binding == HookBinding::All && commit_policy == HookCommitPolicy::Deny {
519        return Err(
520            "commit_policy 'deny' requires an explicit binding: 'on-shell' or 'on-tool-call'; \
521             a hook with no binding: is dispatched at every event, including decision points \
522             it was not written to decide"
523                .to_string(),
524        );
525    }
526    // `binding` is the single source of truth for what the runtime commits; a declared
527    // `commit_policy` the binding can never honor is a mistake that is fully knowable here,
528    // at staging time, rather than mid-session as a `hook_dispatch_error`. An omitted
529    // `binding:` (`All`) reaches every event, so every other policy stays valid for it.
530    if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
531        let honored = commit_policy_for_binding(&binding);
532        if honored.as_ref() != Some(&commit_policy) {
533            let note = if binding == HookBinding::OnInference {
534                " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
535            } else {
536                ""
537            };
538            return Err(format!(
539                "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
540                 commit_policy '{}'{note}",
541                commit_policy.as_str(),
542                binding.as_str(),
543                binding.as_str(),
544                honored.as_ref().map_or("none", HookCommitPolicy::as_str)
545            ));
546        }
547    }
548
549    Ok(HookConfig {
550        binding,
551        execution_mode,
552        commit_policy,
553    })
554}
555
556#[derive(Debug, Clone, PartialEq, Eq)]
557pub struct RuntimeManifest {
558    pub name: String,
559    pub version: String,
560    pub artifacts: Vec<RuntimeArtifact>,
561    pub capabilities: Option<Capabilities>,
562    pub inference: Option<InferenceConfig>,
563    pub context: Option<ContextConfig>,
564    pub observability: Option<ObservabilityConfig>,
565    pub trace: Option<TraceConfig>,
566    pub network: Option<NetworkConfig>,
567    pub lifecycle: Option<LifecycleConfig>,
568    /// Read-only views onto the workdir that the operator opens to processes outside the capsule.
569    /// `None` means nothing is exported and every request to the resource plane is denied.
570    pub exports: Option<Exports>,
571    /// What a controller holding this session's control token may change while it runs. `None`
572    /// — the block absent, `control: {}`, or both lists empty — means the capsule has no control
573    /// surface and mints no token.
574    pub control: Option<ControlConfig>,
575    /// Pins the mur runtime version required by this capsule.
576    /// Used by `mur deploy` to select the binary version to install on the VM,
577    /// and by `mur run` to warn on version mismatch.
578    /// If absent, the running mur binary's version is used.
579    pub mur_version: Option<String>,
580    /// Every key the manifest declared that this build does not recognize, captured by each
581    /// `Raw*` block's `#[serde(flatten)]` overflow map instead of dropped.
582    ///
583    /// Ordered by the walk rather than by the manifest's own line order: a containing block's own
584    /// keys precede the blocks nested inside it, and one block's keys are alphabetical.
585    ///
586    /// Empty for a manifest written entirely in keys this build knows. Never a reason to refuse a
587    /// manifest — it is reported as `W-SEC-019` by
588    /// [`crate::unknown_manifest_keys::warn_on_unknown_manifest_keys`] and nothing else reads it.
589    pub unknown_keys: Vec<UnknownManifestKey>,
590}
591
592impl RuntimeManifest {
593    pub fn effective_lifecycle(&self) -> LifecycleConfig {
594        self.lifecycle.clone().unwrap_or_default()
595    }
596
597    /// Whether this capsule's door serves tasks a peer capsule's runtime sends it: `true` only
598    /// when the manifest declares `exports.peer_tasks.accept: true`. See
599    /// [`Exports::accepts_peer_tasks`].
600    ///
601    /// The capsule's own manifest is the only input. Nothing a launcher, an operator or a
602    /// formation passes can change the answer.
603    pub fn accepts_peer_tasks(&self) -> bool {
604        self.exports
605            .as_ref()
606            .is_some_and(Exports::accepts_peer_tasks)
607    }
608}
609
610/// A setting of a running session that a controller may change over the control surface, when
611/// `control.settings` names it.
612///
613/// Each variant is one manifest key, spelled by [`Self::wire_name`] in `control.settings`, in the
614/// control surface's paths and bodies, and in the trace. What a value must be, and where the agent
615/// loop reads it, is the runtime's to decide per variant.
616#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
617pub enum ControllableSetting {
618    /// `inference.max_tokens`: the per-turn output cap the agent loop sends as `max_tokens`.
619    /// `transport: http` only, on the same terms as the manifest key.
620    InferenceMaxTokens,
621    /// `inference.driver`: which driver choice serves the agent loop's inference calls, by name —
622    /// [`PRIMARY_DRIVER_CHOICE`] or an `inference.alternates` entry's `name`. `transport: http`
623    /// only, and only on a capsule that declares at least one alternate.
624    InferenceDriver,
625}
626
627impl ControllableSetting {
628    /// Every setting a manifest may list, in the order refusals name them.
629    pub const ALL: &'static [ControllableSetting] =
630        &[Self::InferenceMaxTokens, Self::InferenceDriver];
631
632    /// Every setting `control.agent_settings` may list: the ones the agent may change about
633    /// itself through a runtime-provided tool.
634    pub const AGENT_SETTABLE: &'static [ControllableSetting] = &[Self::InferenceDriver];
635
636    /// The manifest key this setting is, as written in `control.settings`.
637    pub fn wire_name(self) -> &'static str {
638        match self {
639            Self::InferenceMaxTokens => "inference.max_tokens",
640            Self::InferenceDriver => "inference.driver",
641        }
642    }
643
644    /// The setting `name` spells, or `None` for anything outside [`Self::ALL`].
645    pub fn from_wire_name(name: &str) -> Option<Self> {
646        Self::ALL
647            .iter()
648            .copied()
649            .find(|setting| setting.wire_name() == name)
650    }
651}
652
653/// The top-level `control:` block: what a controller may change on a running session.
654///
655/// Parsed only to a non-empty declaration; `control: {}` and two empty lists parse as no block.
656/// Nothing a controller sets is persisted, so this is also everything a restart starts from.
657#[derive(Debug, Clone, PartialEq, Eq, Default)]
658pub struct ControlConfig {
659    /// Settings a controller may change, in manifest order, each listed once.
660    pub settings: Vec<ControllableSetting>,
661    /// Credential names a controller supplies at run time, in manifest order, each listed once.
662    /// Every name backs some artifact's `gateway.api_key: ${NAME}`, and none backs the configured
663    /// inference driver's. Such a name is never looked up in the config or the environment.
664    pub secrets: Vec<String>,
665    /// Settings the agent may change about itself, in manifest order, each listed once. Only
666    /// [`ControllableSetting::AGENT_SETTABLE`] members. The grant is the existence of the
667    /// runtime-provided tool that changes the setting, and never a route to the control surface.
668    pub agent_settings: Vec<ControllableSetting>,
669}
670
671impl ControlConfig {
672    /// Whether `name` is a credential a controller supplies.
673    pub fn declares_secret(&self, name: &str) -> bool {
674        self.secrets.iter().any(|secret| secret == name)
675    }
676
677    /// Whether a controller may change `setting`.
678    pub fn controller_may_set(&self, setting: ControllableSetting) -> bool {
679        self.settings.contains(&setting)
680    }
681
682    /// Whether the agent may change `setting`.
683    pub fn agent_may_set(&self, setting: ControllableSetting) -> bool {
684        self.agent_settings.contains(&setting)
685    }
686}
687
688/// The top-level `network:` block, which configures the A2A door. Unrelated to
689/// `capabilities.network`, which is the guest's egress.
690#[derive(Debug, Clone, PartialEq, Eq)]
691pub struct NetworkConfig {
692    /// Internal port the capsule expects to listen on. Default 14159 when absent.
693    pub internal_port: Option<u16>,
694    /// `network.authentication`: how a caller of the door proves it may call. `None` is a public
695    /// door, which answers every caller that reaches the port.
696    pub authentication: Option<NetworkAuthentication>,
697}
698
699/// Every scope a door token can carry: the door's JSON-RPC methods that a token gates, by wire
700/// name, and `resources/files`, the operator resource plane. The runtime's method table is held
701/// to this list by a test, in both directions.
702pub const DOOR_SCOPES: &[&str] = &[
703    "message/send",
704    "message/stream",
705    "stream/watch",
706    "tasks/get",
707    "tasks/cancel",
708    "session/stop",
709    "resources/files",
710];
711
712/// The credential the runtime always mints for an authenticated door, holding every scope in
713/// [`DOOR_SCOPES`]. Reserved: no declared credential may take the name.
714pub const OPERATOR_CREDENTIAL: &str = "operator";
715
716/// Longest credential name `network.authentication.credentials` accepts.
717const MAX_CREDENTIAL_NAME_LEN: usize = 32;
718
719/// How a caller of the door authenticates. A required field with one value, so that an
720/// authenticated door is an explicit statement and a second scheme is an addition, not a default
721/// change.
722#[derive(Debug, Clone, Copy, PartialEq, Eq)]
723pub enum AuthenticationScheme {
724    /// `Authorization: Bearer <token>`, with a token the capsule's runtime mints at launch.
725    Bearer,
726}
727
728impl AuthenticationScheme {
729    /// Every scheme a manifest may name, in the order refusals list them.
730    pub const ALL: &'static [AuthenticationScheme] = &[Self::Bearer];
731
732    /// The scheme as written in `network.authentication.scheme`.
733    pub fn wire_name(self) -> &'static str {
734        match self {
735            Self::Bearer => "bearer",
736        }
737    }
738}
739
740/// One declared credential: a token the runtime mints beside the operator token, reaching only
741/// `scopes`.
742#[derive(Debug, Clone, PartialEq, Eq)]
743pub struct DoorCredential {
744    /// Matches `^[a-z][a-z0-9_-]{0,31}$` and is never [`OPERATOR_CREDENTIAL`].
745    pub name: String,
746    /// Non-empty, each entry in [`DOOR_SCOPES`] and listed once, in declared order.
747    pub scopes: Vec<String>,
748}
749
750/// `network.authentication`: the door refuses every caller that presents no token this session
751/// minted.
752#[derive(Debug, Clone, PartialEq, Eq)]
753pub struct NetworkAuthentication {
754    pub scheme: AuthenticationScheme,
755    /// The declared credentials, sorted by name. Empty when only the operator token is minted.
756    pub credentials: Vec<DoorCredential>,
757}
758
759// ── Exports ───────────────────────────────────────────────────────────────────
760
761/// Per-file read ceiling applied when `exports.files.max_bytes` is absent: 10Mi.
762pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
763
764/// What the operator discloses to processes *outside* the capsule, declared under the top-level
765/// `exports:` key.
766///
767/// A sibling of `capabilities:` rather than a member of it: a capability is something the guest
768/// holds and an export is a disclosure the operator makes. Declaring one
769/// gives the agent nothing it did not already have — it widens only the operator's reach inward,
770/// which is why it never enters the achieved-containment computation.
771#[derive(Debug, Clone, PartialEq, Eq)]
772pub struct Exports {
773    /// The declared read-only file surface. `None` — an `exports:` block with no `files:` —
774    /// means the resource plane is not declared and every request to it is denied.
775    pub files: Option<FileExport>,
776    /// The declared peer-handoff surface. A separate authoriser from [`Self::files`], over a
777    /// separate subtree: declaring one grants nothing about the other, and a capsule may declare
778    /// either, both or neither. `None` means the capsule mints no handles and its peer plane
779    /// answers `no_peer_plane`.
780    pub peer_files: Option<PeerFilesExport>,
781    /// The declared willingness to take tasks from other capsules. `None` — no `peer_tasks:` —
782    /// refuses them exactly as `accept: false` does; read it through
783    /// [`Self::accepts_peer_tasks`] rather than matching on it.
784    pub peer_tasks: Option<PeerTasksExport>,
785}
786
787impl Exports {
788    /// Whether this capsule consents to tasks from peer capsules: `true` only for an explicit
789    /// `exports.peer_tasks.accept: true`. Absent and `false` are the same answer.
790    ///
791    /// The one reading rule. [`RuntimeManifest::accepts_peer_tasks`] delegates here, so a caller
792    /// holding only the lowered `exports:` block decides exactly as one holding the manifest.
793    pub fn accepts_peer_tasks(&self) -> bool {
794        self.peer_tasks.is_some_and(|peer_tasks| peer_tasks.accept)
795    }
796}
797
798/// The `exports.peer_tasks` block: whether the capsule's A2A door serves a task another capsule's
799/// runtime sends it.
800///
801/// An export rather than a capability: tasks already arrive through the door, so consenting gives
802/// the agent nothing it did not have and opens the door to one more class of caller. It names no
803/// peer, and has no field to name one — it is a posture of this capsule, not a grant to anyone.
804/// It is independent of `network.authentication`: consenting neither requires nor implies a
805/// token.
806///
807/// A block rather than a bare boolean so that it can gain fields without a breaking change.
808#[derive(Debug, Clone, Copy, PartialEq, Eq)]
809pub struct PeerTasksExport {
810    /// Required, never inferred. `false` is the same posture as declaring no block at all.
811    pub accept: bool,
812}
813
814/// The `exports.files` block: one subtree of the workdir, readable and nothing else.
815#[derive(Debug, Clone, PartialEq, Eq)]
816pub struct FileExport {
817    /// Subtree of the capsule's accessible workdir this export opens, verbatim as declared
818    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
819    /// directory belongs to the runtime, which is the only component that may decide a path is
820    /// inside the root.
821    ///
822    /// Not required to exist when the capsule launches — the agent may create it during a task.
823    pub root: String,
824    /// Required, with exactly one legal value. A required field with one value is what makes the
825    /// read-only posture an explicit operator statement rather than an unstated default, and
826    /// leaves a future write mode somewhere to be declared rather than somewhere to leak in.
827    pub mode: ExportMode,
828    /// Per-file read ceiling in bytes, defaulting to [`DEFAULT_EXPORT_MAX_BYTES`]. Never an
829    /// aggregate budget: each file is judged on its own size, and a subtree whose total exceeds
830    /// this is still listed in full.
831    pub max_bytes: u64,
832}
833
834/// Handle lifetime applied when `exports.peer_files.max_ttl` is absent and the capsule is
835/// ephemeral (`lifecycle.after_task: exit`): 1h.
836pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
837
838/// The largest `exports.peer_files.max_ttl` a *persistent* capsule
839/// (`lifecycle.after_task: sleep`) may declare: 15m.
840///
841/// An ephemeral capsule needs no ceiling because teardown destroys the minting key and with it
842/// every outstanding handle. A persistent one has withdrawn that bound, so the declared lifetime
843/// becomes the only one — and a handle is not a durability mechanism. Enforced by the runtime,
844/// not here, because the rule reads `lifecycle.after_task` and this parser sees one block at a
845/// time.
846pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
847
848/// Per-file read ceiling applied when `exports.peer_files.max_bytes` is absent: 10Mi.
849pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
850
851/// The `exports.peer_files` block: the one subtree a `share-file` handle may name.
852///
853/// Deliberately not a mode of [`FileExport`]. The operator plane addresses files by path and
854/// enumerates them; this plane has no `list` verb and no path addressing at all, and its audience
855/// is another capsule rather than the operator. Two authorisers over two subtrees, so opening one
856/// never widens the other.
857#[derive(Debug, Clone, PartialEq, Eq)]
858pub struct PeerFilesExport {
859    /// Subtree of the capsule's accessible workdir a handle may name, verbatim as declared
860    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
861    /// directory belongs to the runtime. Not required to exist when the capsule launches.
862    pub root: String,
863    /// The declared `max_ttl`, in seconds, or `None` when the manifest declared none.
864    ///
865    /// Kept undefaulted because the two ephemerality cases answer an absent value differently:
866    /// an ephemeral capsule falls back to [`DEFAULT_PEER_HANDLE_TTL_SECS`], and a persistent one
867    /// refuses to launch. Substituting the default here would erase the difference before
868    /// anything could act on it. See [`Self::effective_max_ttl_secs`].
869    pub max_ttl_secs: Option<u64>,
870    /// Per-file ceiling on a redeemed read, defaulting to [`DEFAULT_PEER_FILES_MAX_BYTES`].
871    /// Per file, never an aggregate budget — the same terms as [`FileExport::max_bytes`], and a
872    /// separate value from it.
873    pub max_bytes: u64,
874}
875
876impl PeerFilesExport {
877    /// The handle lifetime ceiling this export actually applies: the declared `max_ttl`, or the
878    /// ephemeral default when none was declared.
879    ///
880    /// Correct for a persistent capsule too, because one that declared no `max_ttl` never
881    /// launches — the runtime refuses it before a plane is built.
882    pub fn effective_max_ttl_secs(&self) -> u64 {
883        self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
884    }
885}
886
887/// The `capabilities.peer_fetch` block: which peers this capsule may redeem a handle against.
888///
889/// A sibling of [`NetworkCapabilities`] rather than a member of it. Fetching a peer's bytes lands
890/// a file in this capsule's own workdir, so it is an ingestion path and a prompt-injection
891/// surface, and it gets its own operator control. Declaring a destination here does not widen
892/// `capabilities.network.allow`, and a destination allowed there is not redeemable unless it is
893/// also named here.
894#[derive(Debug, Clone, PartialEq, Eq)]
895pub struct PeerFetchCapabilities {
896    /// Network destinations a handle may be redeemed against. Required and non-empty when the
897    /// block is present: an empty list is a parse error, never a silent deny. Same syntax as
898    /// `capabilities.network.allow`, and matched by the same runtime rule matcher.
899    pub allow: Vec<String>,
900}
901
902/// The accepted spelling of every duration in the manifest, stated once so the parser and the
903/// error it produces cannot drift apart.
904pub const DURATION_ACCEPTED_FORM: &str =
905    "must be a duration: an integer, optionally suffixed s/m/h/d";
906
907/// Parses `90`, `30s`, `15m`, `1h`, `14d` into a whole number of seconds.
908///
909/// A bare integer is seconds. Suffixes are lowercase and single-character; `5 minutes` and `30S`
910/// are rejected rather than guessed at, on the same terms as [`parse_byte_size`]. Returns the
911/// accepted-form sentence as its error so every call site reports the same rule.
912pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
913    let trimmed = input.trim();
914    let (digits, multiplier) = match trimmed.strip_suffix('s') {
915        Some(digits) => (digits, 1u64),
916        None => match trimmed.strip_suffix('m') {
917            Some(digits) => (digits, 60),
918            None => match trimmed.strip_suffix('h') {
919                Some(digits) => (digits, 3600),
920                None => match trimmed.strip_suffix('d') {
921                    Some(digits) => (digits, 86_400),
922                    None => (trimmed, 1),
923                },
924            },
925        },
926    };
927    // Not trimmed again: the whole input was trimmed above, so a space surviving here is *inside*
928    // the value (`5 minutes`, `30 m`), which is not a spelling this accepts.
929    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
930        return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
931    }
932    digits
933        .parse::<u64>()
934        .ok()
935        .and_then(|value| value.checked_mul(multiplier))
936        .ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
937}
938
939#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
940pub enum ExportMode {
941    #[serde(rename = "read-only")]
942    ReadOnly,
943}
944
945impl ExportMode {
946    pub fn as_str(self) -> &'static str {
947        match self {
948            ExportMode::ReadOnly => "read-only",
949        }
950    }
951}
952
953impl std::fmt::Display for ExportMode {
954    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
955        f.write_str(self.as_str())
956    }
957}
958
959/// The accepted spelling of every byte count in the manifest, stated once so the parser and the
960/// error it produces cannot drift apart.
961pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
962
963/// Parses `4096`, `1Ki`, `10Mi`, `2Gi` into a byte count.
964///
965/// Binary suffixes only, and case-sensitively: `10MB` is rejected rather than guessed at, because
966/// a manifest that means 10 000 000 and a manifest that means 10 485 760 must not both parse.
967/// Returns the accepted-form sentence as its error so every call site reports the same rule.
968pub fn parse_byte_size(input: &str) -> Result<u64, String> {
969    let trimmed = input.trim();
970    let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
971        Some(digits) => (digits, 1024u64),
972        None => match trimmed.strip_suffix("Mi") {
973            Some(digits) => (digits, 1024 * 1024),
974            None => match trimmed.strip_suffix("Gi") {
975                Some(digits) => (digits, 1024 * 1024 * 1024),
976                None => (trimmed, 1),
977            },
978        },
979    };
980    // Deliberately not trimmed again: the whole input was trimmed above, so any space left here
981    // is *inside* the value (`10 Mi`), which is not a spelling this accepts.
982    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
983        return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
984    }
985    digits
986        .parse::<u64>()
987        .ok()
988        .and_then(|value| value.checked_mul(multiplier))
989        .ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
990}
991
992#[derive(Debug, Clone, PartialEq, Eq)]
993pub struct RuntimeArtifact {
994    pub name: String,
995    pub version: String,
996    pub runtime: ArtifactRuntime,
997    /// Optional local source path. When set, the runtime resolves the artifact directly from
998    /// this path instead of from the registry — no `.mur.zip`, no publish. For a skill the path
999    /// points at a `skill.md` file or at a directory containing one (case-insensitive).
1000    /// Relative paths resolve against the directory containing `murmur.yaml`.
1001    /// Only permitted when `local_source` is true.
1002    pub source: Option<String>,
1003    /// Whether this artifact may be resolved from a local `source:` path. Declared via
1004    /// `local_source:` in the project manifest's artifact entry; when absent it defaults to
1005    /// true for `runtime: skill` and false for every other role, which is exactly the role-based
1006    /// gate this field replaced.
1007    pub local_source: bool,
1008    /// Whether this artifact's payload may be bound as the system prompt via
1009    /// `inference.system_prompt_artifact`. Declared via `prompt_payload:` in the project
1010    /// manifest's artifact entry; when absent it defaults to true for `runtime: skill` and
1011    /// false for every other role.
1012    pub prompt_payload: bool,
1013    /// Per-artifact capability grant, declared via `capabilities:` on this entry in the
1014    /// **capsule operator's own** manifest. Recognized on `runtime: hook`, `runtime: tool`,
1015    /// and `runtime: driver` entries; a `runtime: skill` entry carrying the key is rejected
1016    /// at parse time, because nothing would enforce it and a silently-ignored grant reads
1017    /// like a scoped artifact.
1018    ///
1019    /// What `None` (the key absent) means depends on the role, deliberately:
1020    /// - `hook`: full default-deny — no network and no preopened directory.
1021    /// - `tool`/`driver`: the unchanged capsule-wide ceiling. A declared block *narrows*
1022    ///   from that ceiling and can never widen past it.
1023    ///
1024    /// Deliberately never sourced from the artifact's own bundled `murmur.yaml` (see
1025    /// [`parse_hook_config_from_yaml`], which parses that file and knows nothing about
1026    /// capabilities) — an artifact pulled from a registry cannot self-grant.
1027    ///
1028    /// Reuses the whole [`Capabilities`] type for vocabulary consistency with the
1029    /// capsule-wide block, but only `network` and `filesystem` are consumed by the runtime
1030    /// per-artifact; the other sub-blocks are inert here.
1031    pub capabilities: Option<Capabilities>,
1032    /// What the runtime does when this hook's async job queue is full, declared via
1033    /// `on_overflow:` on this entry. Defaults to [`HookOverflowPolicy::Drop`] when absent.
1034    /// Accepted only on `runtime: hook` entries — every other role is rejected at parse
1035    /// time, since nothing would ever consult it there.
1036    pub on_overflow: HookOverflowPolicy,
1037    /// Operator-authored configuration for this artifact alone, declared via `config:` on this
1038    /// entry. Held as the raw YAML node: this crate accepts any mapping, and the runtime is what
1039    /// lowers it to the JSON delivered as `MURMUR_ARTIFACT_CONFIG` and refuses a shape that
1040    /// cannot travel that way.
1041    ///
1042    /// `None` is the key being absent, which means the variable is absent from the guest
1043    /// environment. `Some(Value::Null)` is `config:` written with nothing under it — a
1044    /// declaration that carries nothing, refused by the runtime rather than treated as absent.
1045    ///
1046    /// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
1047    /// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
1048    /// [`Self::capabilities`]. Operator-sourced only, and never read from the artifact's own
1049    /// bundled `murmur.yaml` — an artifact pulled from a registry cannot configure itself.
1050    ///
1051    /// Plaintext in a file that is also an audit record: secrets belong in `${VAR}` references
1052    /// and the credential-stripping path, not here.
1053    pub config: Option<serde_yaml::Value>,
1054    /// The third-party upstream this artifact reaches through the credential gateway, and the
1055    /// key the runtime presents there on its behalf, declared via `gateway:` on this entry.
1056    ///
1057    /// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
1058    /// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
1059    /// [`Self::capabilities`]. A `runtime: driver` entry may carry it only when it is the
1060    /// configured `transport: http` driver, whose gateway is the metered inference gateway.
1061    ///
1062    /// Operator-sourced only, and never read from the artifact's own bundled `murmur.yaml`: the
1063    /// artifact says how the key is presented (`upstream_auth:`), never where it goes.
1064    pub gateway: Option<ArtifactGateway>,
1065}
1066
1067/// One artifact's credential gateway, as the operator declared it on the artifact's entry.
1068///
1069/// Debug never prints a literal key: [`ApiKeyReference`]'s own `Debug` redacts it.
1070#[derive(Debug, Clone, PartialEq, Eq)]
1071pub struct ArtifactGateway {
1072    /// Absolute `http`/`https` upstream URL with a host and no query or fragment. Plain `http`
1073    /// only to `localhost` or a loopback IP literal.
1074    pub endpoint: String,
1075    /// The key presented upstream. `None` for a keyless gateway, or when the manifest was parsed
1076    /// with [`RuntimeManifest::from_yaml_str_without_secrets`].
1077    pub api_key: Option<ApiKeyReference>,
1078    /// `keyless: true` as the operator wrote it: this upstream takes no key, and the gateway
1079    /// forwards with no credential header. Exclusive with `api_key`; kept as written in both
1080    /// secret modes. Never derived from the endpoint's address.
1081    pub keyless: bool,
1082}
1083
1084#[derive(Debug, Clone, PartialEq, Eq)]
1085pub struct InferenceDriver {
1086    pub artifact: String,
1087    pub config: Option<String>,
1088}
1089
1090/// The name of the driver choice `inference.driver.artifact` with `inference.model` forms. Reserved:
1091/// no `inference.alternates` entry may take it.
1092pub const PRIMARY_DRIVER_CHOICE: &str = "primary";
1093
1094/// One `inference.alternates` entry: a named pair of a declared driver and a model that the
1095/// agent loop may be switched to while the capsule runs.
1096///
1097/// Takes no `driver.config` of its own: `inference.driver.config` reaches every declared driver,
1098/// and per-driver settings are that driver's `artifacts[].config`.
1099#[derive(Debug, Clone, PartialEq, Eq)]
1100pub struct InferenceAlternate {
1101    /// `[a-z][a-z0-9-]{0,62}`, unique among the alternates, and never [`PRIMARY_DRIVER_CHOICE`].
1102    pub name: String,
1103    /// The model string the driver sends, never empty.
1104    pub model: String,
1105    /// `driver.artifact`: an `artifacts:` entry with `runtime: driver`, which carries its own
1106    /// `gateway:` unless it is the primary's own driver.
1107    pub driver: String,
1108}
1109
1110/// What a `gateway.api_key` names, as the manifest wrote it. Parsing never resolves it: the
1111/// runtime looks the value up at staging, where it knows where credentials are kept.
1112#[derive(Clone, PartialEq, Eq)]
1113pub enum ApiKeyReference {
1114    /// `${NAME}`: a credential name, looked up in the global config's `credentials:` map and
1115    /// then in the launching environment.
1116    Environment(String),
1117    /// Any other non-empty value, used verbatim as the key.
1118    Literal(String),
1119}
1120
1121impl std::fmt::Debug for ApiKeyReference {
1122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1123        match self {
1124            Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
1125            Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
1126        }
1127    }
1128}
1129
1130#[derive(Debug, Clone, PartialEq, Eq)]
1131pub struct InferenceConfig {
1132    pub transport: String,
1133    pub model: String,
1134    /// WASM driver artifact. Always present: the inference driver under `transport: http`, the
1135    /// process driver under `transport: process`.
1136    pub driver: Option<InferenceDriver>,
1137    /// The executable to run instead of the one the process driver's `describe()` names. Absent
1138    /// for `transport: http`, and optional under `transport: process`, where the driver names its
1139    /// own binary.
1140    pub command: Option<String>,
1141    pub compaction: Option<CompactionConfig>,
1142    pub system_prompt: Option<String>,
1143    pub system_prompt_file: Option<String>,
1144    /// Name of a skill artifact declared in `artifacts:` whose `skill.md` is used as the system
1145    /// prompt. Mutually exclusive with `system_prompt` and `system_prompt_file`. The skill is
1146    /// excluded from the callable tool inventory (it's already in the system prompt).
1147    pub system_prompt_artifact: Option<String>,
1148    /// Maximum LLM turns per capsule task. Defaults to 10 when absent in the manifest.
1149    /// Enforced as a hard ceiling by the runtime.
1150    pub max_turns: u32,
1151    /// Maximum output tokens the model may generate per turn (`max_tokens` in the driver wire
1152    /// payload). None means the manifest didn't set it; the runtime applies its own default.
1153    /// `transport: http` only — rejected at parse time under `transport: process`.
1154    ///
1155    /// Unrelated to [`ContextConfig::max_tokens`], which is the session-wide token budget that
1156    /// drives compaction. This one is a per-turn *output* cap.
1157    pub max_tokens: Option<u32>,
1158    /// `inference.max_session_tokens`: the most input plus output tokens this session may spend.
1159    /// `None` sets no session ceiling.
1160    ///
1161    /// Valid on both transports, measured differently on each: under `transport: http` it counts
1162    /// the runtime's own measurement of every driver call, and under `transport: process` the
1163    /// counts the harness's driver reports for each turn. Setting it against a process driver
1164    /// that reports no usage is refused at load.
1165    pub max_session_tokens: Option<u64>,
1166    /// `inference.tool_refresh`: when an artifact installed mid-session reaches the tool array
1167    /// the agent loop sends the model. Always [`ToolRefresh::Compaction`] under
1168    /// `transport: process`, where the key is refused at parse time and nothing reads it.
1169    pub tool_refresh: ToolRefresh,
1170    /// `inference.alternates`: the other driver choices the agent loop may be switched to, in
1171    /// manifest order. Empty under `transport: process`, where the key is refused at parse time,
1172    /// and for every capsule that declares none.
1173    pub alternates: Vec<InferenceAlternate>,
1174}
1175
1176/// `inference.tool_refresh`: the turn boundary at which the http agent loop rebuilds the tool
1177/// array it sends the model, once the session's installed set has changed.
1178///
1179/// The array comes first in every provider's cached prompt prefix, so rebuilding it is a full
1180/// prompt-cache miss on the call that carries it. The start of the next task rebuilds it under
1181/// either value.
1182#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1183pub enum ToolRefresh {
1184    /// Rebuild before the first inference call after a compaction commits a replacement
1185    /// context, whose message history is already a full cache miss.
1186    #[default]
1187    Compaction,
1188    /// Rebuild before the next inference call after the install.
1189    Immediate,
1190}
1191
1192impl ToolRefresh {
1193    /// Every value, in the order refusals name them.
1194    pub const ALL: [ToolRefresh; 2] = [Self::Compaction, Self::Immediate];
1195
1196    /// The value as written in the manifest and in `trace.jsonl`.
1197    #[must_use]
1198    pub fn wire_name(self) -> &'static str {
1199        match self {
1200            Self::Compaction => "compaction",
1201            Self::Immediate => "immediate",
1202        }
1203    }
1204}
1205
1206#[derive(Debug, Clone, PartialEq)]
1207pub struct CompactionConfig {
1208    /// Fraction of context_window at which compaction fires (0.0–1.0]. Default: 0.98.
1209    pub threshold: Option<f32>,
1210    /// Model for compaction. None means use the primary inference model.
1211    pub model: Option<String>,
1212    /// System prompt for compaction. None means the compaction hook picks its own default.
1213    /// Mutually exclusive with `system_prompt_file`.
1214    pub system_prompt: Option<String>,
1215    /// Path to a local file whose contents are used as the compaction system prompt,
1216    /// resolved relative to the manifest directory when the session launches. Mutually
1217    /// exclusive with `system_prompt`.
1218    pub system_prompt_file: Option<String>,
1219    /// When true, every committed compaction appends one JSON line recording the
1220    /// replacement summary to `out/compaction-summaries.jsonl` in the session workdir.
1221    /// None (the key absent) is equivalent to false: nothing is written.
1222    pub dump_summaries: Option<bool>,
1223}
1224
1225// Threshold values are validated to (0.0, 1.0] at parse time so NaN is impossible.
1226impl Eq for CompactionConfig {}
1227
1228/// `context.seed_budget` when the manifest leaves it out: a tenth of the window.
1229/// Small enough that a seed cannot crowd out the task it precedes, large enough that a
1230/// memory hook has room to say something.
1231pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
1232
1233/// `context.seed_overflow_margin` when the manifest leaves it out. Slack above
1234/// `seed_budget` within which an over-budget seed is trimmed rather than summarized —
1235/// a few tokens over must not buy an inference call.
1236pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
1237
1238#[derive(Debug, Clone, PartialEq)]
1239pub struct ContextConfig {
1240    /// Token budget for this session; None disables compaction.
1241    pub max_tokens: Option<u32>,
1242    /// Whether the runtime keeps a durable conversation record for this capsule. `true` unless
1243    /// the manifest says `record: off`; `false` turns the mechanism off entirely, creating
1244    /// nothing under `~/.murmur/conversations/`.
1245    pub record: bool,
1246    /// Directory segment under `~/.murmur/conversations/` this capsule's records live in.
1247    /// `None` means the capsule name. Validated as one path segment by the runtime, on the same
1248    /// terms as `capabilities.state.store`. Inert when [`Self::record`] is `false`.
1249    pub record_store: Option<String>,
1250    /// Fraction of [`Self::max_tokens`] an `on-task-start` hook's `seed-context` may
1251    /// occupy, in `0.0..=1.0`. Always populated; [`DEFAULT_SEED_BUDGET`] when the key is
1252    /// absent. Inert without `max_tokens`: a fraction of no ceiling is no ceiling, and a
1253    /// seed with no ceiling is rejected rather than committed unbounded.
1254    pub seed_budget: f32,
1255    /// Slack above the seed budget, as a fraction of it, within which an over-budget seed
1256    /// is trimmed from its front rather than handed to the compaction hook, in
1257    /// `0.0..=1.0`. Always populated; [`DEFAULT_SEED_OVERFLOW_MARGIN`] when the key is
1258    /// absent.
1259    pub seed_overflow_margin: f32,
1260    /// What bounds this capsule's conversation records. `None` — the `retain:` block was absent
1261    /// — keeps every record whole and forever. Inert when [`Self::record`] is `false`.
1262    pub retain: Option<ContextRetainConfig>,
1263}
1264
1265// Both fractions are validated to 0.0..=1.0 at parse time, so NaN is impossible.
1266impl Eq for ContextConfig {}
1267
1268#[derive(Debug, Clone, PartialEq, Eq)]
1269pub struct ObservabilityConfig {
1270    pub otel_endpoint: Option<String>,
1271    pub eval: Option<EvalConfig>,
1272}
1273
1274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1275pub struct TraceConfig {
1276    /// How much of each turn's driver request this session's trace keeps — resolved from
1277    /// `trace.capture` by [`crate::trace_capture::resolve_trace_capture`].
1278    pub capture: TraceCapture,
1279    /// What bounds the session directories under the workdir. `None` — the `retain:` block was
1280    /// absent — keeps every session forever; there is no default policy, because a mechanism
1281    /// that deletes an operator's traces unless they opt out is the one default-allow in a
1282    /// runtime that is default-deny everywhere else.
1283    pub retain: Option<TraceRetainConfig>,
1284}
1285
1286/// The `trace.retain` block: what bounds the set of session directories beside the running one.
1287///
1288/// Both keys are optional and ANDed — a session survives only if it is inside every limit
1289/// declared. An empty block is a parse error, not a no-op: omitting `retain:` is how a capsule
1290/// says "keep everything".
1291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1292pub struct TraceRetainConfig {
1293    /// Newest session directories to keep, counting the running session itself. `None` means
1294    /// the count is unbounded. Never `Some(0)`: truncating the set to nothing would delete the
1295    /// running session's own trace, so it is refused at parse time.
1296    pub max_sessions: Option<u32>,
1297    /// Age beyond which a session directory is removed, in seconds, measured from the
1298    /// millisecond timestamp inside its own uuid-v7 `ses_` id. `None` means no age limit.
1299    pub max_age_secs: Option<u64>,
1300}
1301
1302/// The `context.retain` block: what bounds this capsule's conversation records.
1303///
1304/// Both keys are optional and ANDed. `max_messages` truncates the front of the record the
1305/// launch opens; `max_age_secs` removes a context directory this capsule owns and has not
1306/// written to inside the window.
1307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1308pub struct ContextRetainConfig {
1309    /// Messages to keep at the tail of a record. `None` means the record grows without bound.
1310    /// Never `Some(0)`: truncating a record to nothing is `mur conversation rm`, not retention.
1311    pub max_messages: Option<u32>,
1312    /// Age beyond which a record this capsule owns is removed, in seconds, measured from the
1313    /// last write to its `conversation.jsonl`. `None` means no age limit.
1314    pub max_age_secs: Option<u64>,
1315}
1316
1317#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1318pub struct EvalConfig {
1319    #[serde(skip_serializing_if = "Option::is_none")]
1320    pub dataset_id: Option<String>,
1321    pub scorers: Vec<ScorerConfig>,
1322}
1323
1324#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1325#[serde(tag = "type", rename_all = "snake_case")]
1326pub enum ScorerConfig {
1327    ExitOk { name: String },
1328    MaxTurns { name: String, max: u32 },
1329    MaxTokens { name: String, max: u64 },
1330    ToolSequence { name: String, expected: Vec<String> },
1331    LlmJudge { name: String },
1332}
1333
1334#[derive(Debug, Clone, PartialEq, Eq)]
1335pub struct NetworkCapabilities {
1336    pub allow: Vec<String>,
1337    /// Whether the capsule's shell subprocess tree may create `AF_UNIX` sockets. `false` unless
1338    /// the manifest says otherwise: a local daemon socket — `/var/run/docker.sock` above all —
1339    /// is an unmediated path to host root, and the `allow` list above only governs IP
1340    /// destinations, so nothing else in this block constrains it. Declaring `true` is the
1341    /// deliberate, auditable widening for a capsule that genuinely needs a local daemon socket;
1342    /// it is capsule-wide and coarse (a domain, not a per-path allowlist). `AF_NETLINK` and
1343    /// `AF_PACKET` have no corresponding key and are always denied.
1344    pub unix_sockets: bool,
1345}
1346
1347#[derive(Debug, Clone, PartialEq, Eq)]
1348pub struct FilesystemCapabilities {
1349    pub scope: Option<String>,
1350    /// Whether anything written *into* the session workdir may be executed. `false` unless the
1351    /// manifest says otherwise, and that default is the whole point: with it, the workdir's own
1352    /// Landlock `PathBeneath` rule withholds the `Execute` right, so the kernel — evaluating the
1353    /// resolved path itself, with no userspace round trip — refuses to exec a binary the capsule
1354    /// produced, whatever it is named. That is what makes `capabilities.shell.allow` a complete
1355    /// and sound statement rather than a name-matching convention.
1356    ///
1357    /// `true` takes the `Execute` right back for compile-and-run workflows (a capsule that
1358    /// `gcc`/`cargo build`s inside its workdir and then runs the result). The cost is not
1359    /// negotiable and is not hidden: anything the capsule writes into the workdir can then run
1360    /// regardless of `shell.allow`, so the allowlist stops being an enforceable property of that
1361    /// capsule. A capsule declaring it can therefore never achieve
1362    /// [`ContainmentClass::Scoped`] — see `capsule_runtime::containment` — and pairing it with
1363    /// `capabilities.containment: scoped` is refused at launch.
1364    pub workdir_exec: bool,
1365    /// Workdir-relative subtrees the capsule may read but must not write, in the same vocabulary
1366    /// [`Self::scope`] uses. Empty when the key is absent, which is the whole workdir writable —
1367    /// the behaviour of every capsule that declares nothing here.
1368    ///
1369    /// Entries are trimmed and empty ones dropped at parse; path *shape* is judged by the
1370    /// runtime (`capsule_runtime::protected_paths`), exactly as `scope`'s shape is.
1371    pub read_only: Vec<String>,
1372}
1373
1374/// The `capabilities.task_io` block on a `runtime: hook` artifact entry — the operator's
1375/// grant of the `murmur:task-io/read` host import to that one hook. Recognized nowhere else:
1376/// on any other role, and in the capsule-wide block, it is rejected at parse time rather than
1377/// accepted and silently inert.
1378#[derive(Debug, Clone, PartialEq, Eq)]
1379pub struct TaskIoCapabilities {
1380    /// Whether this hook may read the in-scope task's input and result text. Never inferred:
1381    /// a `task_io:` block that omits `read:` is rejected, on the same terms as
1382    /// `capabilities.shell.interpreter_runtime[].dirs[].list_dir`.
1383    pub read: bool,
1384}
1385
1386/// The `capabilities.conversation` block on a `runtime: hook` artifact entry — the operator's
1387/// grant of the `murmur:conversation/read` host import to that one hook. Rejected at parse time
1388/// on any other artifact role; accepted but inert in the capsule-wide block, which warns
1389/// `W-SEC-016` at staging.
1390#[derive(Debug, Clone, PartialEq, Eq)]
1391pub struct ConversationCapabilities {
1392    /// Whether this hook may read the capsule's durable conversation record. Never inferred: a
1393    /// `conversation:` block that omits `read:` is rejected, exactly as `task_io:` is.
1394    pub read: bool,
1395}
1396
1397/// The `capabilities.plan` block — the operator's grant of the runtime-provided `submit-plan`
1398/// tool to the capsule's agent loop. Read from the capsule-wide block only: an artifact entry
1399/// declaring it grants nothing, because a plan is submitted by the agent and not by an artifact.
1400#[derive(Debug, Clone, PartialEq, Eq)]
1401pub struct PlanCapabilities {
1402    /// Whether this capsule's model may submit a plan for the runtime to schedule. Never
1403    /// inferred: a `plan:` block that omits `submit:` is rejected, exactly as `task_io:` is.
1404    pub submit: bool,
1405}
1406
1407/// The `capabilities.install` block — the operator's grant to pull artifacts into a running
1408/// session through `murmur:artifact-manager/manage.pull`. Read from the capsule-wide block only.
1409/// Each entry is an exact artifact name, a `<prefix>*` or `*`, as
1410/// [`crate::install_pattern::validate`] accepts it, kept verbatim in declaration order. At least
1411/// one list is non-empty: omitting the block, not declaring an empty one, is how a capsule grants
1412/// nothing.
1413#[derive(Debug, Clone, PartialEq, Eq)]
1414pub struct InstallCapabilities {
1415    /// Entries admitting skill artifacts (`skill.md`, no executable payload).
1416    pub skill: Vec<String>,
1417    /// Entries admitting tool artifacts, WASM or native alike.
1418    pub tool: Vec<String>,
1419}
1420
1421#[derive(Debug, Clone, PartialEq, Eq)]
1422pub struct ShellCapabilities {
1423    pub allow: Vec<String>,
1424    pub strip_env: Option<Vec<String>>,
1425    pub baseline_env: Option<Vec<String>>,
1426    /// Typed grants that widen an already-allowlisted `allow` binary's Landlock filesystem
1427    /// scope to the exact host directories its import machinery needs *outside* the workdir
1428    /// (a path-based interpreter like CPython, whose stdlib the `DT_NEEDED` closure cannot
1429    /// reach). Empty unless the manifest declares `capabilities.shell.interpreter_runtime`.
1430    /// This can only ever name specific directories with an explicit per-directory
1431    /// enumerability flag — it has no field that expands a whole install prefix.
1432    pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1433    /// Typed staged-runtime grants that name a pinned host runtime tree to bind-mount read-only
1434    /// into a `sealed` capsule's composed root. Empty unless the manifest declares
1435    /// `capabilities.shell.staged_runtime`. Mutually exclusive with [`Self::interpreter_runtime`]
1436    /// per binary: staging the tree *into* the root is what makes widening Landlock *out to* the
1437    /// host unnecessary, so declaring both for one binary is a contradiction, not a layering.
1438    pub staged_runtime: Vec<StagedRuntimeGrant>,
1439}
1440
1441/// One `capabilities.shell.interpreter_runtime` entry: an already-allowlisted binary plus the
1442/// exact host directories outside the workdir its import machinery must reach. Declaring one
1443/// couples the capsule to a specific host interpreter-version layout, so it fires `W-SEC-009`
1444/// at staging; it exists only to bridge until the staged runtime bind-mount ships.
1445#[derive(Debug, Clone, PartialEq, Eq)]
1446pub struct InterpreterRuntimeGrant {
1447    /// A binary that MUST already appear in this same block's `allow`. This mechanism can only
1448    /// narrow filesystem access alongside an exec grant that already exists — it never itself
1449    /// grants exec.
1450    pub binary: String,
1451    /// The host directories to grant. Never empty (a grant with no directories is rejected at
1452    /// parse time).
1453    pub dirs: Vec<InterpreterRuntimeDir>,
1454}
1455
1456/// One directory inside an [`InterpreterRuntimeGrant`], with its author-declared enumerability.
1457#[derive(Debug, Clone, PartialEq, Eq)]
1458pub struct InterpreterRuntimeDir {
1459    /// An absolute host path (must start with `/`) outside the workdir.
1460    pub path: String,
1461    /// `true` grants `Execute + ReadFile + ReadDir` — the directory's own entries can be
1462    /// enumerated (what CPython's `FileFinder` needs for a `sys.path` entry). `false` grants
1463    /// `Execute + ReadFile` only — files inside can still be opened by exact name, but the
1464    /// directory itself cannot be listed. Never inferred: the author must write it explicitly.
1465    pub list_dir: bool,
1466}
1467
1468/// One `capabilities.shell.staged_runtime` entry: an already-allowlisted binary, the absolute host
1469/// path of a pinned runtime tree that already exists on the launch host, and the `pin` string
1470/// identifying which build that tree is.
1471///
1472/// The mechanism this describes is the inverse of [`InterpreterRuntimeGrant`]. That one widens a
1473/// capsule's Landlock scope *outwards* so an interpreter can reach its stdlib where the host
1474/// happens to keep it — which couples the capsule to one host's directory layout and is why it
1475/// fires `W-SEC-009`. This one moves the tree *inwards*: the composed root of a `sealed` capsule
1476/// carries the runtime at the same absolute path, bind-mounted read-only, so nothing outside the
1477/// root has to stay reachable. Declaring it therefore requires an effective `sealed` floor — there
1478/// is no composed root to stage into below that — and it is refused alongside an
1479/// `interpreter_runtime` grant for the same binary.
1480///
1481/// `source_path` is deliberately a path to an *already-pinned tree* (a vendored toolchain
1482/// directory, a baked-in conda env), never a bare install prefix guessed at random: the runtime
1483/// does not resolve, discover or version-sniff it.
1484#[derive(Debug, Clone, PartialEq, Eq)]
1485pub struct StagedRuntimeGrant {
1486    /// A binary that MUST already appear in this same block's `allow`. Like
1487    /// [`InterpreterRuntimeGrant::binary`], this mechanism never itself grants exec — it only says
1488    /// where the runtime behind an existing exec grant comes from.
1489    pub binary: String,
1490    /// Absolute host path of the runtime tree to stage. Bind-mounted read-only at this same
1491    /// absolute path inside the composed root, so `sys.prefix`, shebangs and anything a previous
1492    /// turn recorded keep resolving.
1493    pub source_path: String,
1494    /// Non-empty, explicit identifier of *which build* the tree at `source_path` is. Never
1495    /// inferred from the tree's contents: it exists so a human can compare the declared pin across
1496    /// two hosts and confirm the same interpreter build shipped to both. The runtime treats it as
1497    /// an opaque string and does not parse it.
1498    pub pin: String,
1499}
1500
1501#[derive(Debug, Clone, PartialEq, Eq)]
1502pub struct SpawnCapabilities {
1503    pub allow: Vec<String>,
1504}
1505
1506/// Host environment variables a WASM guest may observe, from `capabilities.env`.
1507///
1508/// An empty `allow` is legitimate (a declared-but-empty block is a no-op), so unlike
1509/// `capabilities.shell.allow` this is not rejected at parse time.
1510#[derive(Debug, Clone, PartialEq, Eq, Default)]
1511pub struct EnvCapabilities {
1512    pub allow: Vec<String>,
1513}
1514
1515/// Per-guest execution limits from `capabilities.limits`.
1516///
1517/// Every field is `Option` because this type carries only what the manifest actually
1518/// declared — an omitted field stays `None` here and the runtime substitutes its own
1519/// default (see `capsule_runtime::limits::ExecutionLimits`). Keeping the "absent" state
1520/// distinct from "explicitly set" is what lets the runtime own the defaults in one place
1521/// instead of duplicating them into the manifest parser.
1522#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1523pub struct ResourceLimits {
1524    /// Cap on a guest's linear-memory growth, in bytes.
1525    pub memory_bytes: Option<usize>,
1526    /// Cap on a guest's table growth, in elements.
1527    pub table_elements: Option<usize>,
1528    /// Cap on the number of instances a single store may create.
1529    pub instances: Option<usize>,
1530    /// Wall-clock budget for a single guest invocation, in seconds.
1531    pub deadline_seconds: Option<u64>,
1532}
1533
1534/// Host-process (OS-level) resource bounds from `capabilities.resources`.
1535///
1536/// Distinct from [`ResourceLimits`] (`capabilities.limits`) in both mechanism and subject:
1537/// that block bounds a WASM *guest* inside its wasmtime store, this one bounds every *native
1538/// subprocess* the runtime spawns — `rlimit(2)` ceilings applied before `execve`, a Linux
1539/// cgroup v2 scope around the whole process tree, and a periodic workdir-size check. A capsule
1540/// that cannot escape containment can still wedge its host by forking, allocating, or writing
1541/// without bound; this is the block that stops that.
1542///
1543/// Every field is `Option` for the same reason [`ResourceLimits`]'s are: this type carries only
1544/// what the manifest actually declared, and the runtime substitutes its own default for
1545/// anything omitted (see `capsule_runtime::resources::HostResourceLimits`). An omitted field —
1546/// or an omitted block — means defaults, never "unlimited".
1547#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1548pub struct ResourceCapabilities {
1549    /// `RLIMIT_NPROC` headroom for a spawned subprocess that has no cgroup scope: the uid's task
1550    /// count at that spawn plus this value, counted in threads on Linux and processes on macOS.
1551    /// No effect on Linux when the subprocess tree has a cgroup scope, which `cgroup_pids_max`
1552    /// bounds instead.
1553    pub max_processes: Option<u64>,
1554    /// `RLIMIT_NOFILE` hard ceiling on each spawned subprocess.
1555    pub max_open_files: Option<u64>,
1556    /// `RLIMIT_FSIZE` hard ceiling, in bytes, on each spawned subprocess.
1557    pub max_file_size_bytes: Option<u64>,
1558    /// `RLIMIT_CPU` hard ceiling, in CPU-seconds, on each spawned subprocess.
1559    pub cpu_seconds: Option<u64>,
1560    /// `RLIMIT_AS` (Linux) / `RLIMIT_DATA` (macOS) hard ceiling, in bytes, on each spawned
1561    /// subprocess.
1562    pub memory_bytes: Option<u64>,
1563    /// cgroup v2 `memory.max`, in bytes — aggregate across the whole subprocess tree. Linux only.
1564    pub cgroup_memory_bytes: Option<u64>,
1565    /// cgroup v2 `pids.max` — aggregate across the whole subprocess tree. Linux only.
1566    pub cgroup_pids_max: Option<u64>,
1567    /// cgroup v2 `cpu.max` quota as a percentage of one core (200 = two cores). Linux only.
1568    pub cgroup_cpu_percent: Option<u32>,
1569    /// cgroup v2 `io.max` read+write bytes/sec on the workdir's backing device. Linux only,
1570    /// best-effort (the backing device cannot always be resolved).
1571    pub cgroup_io_bytes_per_sec: Option<u64>,
1572    /// Ceiling on total workdir size, in bytes, enforced by a periodic check on every platform.
1573    pub workdir_max_bytes: Option<u64>,
1574}
1575
1576/// Durable, capsule-scoped state for one tool, driver or hook: a host directory outside every
1577/// session workdir, preopened into the guest as `state/`.
1578///
1579/// Absent is deny — no declaration means no second preopen and no directory anywhere. The store
1580/// is keyed by capsule rather than by workdir precisely so it survives a launch that gets a fresh
1581/// session workdir, and so two capsules launched in the same directory never share one.
1582#[derive(Debug, Clone, PartialEq, Eq, Default)]
1583pub struct StateCapabilities {
1584    /// Directory name under `~/.murmur/state/`. `None` means "use the capsule name", which is
1585    /// what the overwhelming majority of declarations want.
1586    ///
1587    /// Read from the capsule operator's own manifest entry and never from the artifact's bundled
1588    /// manifest, so a registry-pulled tool cannot claim a store that already exists. The *shape*
1589    /// of the name is not checked here — a store name is a runtime concern, refused as
1590    /// `E-CAP-009`, exactly as `capabilities.filesystem.scope`'s shape is refused as `E-CAP-002`.
1591    pub store: Option<String>,
1592}
1593
1594// ── Containment class ─────────────────────────────────────────────────────────
1595
1596/// How strongly the host must contain a capsule's *subprocess* tree, declared as a floor
1597/// by the operator and satisfied (or not) by the host's kernel.
1598///
1599/// Declaration order is enforcement order — the `Ord` derive is load-bearing, so variants
1600/// must stay listed weakest-first. Combining floors from several sources is `max`, never
1601/// `min` (see [`effective_containment_floor`]): no source can lower what another raised.
1602///
1603/// The class an operator *declares* is a requirement. The class a host actually *achieves*
1604/// is derived only from a live kernel probe (`capsule_runtime::containment`), never from
1605/// this declaration.
1606#[derive(
1607    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1608)]
1609#[serde(rename_all = "snake_case")]
1610pub enum ContainmentClass {
1611    /// No kernel mechanism required. Capability declarations are honored by the runtime's
1612    /// own dispatch layer and by convention; anything the host cannot mediate is warned
1613    /// about, not refused. Every host satisfies this, including macOS. The default.
1614    #[default]
1615    Advisory,
1616    /// Landlock filesystem mediation + seccomp exec/network allowlisting over the *host*
1617    /// filesystem: every path outside the workdir must be an explicit manifest grant.
1618    /// Requires Linux 5.13+ with a usable Landlock ABI.
1619    Scoped,
1620    /// A private filesystem root: mount-namespace + `pivot_root` isolation onto a composed
1621    /// root, so the host filesystem is not merely mediated but absent. Achievable on an
1622    /// uncontainerised Linux host with a usable Landlock ABI, unprivileged user namespaces,
1623    /// and — where AppArmor's `restrict_unprivileged_userns` is active — the shipped
1624    /// `mur-sealed` profile loaded. A host that cannot back it refuses the launch with a
1625    /// mechanism-specific reason rather than silently degrading to `Scoped` or `Advisory`.
1626    /// See `docs/content/reference/sealed-containment-manual-verification.md`.
1627    Sealed,
1628}
1629
1630impl ContainmentClass {
1631    /// The lowercase wire name, identical in the manifest, the workspace config, the
1632    /// `--containment` flag, `trace.jsonl`, and `--explain-scope` output.
1633    pub fn as_str(self) -> &'static str {
1634        match self {
1635            Self::Advisory => "advisory",
1636            Self::Scoped => "scoped",
1637            Self::Sealed => "sealed",
1638        }
1639    }
1640
1641    /// Every value, weakest first — the single source for the "must be one of: …" text.
1642    pub const ALL: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1643}
1644
1645impl std::fmt::Display for ContainmentClass {
1646    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1647        f.write_str(self.as_str())
1648    }
1649}
1650
1651/// A string that is not one of the three containment class names.
1652#[derive(Debug, Clone, PartialEq, Eq)]
1653pub struct ParseContainmentClassError {
1654    pub value: String,
1655}
1656
1657impl std::fmt::Display for ParseContainmentClassError {
1658    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1659        write!(
1660            f,
1661            "must be one of: advisory, scoped, sealed; got '{}'",
1662            self.value
1663        )
1664    }
1665}
1666
1667impl std::error::Error for ParseContainmentClassError {}
1668
1669impl std::str::FromStr for ContainmentClass {
1670    type Err = ParseContainmentClassError;
1671
1672    fn from_str(value: &str) -> Result<Self, Self::Err> {
1673        match value.trim() {
1674            "advisory" => Ok(Self::Advisory),
1675            "scoped" => Ok(Self::Scoped),
1676            "sealed" => Ok(Self::Sealed),
1677            other => Err(ParseContainmentClassError {
1678                value: other.to_string(),
1679            }),
1680        }
1681    }
1682}
1683
1684/// The one floor a single `mur run` invocation must clear: the *strongest* class any source
1685/// asked for, defaulting to [`ContainmentClass::Advisory`] when no source declares one.
1686///
1687/// Monotonic by construction — an absent source does not participate, and no present source
1688/// can lower what another raised. Argument order carries no precedence.
1689pub fn effective_containment_floor(
1690    workspace_default: Option<ContainmentClass>,
1691    manifest_declared: Option<ContainmentClass>,
1692    cli_flag: Option<ContainmentClass>,
1693) -> ContainmentClass {
1694    [workspace_default, manifest_declared, cli_flag]
1695        .into_iter()
1696        .flatten()
1697        .max()
1698        .unwrap_or_default()
1699}
1700
1701#[derive(Debug, Clone, PartialEq, Eq)]
1702pub struct Capabilities {
1703    pub network: Option<NetworkCapabilities>,
1704    /// Which peers this capsule may redeem a peer-file handle against. `None` means no
1705    /// `fetch-peer-file` tool exists and nothing can be fetched — absent is deny.
1706    pub peer_fetch: Option<PeerFetchCapabilities>,
1707    pub filesystem: Option<FilesystemCapabilities>,
1708    pub shell: Option<ShellCapabilities>,
1709    pub spawn: Option<SpawnCapabilities>,
1710    pub env: Option<EnvCapabilities>,
1711    pub limits: Option<ResourceLimits>,
1712    pub resources: Option<ResourceCapabilities>,
1713    /// Durable state store for this artifact. `None` — the overwhelmingly common case — is deny:
1714    /// no `state/` preopen, and no directory created anywhere. See [`StateCapabilities`].
1715    pub state: Option<StateCapabilities>,
1716    /// Per-hook grant of the `murmur:task-io/read` host import. Only ever `Some` on a
1717    /// `runtime: hook` artifact entry — see [`TaskIoCapabilities`].
1718    pub task_io: Option<TaskIoCapabilities>,
1719    /// Per-hook grant of the `murmur:conversation/read` host import — see
1720    /// [`ConversationCapabilities`].
1721    pub conversation: Option<ConversationCapabilities>,
1722    /// Capsule-wide grant of the runtime-provided `submit-plan` tool. `None` is deny: the tool's
1723    /// synthetic manifest is not written, so it is absent from the model's inventory entirely.
1724    pub plan: Option<PlanCapabilities>,
1725    /// Capsule-wide grant to pull skills and tools at runtime. `None` is deny: every
1726    /// `manage.pull` is refused. See [`InstallCapabilities`].
1727    pub install: Option<InstallCapabilities>,
1728    /// Minimum containment class this capsule declares. `None` (the overwhelmingly common
1729    /// case) means the capsule states no requirement and inherits whatever the workspace
1730    /// config or `--containment` asks for, defaulting to `advisory`.
1731    pub containment: Option<ContainmentClass>,
1732}
1733
1734#[derive(Debug, Error)]
1735pub enum RuntimeManifestError {
1736    #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
1737    NotFound(String),
1738    #[error("{0}")]
1739    YamlSyntax(String),
1740    #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
1741    MissingField { field: String },
1742    #[error(
1743        "{}: invalid artifact declaration at index {index}: {message}",
1744        MANIFEST_FILENAME
1745    )]
1746    InvalidArtifact { index: usize, message: String },
1747    #[error(
1748        "{}: invalid inference config for '{field}': {message}",
1749        MANIFEST_FILENAME
1750    )]
1751    InvalidInferenceConfig { field: String, message: String },
1752    #[error(
1753        "{}: invalid capability config for '{field}': {message}",
1754        MANIFEST_FILENAME
1755    )]
1756    InvalidCapabilities { field: String, message: String },
1757    #[error(
1758        "{}: invalid exports config for '{field}': {message}",
1759        MANIFEST_FILENAME
1760    )]
1761    InvalidExports { field: String, message: String },
1762    #[error(
1763        "{}: invalid control config for '{field}': {message}",
1764        MANIFEST_FILENAME
1765    )]
1766    InvalidControl { field: String, message: String },
1767    #[error(
1768        "{}: invalid network config for '{field}': {message}",
1769        MANIFEST_FILENAME
1770    )]
1771    InvalidNetworkConfig { field: String, message: String },
1772    #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
1773    InvalidTraceConfig { field: String, message: String },
1774    /// A `gateway:` names an upstream but neither binds a non-blank `api_key` nor declares
1775    /// `keyless: true`. Decided on what the manifest wrote, so it fires identically with and
1776    /// without secret resolution.
1777    #[error(
1778        "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
1779         gateway.endpoint '{endpoint}' but binds no credential",
1780        MANIFEST_FILENAME
1781    )]
1782    GatewayWithoutCredential {
1783        index: usize,
1784        name: String,
1785        endpoint: String,
1786    },
1787    #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
1788    Io {
1789        path: String,
1790        #[source]
1791        source: std::io::Error,
1792    },
1793}
1794
1795#[derive(Debug, Deserialize)]
1796struct RawRuntimeManifest {
1797    name: Option<String>,
1798    version: Option<String>,
1799    #[serde(default)]
1800    artifacts: Vec<RawArtifact>,
1801    #[serde(default)]
1802    capabilities: Option<RawCapabilities>,
1803    #[serde(default)]
1804    inference: Option<RawInferenceConfig>,
1805    #[serde(default)]
1806    context: Option<RawContextConfig>,
1807    #[serde(default)]
1808    observability: Option<RawObservabilityConfig>,
1809    #[serde(default)]
1810    trace: Option<RawTraceConfig>,
1811    #[serde(default)]
1812    network: Option<RawNetworkConfig>,
1813    #[serde(default)]
1814    lifecycle: Option<RawLifecycleConfig>,
1815    #[serde(default)]
1816    exports: Option<RawExports>,
1817    #[serde(default)]
1818    control: Option<RawControl>,
1819    #[serde(default)]
1820    mur_version: Option<String>,
1821    /// Captured only to refuse it. `config:` is delivered to one artifact through that artifact's
1822    /// own grant, so a capsule-wide block reaches nothing; without this field it would be one of
1823    /// the unrecognized top-level keys this manifest silently ignores.
1824    #[serde(default, deserialize_with = "deserialize_present")]
1825    config: Option<serde_yaml::Value>,
1826    /// Read by `mur build` and `mur publish` off this same file to select the packaging type.
1827    /// Declared here so a manifest that is both runnable and publishable does not report its own
1828    /// build-side keys as unrecognized; the run path takes no meaning from it and never inspects
1829    /// the value, so any shape parses.
1830    #[serde(default)]
1831    #[allow(dead_code)]
1832    runtime: Option<serde_yaml::Value>,
1833    /// Read by `mur build` and `mur publish`: it separates a native tool from a wasm one, both in
1834    /// the derived packaging type and in `mur publish`'s platform tagging. Ignored on the run
1835    /// path, and untyped for the same reason as `runtime:` above.
1836    #[serde(default)]
1837    #[allow(dead_code)]
1838    implementation: Option<serde_yaml::Value>,
1839    /// Read by `mur build` and `mur publish` as the declared packaging type, which overrides the
1840    /// one derived from `runtime:` and `implementation:`. Ignored on the run path, and untyped for
1841    /// the same reason as `runtime:` above.
1842    #[serde(default)]
1843    #[allow(dead_code)]
1844    execution: Option<serde_yaml::Value>,
1845    /// Read by `mur build` as the list of companion files to pack beside the manifest. Ignored on
1846    /// the run path, and untyped for the same reason as `runtime:` above.
1847    #[serde(default)]
1848    #[allow(dead_code)]
1849    requires_files: Option<serde_yaml::Value>,
1850    #[serde(flatten)]
1851    unknown: UnknownKeys,
1852}
1853
1854#[derive(Debug, Deserialize)]
1855struct RawControl {
1856    /// Untyped, so a value that is not a list of strings is refused as an
1857    /// [`RuntimeManifestError::InvalidControl`] naming the field rather than as a serde type error
1858    /// naming a line number.
1859    #[serde(default)]
1860    settings: Option<serde_yaml::Value>,
1861    #[serde(default)]
1862    secrets: Option<serde_yaml::Value>,
1863    #[serde(default)]
1864    agent_settings: Option<serde_yaml::Value>,
1865    #[serde(flatten)]
1866    unknown: UnknownKeys,
1867}
1868
1869#[derive(Debug, Deserialize)]
1870struct RawExports {
1871    #[serde(default)]
1872    files: Option<RawFileExport>,
1873    #[serde(default)]
1874    peer_files: Option<RawPeerFilesExport>,
1875    #[serde(default)]
1876    peer_tasks: Option<RawPeerTasksExport>,
1877    #[serde(flatten)]
1878    unknown: UnknownKeys,
1879}
1880
1881#[derive(Debug, Deserialize)]
1882struct RawPeerTasksExport {
1883    // `Option` so an omitted `accept` is distinguishable from an explicit `false`: the omission
1884    // is refused rather than defaulted, because consent is never inferred.
1885    #[serde(default)]
1886    accept: Option<bool>,
1887    #[serde(flatten)]
1888    unknown: UnknownKeys,
1889}
1890
1891#[derive(Debug, Deserialize)]
1892struct RawPeerFilesExport {
1893    #[serde(default)]
1894    root: Option<String>,
1895    /// Untyped for the same reason as [`RawFileExport::max_bytes`]: `30m` is a YAML string and a
1896    /// bare `1800` is a YAML integer, and a wrong-shaped value has to reach the parser to be
1897    /// reported as an [`RuntimeManifestError::InvalidExports`] naming the field.
1898    #[serde(default)]
1899    max_ttl: Option<serde_yaml::Value>,
1900    #[serde(default)]
1901    max_bytes: Option<serde_yaml::Value>,
1902    #[serde(flatten)]
1903    unknown: UnknownKeys,
1904}
1905
1906#[derive(Debug, Deserialize)]
1907struct RawFileExport {
1908    #[serde(default)]
1909    root: Option<String>,
1910    #[serde(default)]
1911    mode: Option<String>,
1912    /// Deliberately untyped: `max_bytes` accepts both a bare integer and a suffixed string, and
1913    /// a wrong-shaped value has to reach [`parse_exports`] to be reported as an
1914    /// [`RuntimeManifestError::InvalidExports`] naming the field rather than as a serde type
1915    /// error naming a line number.
1916    #[serde(default)]
1917    max_bytes: Option<serde_yaml::Value>,
1918    #[serde(flatten)]
1919    unknown: UnknownKeys,
1920}
1921
1922#[derive(Debug, Deserialize)]
1923struct RawLifecycleConfig {
1924    #[serde(default)]
1925    task_acceptance: Option<TaskAcceptance>,
1926    #[serde(default)]
1927    after_task: Option<AfterTask>,
1928    #[serde(default)]
1929    queue_depth: Option<usize>,
1930    #[serde(default)]
1931    input_timeout_secs: Option<u64>,
1932    #[serde(default)]
1933    conversation: Option<ConversationMode>,
1934    #[serde(default)]
1935    max_task_reopens: Option<u32>,
1936    #[serde(default)]
1937    shell_grace_secs: Option<u64>,
1938    #[serde(default)]
1939    delegation_deadline_secs: Option<u64>,
1940    #[serde(flatten)]
1941    unknown: UnknownKeys,
1942}
1943
1944#[derive(Debug, Deserialize)]
1945struct RawNetworkConfig {
1946    internal_port: Option<u16>,
1947    #[serde(default)]
1948    authentication: Option<RawNetworkAuthentication>,
1949    #[serde(flatten)]
1950    unknown: UnknownKeys,
1951}
1952
1953#[derive(Debug, Deserialize)]
1954struct RawNetworkAuthentication {
1955    #[serde(default)]
1956    scheme: Option<String>,
1957    /// Keyed by credential name. A `BTreeMap`, so the lowered credentials come out sorted by name
1958    /// whatever order the manifest wrote them in.
1959    #[serde(default)]
1960    credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
1961    #[serde(flatten)]
1962    unknown: UnknownKeys,
1963}
1964
1965#[derive(Debug, Deserialize)]
1966struct RawDoorCredential {
1967    /// Untyped, so a value that is not a list of strings is refused as an
1968    /// [`RuntimeManifestError::InvalidNetworkConfig`] naming the field rather than as a serde type
1969    /// error naming a line number.
1970    #[serde(default)]
1971    scopes: Option<serde_yaml::Value>,
1972    #[serde(flatten)]
1973    unknown: UnknownKeys,
1974}
1975
1976#[derive(Debug, Deserialize)]
1977struct RawContextConfig {
1978    max_tokens: Option<u32>,
1979    /// `on` | `off`. Kept as a raw `String` so an unrecognized value reports as an invalid
1980    /// value for this key rather than as a serde variant error against the whole block.
1981    #[serde(default)]
1982    record: Option<String>,
1983    #[serde(default)]
1984    record_store: Option<String>,
1985    #[serde(default)]
1986    seed_budget: Option<f32>,
1987    #[serde(default)]
1988    seed_overflow_margin: Option<f32>,
1989    /// Untyped so an empty block, a null block and an unknown key inside it are each reported
1990    /// as an invalid `context.retain` rather than as a serde message about a struct field.
1991    #[serde(default, deserialize_with = "present_yaml_value")]
1992    retain: Option<serde_yaml::Value>,
1993    #[serde(flatten)]
1994    unknown: UnknownKeys,
1995}
1996
1997#[derive(Debug, Deserialize)]
1998struct RawObservabilityConfig {
1999    #[serde(default)]
2000    otel_endpoint: Option<String>,
2001    #[serde(default)]
2002    eval: Option<RawEvalConfig>,
2003    #[serde(flatten)]
2004    unknown: UnknownKeys,
2005}
2006
2007#[derive(Debug, Deserialize)]
2008struct RawTraceConfig {
2009    /// Untyped so an unparseable mode reaches [`resolve_trace_capture`] and is reported as a
2010    /// [`RuntimeManifestError::InvalidTraceConfig`] naming the field and the accepted values,
2011    /// rather than as a serde message about an unknown variant.
2012    #[serde(default)]
2013    capture: Option<String>,
2014    /// Untyped for the same reason as `context.retain` — see [`RawContextConfig::retain`].
2015    #[serde(default, deserialize_with = "present_yaml_value")]
2016    retain: Option<serde_yaml::Value>,
2017    #[serde(flatten)]
2018    unknown: UnknownKeys,
2019}
2020
2021#[derive(Debug, Deserialize)]
2022struct RawEvalConfig {
2023    #[serde(default)]
2024    dataset_id: Option<String>,
2025    #[serde(default)]
2026    scorers: Vec<RawScorerConfig>,
2027    #[serde(flatten)]
2028    unknown: UnknownKeys,
2029}
2030
2031#[derive(Debug, Deserialize)]
2032struct RawScorerConfig {
2033    #[serde(rename = "type")]
2034    scorer_type: String,
2035    name: Option<String>,
2036    max: Option<u64>,
2037    expected: Option<Vec<String>>,
2038    #[serde(flatten)]
2039    unknown: UnknownKeys,
2040}
2041
2042#[derive(Debug, Deserialize)]
2043struct RawArtifact {
2044    name: Option<String>,
2045    version: Option<String>,
2046    runtime: Option<String>,
2047    #[serde(default)]
2048    source: Option<String>,
2049    #[serde(default)]
2050    local_source: Option<bool>,
2051    #[serde(default)]
2052    prompt_payload: Option<bool>,
2053    #[serde(default)]
2054    capabilities: Option<RawCapabilities>,
2055    #[serde(default)]
2056    on_overflow: Option<String>,
2057    /// Untyped, and deserialized through [`deserialize_present`] rather than plain `Option`, so
2058    /// `config:` written with nothing under it arrives as `Some(Value::Null)` instead of
2059    /// collapsing into the absent case. The two mean different things: absent grants no variable,
2060    /// while an empty declaration is a written statement that carries nothing and is refused.
2061    #[serde(default, deserialize_with = "deserialize_present")]
2062    config: Option<serde_yaml::Value>,
2063    /// `Some(None)` is `gateway:` written with nothing under it, refused as a block with no
2064    /// endpoint rather than collapsed into the absent case.
2065    #[serde(default, deserialize_with = "deserialize_present")]
2066    gateway: Option<Option<RawArtifactGateway>>,
2067    #[serde(flatten)]
2068    unknown: UnknownKeys,
2069}
2070
2071#[derive(Debug, Deserialize)]
2072struct RawArtifactGateway {
2073    #[serde(default)]
2074    endpoint: Option<String>,
2075    /// `Some(None)` is `api_key:` written as YAML null: present for the contradiction with
2076    /// `keyless: true`, and no credential for the missing-credential refusal.
2077    #[serde(default, deserialize_with = "deserialize_present")]
2078    api_key: Option<Option<String>>,
2079    /// Untyped, so a non-boolean is refused as an invalid declaration naming the field.
2080    #[serde(default)]
2081    keyless: Option<serde_yaml::Value>,
2082    #[serde(flatten)]
2083    unknown: UnknownKeys,
2084}
2085
2086/// Deserialize a present key into `Some(value)` even when its value is YAML null, leaving `None`
2087/// to mean the key was absent and `#[serde(default)]` supplied it.
2088///
2089/// serde's own `Option` impl maps a null value to `None`, which erases exactly the distinction
2090/// `config:` rests on.
2091fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
2092where
2093    T: Deserialize<'de>,
2094    D: serde::Deserializer<'de>,
2095{
2096    T::deserialize(deserializer).map(Some)
2097}
2098
2099#[derive(Debug, Deserialize)]
2100struct RawCapabilities {
2101    #[serde(default)]
2102    network: Option<RawNetworkCapabilities>,
2103    #[serde(default)]
2104    peer_fetch: Option<RawPeerFetchCapabilities>,
2105    #[serde(default)]
2106    filesystem: Option<RawFilesystemCapabilities>,
2107    #[serde(default)]
2108    shell: Option<RawShellCapabilities>,
2109    #[serde(default)]
2110    spawn: Option<RawSpawnCapabilities>,
2111    #[serde(default)]
2112    env: Option<RawEnvCapabilities>,
2113    #[serde(default)]
2114    limits: Option<RawResourceLimits>,
2115    #[serde(default)]
2116    resources: Option<RawResourceCapabilities>,
2117    #[serde(default)]
2118    state: Option<RawStateCapabilities>,
2119    #[serde(default)]
2120    task_io: Option<RawTaskIoCapabilities>,
2121    #[serde(default)]
2122    conversation: Option<RawConversationCapabilities>,
2123    #[serde(default)]
2124    plan: Option<RawPlanCapabilities>,
2125    #[serde(default)]
2126    install: Option<RawInstallCapabilities>,
2127    /// Kept as a raw `String` rather than a `ContainmentClass` so a typo reports through
2128    /// `InvalidCapabilities` like every other bad capability value, instead of a bare serde
2129    /// "unknown variant" error attributed to the whole `capabilities:` block.
2130    #[serde(default)]
2131    containment: Option<String>,
2132    #[serde(flatten)]
2133    unknown: UnknownKeys,
2134}
2135
2136#[derive(Debug, Deserialize)]
2137struct RawTaskIoCapabilities {
2138    // `Option` so an omitted `read` is distinguishable from an explicit `false`: the omission
2139    // is rejected outright rather than defaulted, because a capability is never inferred.
2140    #[serde(default)]
2141    read: Option<bool>,
2142    #[serde(flatten)]
2143    unknown: UnknownKeys,
2144}
2145
2146#[derive(Debug, Deserialize)]
2147struct RawConversationCapabilities {
2148    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2149    // than defaulted.
2150    #[serde(default)]
2151    read: Option<bool>,
2152    #[serde(flatten)]
2153    unknown: UnknownKeys,
2154}
2155
2156#[derive(Debug, Deserialize)]
2157struct RawPlanCapabilities {
2158    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2159    // than defaulted.
2160    #[serde(default)]
2161    submit: Option<bool>,
2162    #[serde(flatten)]
2163    unknown: UnknownKeys,
2164}
2165
2166#[derive(Debug, Deserialize)]
2167struct RawInstallCapabilities {
2168    #[serde(default)]
2169    skill: Vec<String>,
2170    #[serde(default)]
2171    tool: Vec<String>,
2172    #[serde(flatten)]
2173    unknown: UnknownKeys,
2174}
2175
2176#[derive(Debug, Deserialize)]
2177struct RawSpawnCapabilities {
2178    #[serde(default)]
2179    allow: Vec<String>,
2180    #[serde(flatten)]
2181    unknown: UnknownKeys,
2182}
2183
2184#[derive(Debug, Deserialize)]
2185struct RawEnvCapabilities {
2186    #[serde(default)]
2187    allow: Vec<String>,
2188    #[serde(flatten)]
2189    unknown: UnknownKeys,
2190}
2191
2192#[derive(Debug, Deserialize)]
2193struct RawResourceLimits {
2194    #[serde(default)]
2195    memory_bytes: Option<usize>,
2196    #[serde(default)]
2197    table_elements: Option<usize>,
2198    #[serde(default)]
2199    instances: Option<usize>,
2200    #[serde(default)]
2201    deadline_seconds: Option<u64>,
2202    #[serde(flatten)]
2203    unknown: UnknownKeys,
2204}
2205
2206#[derive(Debug, Deserialize)]
2207struct RawStateCapabilities {
2208    #[serde(default)]
2209    store: Option<String>,
2210    #[serde(flatten)]
2211    unknown: UnknownKeys,
2212}
2213
2214#[derive(Debug, Deserialize)]
2215struct RawResourceCapabilities {
2216    #[serde(default)]
2217    max_processes: Option<u64>,
2218    #[serde(default)]
2219    max_open_files: Option<u64>,
2220    #[serde(default)]
2221    max_file_size_bytes: Option<u64>,
2222    #[serde(default)]
2223    cpu_seconds: Option<u64>,
2224    #[serde(default)]
2225    memory_bytes: Option<u64>,
2226    #[serde(default)]
2227    cgroup_memory_bytes: Option<u64>,
2228    #[serde(default)]
2229    cgroup_pids_max: Option<u64>,
2230    #[serde(default)]
2231    cgroup_cpu_percent: Option<u32>,
2232    #[serde(default)]
2233    cgroup_io_bytes_per_sec: Option<u64>,
2234    #[serde(default)]
2235    workdir_max_bytes: Option<u64>,
2236    #[serde(flatten)]
2237    unknown: UnknownKeys,
2238}
2239
2240#[derive(Debug, Deserialize)]
2241struct RawNetworkCapabilities {
2242    #[serde(default)]
2243    allow: Vec<String>,
2244    #[serde(default)]
2245    unix_sockets: bool,
2246    #[serde(flatten)]
2247    unknown: UnknownKeys,
2248}
2249
2250#[derive(Debug, Deserialize)]
2251struct RawPeerFetchCapabilities {
2252    #[serde(default)]
2253    allow: Vec<String>,
2254    #[serde(flatten)]
2255    unknown: UnknownKeys,
2256}
2257
2258#[derive(Debug, Deserialize)]
2259struct RawFilesystemCapabilities {
2260    #[serde(default)]
2261    scope: Option<String>,
2262    /// Same shape and defaulting convention as `RawNetworkCapabilities::unix_sockets`: a plain
2263    /// `bool` that is always present after parsing and defaults to `false`, never an `Option`.
2264    /// "The key is absent" and "the key says `false`" are the same declaration.
2265    #[serde(default)]
2266    workdir_exec: bool,
2267    /// Always a list after parsing, never an `Option`: an absent key and an empty list are the
2268    /// same declaration — nothing is protected.
2269    #[serde(default)]
2270    read_only: Vec<String>,
2271    #[serde(flatten)]
2272    unknown: UnknownKeys,
2273}
2274
2275#[derive(Debug, Deserialize)]
2276struct RawShellCapabilities {
2277    #[serde(default)]
2278    allow: Vec<String>,
2279    #[serde(default)]
2280    strip_env: Option<Vec<String>>,
2281    #[serde(default)]
2282    baseline_env: Option<Vec<String>>,
2283    #[serde(default)]
2284    interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
2285    #[serde(default)]
2286    staged_runtime: Vec<RawStagedRuntimeGrant>,
2287    #[serde(flatten)]
2288    unknown: UnknownKeys,
2289}
2290
2291#[derive(Debug, Deserialize)]
2292struct RawInterpreterRuntimeGrant {
2293    #[serde(default)]
2294    binary: Option<String>,
2295    #[serde(default)]
2296    dirs: Vec<RawInterpreterRuntimeDir>,
2297    #[serde(flatten)]
2298    unknown: UnknownKeys,
2299}
2300
2301#[derive(Debug, Deserialize)]
2302struct RawInterpreterRuntimeDir {
2303    #[serde(default)]
2304    path: Option<String>,
2305    // `Option` so an omitted `list_dir` is distinguishable from an explicit `false`: the
2306    // omission is rejected outright rather than defaulted, because enumerability is never
2307    // inferred.
2308    #[serde(default)]
2309    list_dir: Option<bool>,
2310    #[serde(flatten)]
2311    unknown: UnknownKeys,
2312}
2313
2314#[derive(Debug, Deserialize)]
2315struct RawStagedRuntimeGrant {
2316    #[serde(default)]
2317    binary: Option<String>,
2318    #[serde(default)]
2319    source_path: Option<String>,
2320    // `Option` so an omitted `pin` reaches `parse_staged_runtime` and is rejected there with a
2321    // field-naming message, rather than defaulting to an empty string that would silently mean
2322    // "unpinned" — the one thing this field exists to make impossible.
2323    #[serde(default)]
2324    pin: Option<String>,
2325    #[serde(flatten)]
2326    unknown: UnknownKeys,
2327}
2328
2329#[derive(Debug, Deserialize)]
2330struct RawInferenceConfig {
2331    transport: Option<String>,
2332    /// Removed: the driver's upstream is `gateway.endpoint` on its artifact entry. Deserialized
2333    /// only so `parse_inference` can refuse it by name, naming where it moved.
2334    #[serde(default, deserialize_with = "deserialize_present")]
2335    endpoint: Option<serde_yaml::Value>,
2336    model: Option<String>,
2337    /// Removed: the driver's key is `gateway.api_key` on its artifact entry. Deserialized only so
2338    /// `parse_inference` can refuse it by name, naming where it moved.
2339    #[serde(default, deserialize_with = "deserialize_present")]
2340    api_key: Option<serde_yaml::Value>,
2341    #[serde(default)]
2342    command: Option<String>,
2343    #[serde(default)]
2344    system_prompt: Option<String>,
2345    #[serde(default)]
2346    system_prompt_file: Option<String>,
2347    #[serde(default)]
2348    system_prompt_artifact: Option<String>,
2349    #[serde(default)]
2350    driver: Option<RawInferenceDriver>,
2351    #[serde(default)]
2352    compaction: Option<RawCompactionConfig>,
2353    #[serde(default)]
2354    max_turns: Option<u32>,
2355    /// The `inference` spelling of the reopen budget is not a live field. It is deserialized
2356    /// only so `parse_inference` can refuse it; without it serde would ignore the key.
2357    #[serde(default)]
2358    max_task_reopens: Option<u32>,
2359    #[serde(default)]
2360    max_tokens: Option<u32>,
2361    #[serde(default)]
2362    max_session_tokens: Option<u64>,
2363    #[serde(default)]
2364    tool_refresh: Option<String>,
2365    #[serde(default)]
2366    alternates: Option<Vec<RawInferenceAlternate>>,
2367    #[serde(flatten)]
2368    unknown: UnknownKeys,
2369}
2370
2371#[derive(Debug, Deserialize)]
2372struct RawInferenceAlternate {
2373    #[serde(default)]
2374    name: Option<String>,
2375    #[serde(default)]
2376    model: Option<String>,
2377    #[serde(default)]
2378    driver: Option<RawInferenceDriver>,
2379    #[serde(flatten)]
2380    unknown: UnknownKeys,
2381}
2382
2383#[derive(Debug, Deserialize)]
2384struct RawCompactionConfig {
2385    threshold: Option<f32>,
2386    model: Option<String>,
2387    system_prompt: Option<String>,
2388    system_prompt_file: Option<String>,
2389    dump_summaries: Option<bool>,
2390    #[serde(flatten)]
2391    unknown: UnknownKeys,
2392}
2393
2394#[derive(Debug, Deserialize)]
2395struct RawInferenceDriver {
2396    artifact: Option<String>,
2397    #[serde(default)]
2398    config: Option<serde_yaml::Value>,
2399    #[serde(flatten)]
2400    unknown: UnknownKeys,
2401}
2402
2403/// The keys one `Raw*` block carried that no field of it claims.
2404///
2405/// A `BTreeMap` so the capture order is the manifest's key order sorted, which keeps the
2406/// `W-SEC-019` lines for one manifest identical on every run. The values are discarded: a key this
2407/// build does not recognize is reported by name and by containing block, and printing whatever an
2408/// operator wrote under it would put manifest contents into a diagnostic that has no use for them.
2409type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2410
2411/// A `Raw*` deserialization block that captures the keys this build does not recognize.
2412///
2413/// Implemented for every `Raw*` struct in this file, which is asserted structurally by
2414/// `every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names` rather than left to
2415/// review: a manifest key added without its block learning about it is exactly the defect
2416/// `W-SEC-019` exists to report.
2417trait RawBlock {
2418    /// Every key serde accepts on this block, in declaration order and under the name serde
2419    /// matches (so a `#[serde(rename)]` field appears under its renamed spelling). This is the
2420    /// candidate set [`crate::unknown_manifest_keys::nearest_known_key`] suggests from.
2421    const KNOWN_KEYS: &'static [&'static str];
2422
2423    fn unknown_keys(&self) -> &UnknownKeys;
2424
2425    /// Descends into the blocks this one owns, in declaration order, so the reported paths read
2426    /// down the manifest the way the document does.
2427    ///
2428    /// Defaulted to a no-op, which is correct for a block that owns no other block. A block that
2429    /// does own one must override this: `every_raw_struct_descends_into_the_blocks_it_owns` reads
2430    /// this file's own text and fails, naming the struct and the field, if it does not — an
2431    /// unwalked block reports none of its keys, silently.
2432    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2433        let _ = (path, out);
2434    }
2435}
2436
2437impl RawBlock for RawRuntimeManifest {
2438    const KNOWN_KEYS: &'static [&'static str] = &[
2439        "name",
2440        "version",
2441        "artifacts",
2442        "capabilities",
2443        "inference",
2444        "context",
2445        "observability",
2446        "trace",
2447        "network",
2448        "lifecycle",
2449        "exports",
2450        "control",
2451        "mur_version",
2452        "config",
2453        "runtime",
2454        "implementation",
2455        "execution",
2456        "requires_files",
2457    ];
2458    fn unknown_keys(&self) -> &UnknownKeys {
2459        &self.unknown
2460    }
2461
2462    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2463        let artifacts = child_path(path, "artifacts");
2464        for (index, artifact) in self.artifacts.iter().enumerate() {
2465            collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2466        }
2467        if let Some(capabilities) = &self.capabilities {
2468            collect_block(capabilities, &child_path(path, "capabilities"), out);
2469        }
2470        if let Some(inference) = &self.inference {
2471            collect_block(inference, &child_path(path, "inference"), out);
2472        }
2473        if let Some(context) = &self.context {
2474            collect_block(context, &child_path(path, "context"), out);
2475        }
2476        if let Some(observability) = &self.observability {
2477            collect_block(observability, &child_path(path, "observability"), out);
2478        }
2479        if let Some(trace) = &self.trace {
2480            collect_block(trace, &child_path(path, "trace"), out);
2481        }
2482        if let Some(network) = &self.network {
2483            collect_block(network, &child_path(path, "network"), out);
2484        }
2485        if let Some(lifecycle) = &self.lifecycle {
2486            collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2487        }
2488        if let Some(exports) = &self.exports {
2489            collect_block(exports, &child_path(path, "exports"), out);
2490        }
2491        if let Some(control) = &self.control {
2492            collect_block(control, &child_path(path, "control"), out);
2493        }
2494    }
2495}
2496
2497impl RawBlock for RawControl {
2498    const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
2499    fn unknown_keys(&self) -> &UnknownKeys {
2500        &self.unknown
2501    }
2502}
2503
2504impl RawBlock for RawExports {
2505    const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files", "peer_tasks"];
2506    fn unknown_keys(&self) -> &UnknownKeys {
2507        &self.unknown
2508    }
2509
2510    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2511        if let Some(files) = &self.files {
2512            collect_block(files, &child_path(path, "files"), out);
2513        }
2514        if let Some(peer_files) = &self.peer_files {
2515            collect_block(peer_files, &child_path(path, "peer_files"), out);
2516        }
2517        if let Some(peer_tasks) = &self.peer_tasks {
2518            collect_block(peer_tasks, &child_path(path, "peer_tasks"), out);
2519        }
2520    }
2521}
2522
2523impl RawBlock for RawPeerTasksExport {
2524    const KNOWN_KEYS: &'static [&'static str] = &["accept"];
2525    fn unknown_keys(&self) -> &UnknownKeys {
2526        &self.unknown
2527    }
2528}
2529
2530impl RawBlock for RawPeerFilesExport {
2531    const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2532    fn unknown_keys(&self) -> &UnknownKeys {
2533        &self.unknown
2534    }
2535}
2536
2537impl RawBlock for RawFileExport {
2538    const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2539    fn unknown_keys(&self) -> &UnknownKeys {
2540        &self.unknown
2541    }
2542}
2543
2544impl RawBlock for RawLifecycleConfig {
2545    const KNOWN_KEYS: &'static [&'static str] = &[
2546        "task_acceptance",
2547        "after_task",
2548        "queue_depth",
2549        "input_timeout_secs",
2550        "conversation",
2551        "max_task_reopens",
2552        "shell_grace_secs",
2553        "delegation_deadline_secs",
2554    ];
2555    fn unknown_keys(&self) -> &UnknownKeys {
2556        &self.unknown
2557    }
2558}
2559
2560impl RawBlock for RawNetworkConfig {
2561    const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
2562    fn unknown_keys(&self) -> &UnknownKeys {
2563        &self.unknown
2564    }
2565
2566    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2567        if let Some(authentication) = &self.authentication {
2568            collect_block(authentication, &child_path(path, "authentication"), out);
2569        }
2570    }
2571}
2572
2573impl RawBlock for RawNetworkAuthentication {
2574    const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
2575    fn unknown_keys(&self) -> &UnknownKeys {
2576        &self.unknown
2577    }
2578
2579    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2580        let credentials_path = child_path(path, "credentials");
2581        for (name, credential) in self.credentials.iter().flatten() {
2582            collect_block(credential, &child_path(&credentials_path, name), out);
2583        }
2584    }
2585}
2586
2587impl RawBlock for RawDoorCredential {
2588    const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
2589    fn unknown_keys(&self) -> &UnknownKeys {
2590        &self.unknown
2591    }
2592}
2593
2594impl RawBlock for RawContextConfig {
2595    const KNOWN_KEYS: &'static [&'static str] = &[
2596        "max_tokens",
2597        "record",
2598        "record_store",
2599        "seed_budget",
2600        "seed_overflow_margin",
2601        "retain",
2602    ];
2603    fn unknown_keys(&self) -> &UnknownKeys {
2604        &self.unknown
2605    }
2606}
2607
2608impl RawBlock for RawObservabilityConfig {
2609    const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
2610    fn unknown_keys(&self) -> &UnknownKeys {
2611        &self.unknown
2612    }
2613
2614    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2615        if let Some(eval) = &self.eval {
2616            collect_block(eval, &child_path(path, "eval"), out);
2617        }
2618    }
2619}
2620
2621impl RawBlock for RawTraceConfig {
2622    const KNOWN_KEYS: &'static [&'static str] = &["capture", "retain"];
2623    fn unknown_keys(&self) -> &UnknownKeys {
2624        &self.unknown
2625    }
2626}
2627
2628impl RawBlock for RawEvalConfig {
2629    const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
2630    fn unknown_keys(&self) -> &UnknownKeys {
2631        &self.unknown
2632    }
2633
2634    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2635        let scorers = child_path(path, "scorers");
2636        for (index, scorer) in self.scorers.iter().enumerate() {
2637            collect_block(scorer, &format!("{scorers}[{index}]"), out);
2638        }
2639    }
2640}
2641
2642impl RawBlock for RawScorerConfig {
2643    const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
2644    fn unknown_keys(&self) -> &UnknownKeys {
2645        &self.unknown
2646    }
2647}
2648
2649impl RawBlock for RawArtifact {
2650    const KNOWN_KEYS: &'static [&'static str] = &[
2651        "name",
2652        "version",
2653        "runtime",
2654        "source",
2655        "local_source",
2656        "prompt_payload",
2657        "capabilities",
2658        "on_overflow",
2659        "config",
2660        "gateway",
2661    ];
2662    fn unknown_keys(&self) -> &UnknownKeys {
2663        &self.unknown
2664    }
2665
2666    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2667        if let Some(capabilities) = &self.capabilities {
2668            collect_block(capabilities, &child_path(path, "capabilities"), out);
2669        }
2670        if let Some(Some(gateway)) = &self.gateway {
2671            collect_block(gateway, &child_path(path, "gateway"), out);
2672        }
2673    }
2674}
2675
2676impl RawBlock for RawArtifactGateway {
2677    const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
2678    fn unknown_keys(&self) -> &UnknownKeys {
2679        &self.unknown
2680    }
2681}
2682
2683impl RawBlock for RawCapabilities {
2684    const KNOWN_KEYS: &'static [&'static str] = &[
2685        "network",
2686        "peer_fetch",
2687        "filesystem",
2688        "shell",
2689        "spawn",
2690        "env",
2691        "limits",
2692        "resources",
2693        "state",
2694        "task_io",
2695        "conversation",
2696        "plan",
2697        "install",
2698        "containment",
2699    ];
2700    fn unknown_keys(&self) -> &UnknownKeys {
2701        &self.unknown
2702    }
2703
2704    /// Reached from both [`RawRuntimeManifest`] and [`RawArtifact`], so the capsule-wide block and
2705    /// a per-artifact one report through identical code and differ only in the `path` they are
2706    /// handed — `capabilities.shell` against `artifacts[0].capabilities.shell`.
2707    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2708        if let Some(network) = &self.network {
2709            collect_block(network, &child_path(path, "network"), out);
2710        }
2711        if let Some(peer_fetch) = &self.peer_fetch {
2712            collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
2713        }
2714        if let Some(filesystem) = &self.filesystem {
2715            collect_block(filesystem, &child_path(path, "filesystem"), out);
2716        }
2717        if let Some(shell) = &self.shell {
2718            collect_block(shell, &child_path(path, "shell"), out);
2719        }
2720        if let Some(spawn) = &self.spawn {
2721            collect_block(spawn, &child_path(path, "spawn"), out);
2722        }
2723        if let Some(env) = &self.env {
2724            collect_block(env, &child_path(path, "env"), out);
2725        }
2726        if let Some(limits) = &self.limits {
2727            collect_block(limits, &child_path(path, "limits"), out);
2728        }
2729        if let Some(resources) = &self.resources {
2730            collect_block(resources, &child_path(path, "resources"), out);
2731        }
2732        if let Some(state) = &self.state {
2733            collect_block(state, &child_path(path, "state"), out);
2734        }
2735        if let Some(task_io) = &self.task_io {
2736            collect_block(task_io, &child_path(path, "task_io"), out);
2737        }
2738        if let Some(conversation) = &self.conversation {
2739            collect_block(conversation, &child_path(path, "conversation"), out);
2740        }
2741        if let Some(plan) = &self.plan {
2742            collect_block(plan, &child_path(path, "plan"), out);
2743        }
2744        if let Some(install) = &self.install {
2745            collect_block(install, &child_path(path, "install"), out);
2746        }
2747    }
2748}
2749
2750impl RawBlock for RawTaskIoCapabilities {
2751    const KNOWN_KEYS: &'static [&'static str] = &["read"];
2752    fn unknown_keys(&self) -> &UnknownKeys {
2753        &self.unknown
2754    }
2755}
2756
2757impl RawBlock for RawConversationCapabilities {
2758    const KNOWN_KEYS: &'static [&'static str] = &["read"];
2759    fn unknown_keys(&self) -> &UnknownKeys {
2760        &self.unknown
2761    }
2762}
2763
2764impl RawBlock for RawPlanCapabilities {
2765    const KNOWN_KEYS: &'static [&'static str] = &["submit"];
2766    fn unknown_keys(&self) -> &UnknownKeys {
2767        &self.unknown
2768    }
2769}
2770
2771impl RawBlock for RawInstallCapabilities {
2772    const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool"];
2773    fn unknown_keys(&self) -> &UnknownKeys {
2774        &self.unknown
2775    }
2776}
2777
2778impl RawBlock for RawSpawnCapabilities {
2779    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2780    fn unknown_keys(&self) -> &UnknownKeys {
2781        &self.unknown
2782    }
2783}
2784
2785impl RawBlock for RawEnvCapabilities {
2786    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2787    fn unknown_keys(&self) -> &UnknownKeys {
2788        &self.unknown
2789    }
2790}
2791
2792impl RawBlock for RawResourceLimits {
2793    const KNOWN_KEYS: &'static [&'static str] = &[
2794        "memory_bytes",
2795        "table_elements",
2796        "instances",
2797        "deadline_seconds",
2798    ];
2799    fn unknown_keys(&self) -> &UnknownKeys {
2800        &self.unknown
2801    }
2802}
2803
2804impl RawBlock for RawStateCapabilities {
2805    const KNOWN_KEYS: &'static [&'static str] = &["store"];
2806    fn unknown_keys(&self) -> &UnknownKeys {
2807        &self.unknown
2808    }
2809}
2810
2811impl RawBlock for RawResourceCapabilities {
2812    const KNOWN_KEYS: &'static [&'static str] = &[
2813        "max_processes",
2814        "max_open_files",
2815        "max_file_size_bytes",
2816        "cpu_seconds",
2817        "memory_bytes",
2818        "cgroup_memory_bytes",
2819        "cgroup_pids_max",
2820        "cgroup_cpu_percent",
2821        "cgroup_io_bytes_per_sec",
2822        "workdir_max_bytes",
2823    ];
2824    fn unknown_keys(&self) -> &UnknownKeys {
2825        &self.unknown
2826    }
2827}
2828
2829impl RawBlock for RawNetworkCapabilities {
2830    const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
2831    fn unknown_keys(&self) -> &UnknownKeys {
2832        &self.unknown
2833    }
2834}
2835
2836impl RawBlock for RawPeerFetchCapabilities {
2837    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2838    fn unknown_keys(&self) -> &UnknownKeys {
2839        &self.unknown
2840    }
2841}
2842
2843impl RawBlock for RawFilesystemCapabilities {
2844    const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only"];
2845    fn unknown_keys(&self) -> &UnknownKeys {
2846        &self.unknown
2847    }
2848}
2849
2850impl RawBlock for RawShellCapabilities {
2851    const KNOWN_KEYS: &'static [&'static str] = &[
2852        "allow",
2853        "strip_env",
2854        "baseline_env",
2855        "interpreter_runtime",
2856        "staged_runtime",
2857    ];
2858    fn unknown_keys(&self) -> &UnknownKeys {
2859        &self.unknown
2860    }
2861
2862    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2863        let interpreter_runtime = child_path(path, "interpreter_runtime");
2864        for (index, grant) in self.interpreter_runtime.iter().enumerate() {
2865            collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
2866        }
2867        let staged_runtime = child_path(path, "staged_runtime");
2868        for (index, grant) in self.staged_runtime.iter().enumerate() {
2869            collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
2870        }
2871    }
2872}
2873
2874impl RawBlock for RawInterpreterRuntimeGrant {
2875    const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
2876    fn unknown_keys(&self) -> &UnknownKeys {
2877        &self.unknown
2878    }
2879
2880    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2881        let dirs = child_path(path, "dirs");
2882        for (index, dir) in self.dirs.iter().enumerate() {
2883            collect_block(dir, &format!("{dirs}[{index}]"), out);
2884        }
2885    }
2886}
2887
2888impl RawBlock for RawInterpreterRuntimeDir {
2889    const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
2890    fn unknown_keys(&self) -> &UnknownKeys {
2891        &self.unknown
2892    }
2893}
2894
2895impl RawBlock for RawStagedRuntimeGrant {
2896    const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
2897    fn unknown_keys(&self) -> &UnknownKeys {
2898        &self.unknown
2899    }
2900}
2901
2902impl RawBlock for RawInferenceConfig {
2903    const KNOWN_KEYS: &'static [&'static str] = &[
2904        "transport",
2905        "endpoint",
2906        "model",
2907        "api_key",
2908        "command",
2909        "system_prompt",
2910        "system_prompt_file",
2911        "system_prompt_artifact",
2912        "driver",
2913        "compaction",
2914        "max_turns",
2915        "max_task_reopens",
2916        "max_tokens",
2917        "max_session_tokens",
2918        "tool_refresh",
2919        "alternates",
2920    ];
2921    fn unknown_keys(&self) -> &UnknownKeys {
2922        &self.unknown
2923    }
2924
2925    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2926        if let Some(driver) = &self.driver {
2927            collect_block(driver, &child_path(path, "driver"), out);
2928        }
2929        if let Some(compaction) = &self.compaction {
2930            collect_block(compaction, &child_path(path, "compaction"), out);
2931        }
2932        if let Some(alternates) = &self.alternates {
2933            let alternates_path = child_path(path, "alternates");
2934            for (index, alternate) in alternates.iter().enumerate() {
2935                collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
2936            }
2937        }
2938    }
2939}
2940
2941impl RawBlock for RawInferenceAlternate {
2942    const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
2943    fn unknown_keys(&self) -> &UnknownKeys {
2944        &self.unknown
2945    }
2946
2947    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2948        if let Some(driver) = &self.driver {
2949            collect_block(driver, &child_path(path, "driver"), out);
2950        }
2951    }
2952}
2953
2954impl RawBlock for RawCompactionConfig {
2955    const KNOWN_KEYS: &'static [&'static str] = &[
2956        "threshold",
2957        "model",
2958        "system_prompt",
2959        "system_prompt_file",
2960        "dump_summaries",
2961    ];
2962    fn unknown_keys(&self) -> &UnknownKeys {
2963        &self.unknown
2964    }
2965}
2966
2967impl RawBlock for RawInferenceDriver {
2968    const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
2969    fn unknown_keys(&self) -> &UnknownKeys {
2970        &self.unknown
2971    }
2972}
2973
2974/// Walks a parsed raw manifest and reports every key no block of it claimed, each block's own
2975/// keys before the blocks it owns, in the order the blocks appear in the manifest's own type.
2976///
2977/// Called once, immediately after `serde_yaml` returns and before any field is moved out, so the
2978/// result covers the whole document rather than the parts validation happens to reach. Reporting
2979/// only; nothing here can refuse a manifest.
2980fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
2981    let mut out = Vec::new();
2982    collect_block(raw, "", &mut out);
2983    out
2984}
2985
2986/// One block's captured keys, each paired with the nearest key that block does recognize, then
2987/// the same for every block it owns.
2988///
2989/// `path` is empty for the manifest root, which is what
2990/// [`crate::unknown_manifest_keys::UnknownManifestKey`] renders as "at the top level".
2991fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
2992    for key in block.unknown_keys().keys() {
2993        out.push(UnknownManifestKey {
2994            key: key.clone(),
2995            block_path: path.to_string(),
2996            nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
2997        });
2998    }
2999    block.walk_children(path, out);
3000}
3001
3002/// The dotted path of `field` inside the block at `parent`, built in one place so no
3003/// [`RawBlock::walk_children`] has to reproduce the rule that the manifest root has no prefix.
3004/// An indexed child appends its own `[index]` to the result.
3005fn child_path(parent: &str, field: &str) -> String {
3006    if parent.is_empty() {
3007        return field.to_string();
3008    }
3009    format!("{parent}.{field}")
3010}
3011
3012/// Read a manifest file's text, mapping a missing file to
3013/// [`RuntimeManifestError::NotFound`] and any other read failure to
3014/// [`RuntimeManifestError::Io`].
3015///
3016/// For a caller that needs the manifest text as well as the parsed manifest — `mur doctor` reads
3017/// the references a manifest makes out of the same bytes it parses, so the file is opened once.
3018pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
3019    fs::read_to_string(path).map_err(|source| {
3020        if source.kind() == std::io::ErrorKind::NotFound {
3021            return RuntimeManifestError::NotFound(path.display().to_string());
3022        }
3023
3024        RuntimeManifestError::Io {
3025            path: path.display().to_string(),
3026            source,
3027        }
3028    })
3029}
3030
3031#[must_use = "validated runtime manifest is required before run"]
3032pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
3033    let content = read_runtime_manifest_text(path)?;
3034
3035    RuntimeManifest::from_yaml_str(&content)
3036}
3037
3038/// One `${VAR}` reference a manifest makes, and the field that makes it.
3039#[derive(Debug, Clone, PartialEq, Eq)]
3040pub struct ReferencedEnvVariable {
3041    /// The manifest field holding the reference, e.g. `artifacts.murmur-driver-anthropic.gateway.api_key`.
3042    pub field: String,
3043    /// The variable the reference names.
3044    pub variable: String,
3045}
3046
3047impl ReferencedEnvVariable {
3048    /// Whether the reference is a gateway's `api_key` — a credential the runtime also answers
3049    /// from the global config's `credentials:` map, not only from the environment.
3050    pub fn is_gateway_credential(&self) -> bool {
3051        self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
3052    }
3053}
3054
3055/// The narrow view of a manifest this scan needs: the fields whose value may be an `${ENV_REF}`,
3056/// and nothing else. Every other key is ignored, so a manifest this build would refuse for an
3057/// unrelated reason still yields its references.
3058///
3059/// Deliberately not one of the `Raw*` structs: those carry the unknown-key overflow map that
3060/// makes `warn_on_unknown_manifest_keys` complete, and this one is a second, partial reader of a
3061/// document that reader has already covered.
3062#[derive(Deserialize)]
3063struct ReferencedManifestFields {
3064    #[serde(default)]
3065    artifacts: Vec<ReferencedArtifactFields>,
3066}
3067
3068#[derive(Deserialize)]
3069struct ReferencedArtifactFields {
3070    #[serde(default)]
3071    name: Option<String>,
3072    #[serde(default)]
3073    gateway: Option<ReferencedGatewayFields>,
3074}
3075
3076#[derive(Deserialize)]
3077struct ReferencedGatewayFields {
3078    #[serde(default)]
3079    api_key: Option<String>,
3080}
3081
3082/// Every environment variable `manifest_yaml` references through an `${ENV_REF}`, sorted by
3083/// variable name and deduplicated.
3084///
3085/// Never fails and never reads the environment: unparseable YAML, an absent field and a literal
3086/// (non-reference) value all yield an empty list. What counts as a reference is decided by the
3087/// same `parse_env_reference` the resolving parse applies, so a name reported here is exactly a
3088/// name [`RuntimeManifest::from_yaml_str`] would have looked up.
3089pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
3090    let Ok(raw) = serde_yaml::from_str::<ReferencedManifestFields>(manifest_yaml) else {
3091        return Vec::new();
3092    };
3093
3094    let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
3095    for artifact in raw.artifacts {
3096        let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
3097            continue;
3098        };
3099        if let Some(variable) = parse_env_reference(api_key.trim()) {
3100            let name = artifact.name.unwrap_or_default();
3101            referenced.push(ReferencedEnvVariable {
3102                field: format!("artifacts.{}.gateway.api_key", name.trim()),
3103                variable: variable.to_string(),
3104            });
3105        }
3106    }
3107
3108    referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
3109    referenced.dedup();
3110    referenced
3111}
3112
3113/// Whether a parse keeps what the manifest says about a secret it references.
3114///
3115/// The two [`RuntimeManifest`] constructors differ by this value and nothing else, so validation
3116/// cannot drift between a manifest `mur run` accepts and one a referee accepts. Neither reads the
3117/// process environment.
3118#[derive(Clone, Copy, PartialEq, Eq)]
3119enum SecretResolution {
3120    /// Every `gateway.api_key` is kept as an [`ApiKeyReference`], unresolved.
3121    Resolve,
3122    /// Every `gateway.api_key` is left unread. Each parsed [`ArtifactGateway::api_key`] is `None`
3123    /// whatever the manifest wrote.
3124    Skip,
3125}
3126
3127impl RuntimeManifest {
3128    pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
3129        Self::parse(input, SecretResolution::Resolve)
3130    }
3131
3132    /// Parse a manifest without resolving any secret it references.
3133    ///
3134    /// Validation is identical to [`RuntimeManifest::from_yaml_str`] on every block, `artifacts`
3135    /// included, with one difference: every resulting [`ArtifactGateway::api_key`] is `None`,
3136    /// whether the manifest wrote an `${ENV_REF}` or a literal key.
3137    ///
3138    /// For a caller that reads a foreign capsule's manifest for its capability policy alone —
3139    /// `mur-roost` refereeing a delegation — and is entitled to none of the credentials that
3140    /// manifest names.
3141    pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
3142        Self::parse(input, SecretResolution::Skip)
3143    }
3144
3145    fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
3146        let raw: RawRuntimeManifest = serde_yaml::from_str(input).map_err(|err| {
3147            if let Some(location) = err.location() {
3148                RuntimeManifestError::YamlSyntax(format!(
3149                    "{}: YAML syntax error at line {}, column {}: {}",
3150                    MANIFEST_FILENAME,
3151                    location.line(),
3152                    location.column(),
3153                    err
3154                ))
3155            } else {
3156                RuntimeManifestError::YamlSyntax(format!(
3157                    "{MANIFEST_FILENAME}: YAML syntax error: {err}"
3158                ))
3159            }
3160        })?;
3161
3162        // Collected before any field is moved out of `raw`, so the walk sees the whole document
3163        // rather than the part validation happens to reach before its first refusal.
3164        let unknown_keys = collect_unknown_keys(&raw);
3165
3166        let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
3167            RuntimeManifestError::MissingField {
3168                field: "name".to_string(),
3169            }
3170        })?;
3171
3172        let version = raw
3173            .version
3174            .filter(|s| !s.trim().is_empty())
3175            .ok_or_else(|| RuntimeManifestError::MissingField {
3176                field: "version".to_string(),
3177            })?;
3178
3179        // Read off the raw entries because a parse that skips secrets keeps no `ApiKeyReference`,
3180        // and `control.secrets` is checked against what the manifest wrote either way.
3181        let gateway_references = raw_gateway_references(&raw.artifacts);
3182
3183        let artifacts = raw
3184            .artifacts
3185            .into_iter()
3186            .enumerate()
3187            .map(|(index, artifact)| {
3188                let name = artifact
3189                    .name
3190                    .filter(|s| !s.trim().is_empty())
3191                    .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3192                        index,
3193                        message: "missing required field 'name'".to_string(),
3194                    })?;
3195
3196                let source = artifact.source.filter(|s| !s.trim().is_empty());
3197
3198                let runtime = match artifact.runtime.as_deref() {
3199                    None | Some("tool") => ArtifactRuntime::Tool,
3200                    Some("wasm") | Some("native") => {
3201                        return Err(RuntimeManifestError::InvalidArtifact {
3202                            index,
3203                            message: format!(
3204                                "use 'runtime: tool'; implementation is declared in the \
3205                                 artifact's own manifest (got '{}')",
3206                                artifact.runtime.as_deref().unwrap_or("")
3207                            ),
3208                        });
3209                    }
3210                    Some("driver") => ArtifactRuntime::Driver,
3211                    Some("hook") => ArtifactRuntime::Hook,
3212                    Some("skill") => ArtifactRuntime::Skill,
3213                    Some(other) => {
3214                        return Err(RuntimeManifestError::InvalidArtifact {
3215                            index,
3216                            message: format!(
3217                                "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
3218                            ),
3219                        });
3220                    }
3221                };
3222
3223                // Capabilities are keyed on declared properties, not on the role. When the
3224                // property is absent we derive it from the role, which reproduces the previous
3225                // skill-only gate exactly and leaves every existing manifest unchanged.
3226                let local_source = artifact
3227                    .local_source
3228                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3229                let prompt_payload = artifact
3230                    .prompt_payload
3231                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3232
3233                // `source:` requires the artifact to declare `local_source: true`.
3234                if source.is_some() && !local_source {
3235                    return Err(RuntimeManifestError::InvalidArtifact {
3236                        index,
3237                        message: format!(
3238                            "artifact '{name}' declares 'source:' but does not declare \
3239                             'local_source: true' (runtime: {})",
3240                            runtime.as_str()
3241                        ),
3242                    });
3243                }
3244
3245                // When `source:` is present, `version:` is optional — substitute "local".
3246                // If both are set, `version:` is ignored and a warning is printed to stderr.
3247                let version = if source.is_some() {
3248                    if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
3249                        crate::warn_line(format_args!(
3250                            "warning: artifact '{name}' declares both 'source:' and \
3251                             'version: {explicit}'; version is ignored for local-source skills \
3252                             (using 'local')"
3253                        ));
3254                    }
3255                    "local".to_string()
3256                } else {
3257                    artifact
3258                        .version
3259                        .filter(|s| !s.trim().is_empty())
3260                        .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3261                            index,
3262                            message: "missing required field 'version'".to_string(),
3263                        })?
3264                };
3265
3266                // Per-artifact `capabilities:` is enforced on every role that actually
3267                // executes — `hook` (default-deny grant) and `tool`/`driver` (narrowing
3268                // below the capsule ceiling). A `skill` has no execution surface, so the
3269                // block would be silently ignored there; that reads like a scoped artifact
3270                // when it is not, hence a parse error rather than a no-op.
3271                let capabilities = match artifact.capabilities {
3272                    None => None,
3273                    Some(raw_caps) => {
3274                        // The install grant is held by the session, not by one artifact: a
3275                        // per-artifact block would reach no pull at all.
3276                        if raw_caps.install.is_some() {
3277                            return Err(RuntimeManifestError::InvalidArtifact {
3278                                index,
3279                                message: format!(
3280                                    "artifact '{name}' declares 'capabilities.install:'; the \
3281                                     grant is capsule-wide and is only recognized in the \
3282                                     top-level capabilities block"
3283                                ),
3284                            });
3285                        }
3286                        if runtime == ArtifactRuntime::Skill {
3287                            return Err(RuntimeManifestError::InvalidArtifact {
3288                                index,
3289                                message: format!(
3290                                    "artifact '{name}' declares per-artifact 'capabilities:' but \
3291                                     has 'runtime: {}'; per-artifact capabilities are only \
3292                                     recognized on 'runtime: hook', 'runtime: tool', and \
3293                                     'runtime: driver' entries — use the capsule-wide top-level \
3294                                     'capabilities:' block instead",
3295                                    runtime.as_str()
3296                                ),
3297                            });
3298                        }
3299                        // `task_io` grants the `murmur:task-io/read` host import, which only
3300                        // hook components can be handed. On a tool or driver entry nothing
3301                        // would enforce it, and a silently-inert grant reads like a scoped
3302                        // artifact — the same reason `on_overflow:` below is a parse error off
3303                        // a hook.
3304                        if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
3305                            return Err(RuntimeManifestError::InvalidArtifact {
3306                                index,
3307                                message: format!(
3308                                    "artifact '{name}' declares 'capabilities.task_io:' but has \
3309                                     'runtime: {}'; the key grants the murmur:task-io/read host \
3310                                     import and is only recognized on 'runtime: hook' entries",
3311                                    runtime.as_str()
3312                                ),
3313                            });
3314                        }
3315                        // `conversation` grants the `murmur:conversation/read` host import, on the
3316                        // same terms as `task_io` above: only a hook component's world can import
3317                        // it, so on any other role the grant would be silently inert.
3318                        if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
3319                            return Err(RuntimeManifestError::InvalidArtifact {
3320                                index,
3321                                message: format!(
3322                                    "artifact '{name}' declares 'capabilities.conversation:' but \
3323                                     has 'runtime: {}'; the key grants the \
3324                                     murmur:conversation/read host import and is only recognized \
3325                                     on 'runtime: hook' entries",
3326                                    runtime.as_str()
3327                                ),
3328                            });
3329                        }
3330                        parse_capabilities(Some(raw_caps))?
3331                    }
3332                };
3333
3334                // `on_overflow:` governs an async hook's job queue, which only exists for
3335                // `runtime: hook`. On any other role it would be silently ignored, so it is
3336                // a parse error for the same reason per-artifact `capabilities:` is on a
3337                // skill. It stays legal on a *blocking* hook entry (simply inert): the
3338                // execution mode lives in the artifact's own manifest and is unknown here.
3339                let on_overflow = match artifact.on_overflow.as_deref() {
3340                    None => HookOverflowPolicy::default(),
3341                    Some(raw) => {
3342                        if runtime != ArtifactRuntime::Hook {
3343                            return Err(RuntimeManifestError::InvalidArtifact {
3344                                index,
3345                                message: format!(
3346                                    "artifact '{name}' declares 'on_overflow:' but has \
3347                                     'runtime: {}'; the key governs an async hook's job queue \
3348                                     and is only recognized on 'runtime: hook' entries",
3349                                    runtime.as_str()
3350                                ),
3351                            });
3352                        }
3353                        match raw {
3354                            "drop" => HookOverflowPolicy::Drop,
3355                            "block" => HookOverflowPolicy::Block,
3356                            other => {
3357                                return Err(RuntimeManifestError::InvalidArtifact {
3358                                    index,
3359                                    message: format!(
3360                                        "artifact '{name}' declares unknown on_overflow \
3361                                         '{other}'; expected: drop, block"
3362                                    ),
3363                                });
3364                            }
3365                        }
3366                    }
3367                };
3368
3369                // `config:` reaches an artifact through that artifact's own grant, and a skill
3370                // holds no grant and runs no component — nothing would ever deliver it. Refused
3371                // rather than ignored, for the reason the two gates above are: a key that reads
3372                // like configuration and is silently dropped is worse than one that is refused.
3373                // The block's *shape* is the runtime's business, not this parser's; only the
3374                // role is decided here.
3375                if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
3376                    return Err(RuntimeManifestError::InvalidArtifact {
3377                        index,
3378                        message: format!(
3379                            "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
3380                             is recognized only on 'runtime: hook', 'runtime: tool', and \
3381                             'runtime: driver' entries",
3382                            runtime.as_str()
3383                        ),
3384                    });
3385                }
3386
3387                let gateway = match artifact.gateway {
3388                    None => None,
3389                    Some(raw_gateway) => {
3390                        if runtime == ArtifactRuntime::Skill {
3391                            return Err(RuntimeManifestError::InvalidArtifact {
3392                                index,
3393                                message: format!(
3394                                    "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
3395                                     the key is recognized only on 'runtime: hook', \
3396                                     'runtime: tool', and 'runtime: driver' entries",
3397                                    runtime.as_str()
3398                                ),
3399                            });
3400                        }
3401                        Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
3402                    }
3403                };
3404
3405                Ok(RuntimeArtifact {
3406                    name,
3407                    version,
3408                    runtime,
3409                    source,
3410                    local_source,
3411                    prompt_payload,
3412                    capabilities,
3413                    on_overflow,
3414                    config: artifact.config,
3415                    gateway,
3416                })
3417            })
3418            .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
3419
3420        // The capsule-wide block reaches capsule, tool and driver components, none of which
3421        // can be handed the murmur:task-io/read import. Rejected rather than ignored, on the
3422        // same terms as declaring it on a non-hook artifact entry.
3423        if raw
3424            .capabilities
3425            .as_ref()
3426            .is_some_and(|caps| caps.task_io.is_some())
3427        {
3428            return Err(RuntimeManifestError::InvalidCapabilities {
3429                field: "capabilities.task_io".to_string(),
3430                message: "is recognized only on 'runtime: hook' artifact entries, not in the \
3431                          capsule-wide capabilities block — the grant is per-hook"
3432                    .to_string(),
3433            });
3434        }
3435        // Config is delivered on one artifact's own grant, so there is no capsule-wide form of
3436        // it: the capsule's guest holds no artifact grant, and a top-level block would reach no
3437        // component at all. Refused rather than ignored, on the same terms as the capsule-wide
3438        // `task_io` block above.
3439        if raw.config.is_some() {
3440            return Err(RuntimeManifestError::InvalidCapabilities {
3441                field: "config".to_string(),
3442                message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
3443                          'runtime: driver' entry that reads it — there is no capsule-wide \
3444                          config block"
3445                    .to_string(),
3446            });
3447        }
3448        let capabilities = parse_capabilities(raw.capabilities)?;
3449        let inference = parse_inference(raw.inference)?;
3450        validate_driver_gateways(&artifacts, inference.as_ref())?;
3451        validate_driver_choices(&artifacts, inference.as_ref())?;
3452        validate_process_driver_entry(&artifacts, inference.as_ref())?;
3453
3454        // Validate system_prompt_artifact: must name a declared artifact whose payload may be
3455        // bound as the system prompt (`prompt_payload`, defaulted from the role when absent).
3456        if let Some(ref sp_art) = inference
3457            .as_ref()
3458            .and_then(|i| i.system_prompt_artifact.clone())
3459        {
3460            let matching = artifacts.iter().find(|a| &a.name == sp_art);
3461            match matching {
3462                None => {
3463                    return Err(RuntimeManifestError::InvalidInferenceConfig {
3464                        field: "inference.system_prompt_artifact".to_string(),
3465                        message: format!("artifact '{sp_art}' is not declared in artifacts:"),
3466                    });
3467                }
3468                Some(a) if !a.prompt_payload => {
3469                    return Err(RuntimeManifestError::InvalidInferenceConfig {
3470                        field: "inference.system_prompt_artifact".to_string(),
3471                        message: format!(
3472                            "artifact '{sp_art}' (runtime: {}) does not declare \
3473                             'prompt_payload: true', which system_prompt_artifact requires",
3474                            a.runtime.as_str()
3475                        ),
3476                    });
3477                }
3478                Some(_) => {} // valid
3479            }
3480        }
3481        let context = parse_context(raw.context)?;
3482        let observability = parse_observability(raw.observability);
3483        let trace = raw
3484            .trace
3485            .map(|raw_trace| {
3486                let capture = resolve_trace_capture(raw_trace.capture.as_deref())?;
3487                let retain = parse_trace_retain(raw_trace.retain)?;
3488                Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
3489            })
3490            .transpose()?;
3491        let network = raw
3492            .network
3493            .map(|n| {
3494                Ok::<_, RuntimeManifestError>(NetworkConfig {
3495                    internal_port: n.internal_port,
3496                    authentication: parse_network_authentication(n.authentication)?,
3497                })
3498            })
3499            .transpose()?;
3500        refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
3501        let lifecycle = raw.lifecycle.map(|raw_lc| {
3502            let defaults = LifecycleConfig::default();
3503            LifecycleConfig {
3504                task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
3505                after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
3506                queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
3507                input_timeout_secs: raw_lc.input_timeout_secs,
3508                conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
3509                max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
3510                shell_grace_secs: raw_lc.shell_grace_secs.unwrap_or(defaults.shell_grace_secs),
3511                delegation_deadline_secs: raw_lc
3512                    .delegation_deadline_secs
3513                    .unwrap_or(defaults.delegation_deadline_secs),
3514            }
3515        });
3516
3517        let exports = parse_exports(raw.exports)?;
3518        let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
3519        validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
3520
3521        Ok(Self {
3522            name,
3523            version,
3524            artifacts,
3525            capabilities,
3526            inference,
3527            context,
3528            observability,
3529            trace,
3530            network,
3531            lifecycle,
3532            exports,
3533            control,
3534            mur_version: raw.mur_version.filter(|s| !s.trim().is_empty()),
3535            unknown_keys,
3536        })
3537    }
3538}
3539
3540/// Every artifact entry whose `gateway.api_key` is written as `${NAME}`, as `(artifact, NAME)`.
3541fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
3542    artifacts
3543        .iter()
3544        .filter_map(|artifact| {
3545            let name = artifact.name.as_deref()?.trim();
3546            let written = artifact
3547                .gateway
3548                .as_ref()?
3549                .as_ref()?
3550                .api_key
3551                .as_ref()?
3552                .as_ref()?;
3553            let variable = parse_env_reference(written.trim())?;
3554            Some((name.to_string(), variable.to_string()))
3555        })
3556        .collect()
3557}
3558
3559/// Lowers the top-level `control:` block. A block that declares nothing is no block.
3560///
3561/// `gateway_references` is every `(artifact, NAME)` pair an entry's `gateway.api_key: ${NAME}`
3562/// wrote. A secret must back one of them, and must not back the configured `transport: http`
3563/// driver's: that credential is resolved at staging and keys every turn, so a session launched
3564/// without it could make no inference call at all.
3565fn parse_control(
3566    raw: Option<RawControl>,
3567    gateway_references: &[(String, String)],
3568    inference: Option<&InferenceConfig>,
3569) -> Result<Option<ControlConfig>, RuntimeManifestError> {
3570    let Some(raw) = raw else {
3571        return Ok(None);
3572    };
3573    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
3574        field: field.to_string(),
3575        message,
3576    };
3577    let settings_written = control_string_list("control.settings", raw.settings)?;
3578    let secrets_written = control_string_list("control.secrets", raw.secrets)?;
3579    let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
3580    if settings_written.is_empty()
3581        && secrets_written.is_empty()
3582        && agent_settings_written.is_empty()
3583    {
3584        return Ok(None);
3585    }
3586    let Some(inference) = inference else {
3587        return Err(refuse(
3588            "control",
3589            "is declared on a capsule with no inference: block; the control surface is served on \
3590             an agent capsule's A2A listener, and a script capsule opens none"
3591                .to_string(),
3592        ));
3593    };
3594
3595    let mut settings = Vec::new();
3596    for entry in settings_written {
3597        let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
3598            let controllable: Vec<&str> = ControllableSetting::ALL
3599                .iter()
3600                .map(|setting| setting.wire_name())
3601                .collect();
3602            return Err(refuse(
3603                "control.settings",
3604                format!(
3605                    "'{entry}' is not a controllable setting; the controllable settings are: {}",
3606                    controllable.join(", ")
3607                ),
3608            ));
3609        };
3610        if settings.contains(&setting) {
3611            return Err(refuse(
3612                "control.settings",
3613                format!("'{entry}' is listed more than once"),
3614            ));
3615        }
3616        match setting {
3617            ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
3618                return Err(refuse(
3619                    "control.settings",
3620                    format!(
3621                        "'{entry}' is controllable only under inference.transport: http; a \
3622                         transport: {} harness sets its own output cap",
3623                        inference.transport
3624                    ),
3625                ));
3626            }
3627            ControllableSetting::InferenceMaxTokens => {}
3628            ControllableSetting::InferenceDriver => {
3629                check_driver_setting(inference).map_err(|message| {
3630                    refuse("control.settings", format!("'{entry}' {message}"))
3631                })?;
3632            }
3633        }
3634        settings.push(setting);
3635    }
3636
3637    let mut agent_settings = Vec::new();
3638    for entry in agent_settings_written {
3639        let Some(setting) = ControllableSetting::from_wire_name(&entry)
3640            .filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
3641        else {
3642            let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
3643                .iter()
3644                .map(|setting| setting.wire_name())
3645                .collect();
3646            return Err(refuse(
3647                "control.agent_settings",
3648                format!(
3649                    "'{entry}' is not a setting the agent may change; control.agent_settings \
3650                     accepts only: {}",
3651                    settable.join(", ")
3652                ),
3653            ));
3654        };
3655        if agent_settings.contains(&setting) {
3656            return Err(refuse(
3657                "control.agent_settings",
3658                format!("'{entry}' is listed more than once"),
3659            ));
3660        }
3661        check_driver_setting(inference)
3662            .map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
3663        agent_settings.push(setting);
3664    }
3665
3666    let inference_driver = (inference.transport == "http")
3667        .then(|| {
3668            inference
3669                .driver
3670                .as_ref()
3671                .map(|driver| driver.artifact.as_str())
3672        })
3673        .flatten();
3674    let mut secrets: Vec<String> = Vec::new();
3675    for entry in secrets_written {
3676        if !is_valid_env_variable(&entry) {
3677            return Err(refuse(
3678                "control.secrets",
3679                format!(
3680                    "'{entry}' is not a credential name; a name is uppercase letters, digits and \
3681                     underscores, and does not start with a digit"
3682                ),
3683            ));
3684        }
3685        if secrets.contains(&entry) {
3686            return Err(refuse(
3687                "control.secrets",
3688                format!("'{entry}' is listed more than once"),
3689            ));
3690        }
3691        let backing: Vec<&str> = gateway_references
3692            .iter()
3693            .filter(|(_, variable)| *variable == entry)
3694            .map(|(artifact, _)| artifact.as_str())
3695            .collect();
3696        if backing.is_empty() {
3697            return Err(refuse(
3698                "control.secrets",
3699                format!(
3700                    "'{entry}' is not referenced by any artifact's gateway.api_key as \
3701                     ${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
3702                ),
3703            ));
3704        }
3705        if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
3706            return Err(refuse(
3707                "control.secrets",
3708                format!(
3709                    "'{entry}' keys the configured inference driver's gateway \
3710                     (artifacts.{driver}.gateway.api_key); the inference credential is resolved \
3711                     at launch and cannot be supplied by a controller"
3712                ),
3713            ));
3714        }
3715        secrets.push(entry);
3716    }
3717
3718    Ok(Some(ControlConfig {
3719        settings,
3720        secrets,
3721        agent_settings,
3722    }))
3723}
3724
3725/// Why `inference.driver` cannot be listed for `inference`, or `Ok` when it can: only under
3726/// `transport: http`, and only with at least one alternate to select.
3727fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
3728    if inference.transport != "http" {
3729        return Err(format!(
3730            "is controllable only under inference.transport: http; a transport: {} harness owns \
3731             the model it runs",
3732            inference.transport
3733        ));
3734    }
3735    if inference.alternates.is_empty() {
3736        return Err(
3737            "selects among inference.alternates, and this capsule declares none; declare an \
3738             alternate to switch to"
3739                .to_string(),
3740        );
3741    }
3742    Ok(())
3743}
3744
3745/// `value` as a list of strings, trimmed, for `field`. Absent and YAML null are the empty list.
3746fn control_string_list(
3747    field: &str,
3748    value: Option<serde_yaml::Value>,
3749) -> Result<Vec<String>, RuntimeManifestError> {
3750    let refuse = || RuntimeManifestError::InvalidControl {
3751        field: field.to_string(),
3752        message: "must be a list of names".to_string(),
3753    };
3754    let items = match value {
3755        None | Some(serde_yaml::Value::Null) => return Ok(Vec::new()),
3756        Some(serde_yaml::Value::Sequence(items)) => items,
3757        Some(_) => return Err(refuse()),
3758    };
3759    items
3760        .into_iter()
3761        .map(|item| match item {
3762            serde_yaml::Value::String(name) => Ok(name.trim().to_string()),
3763            _ => Err(refuse()),
3764        })
3765        .collect()
3766}
3767
3768/// Lowers `network.authentication`. An absent or null block is a public door.
3769fn parse_network_authentication(
3770    raw: Option<RawNetworkAuthentication>,
3771) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
3772    let Some(raw) = raw else {
3773        return Ok(None);
3774    };
3775    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
3776        field: field.to_string(),
3777        message,
3778    };
3779    let schemes: Vec<&str> = AuthenticationScheme::ALL
3780        .iter()
3781        .map(|scheme| scheme.wire_name())
3782        .collect();
3783    let scheme = match raw.scheme.as_deref().map(str::trim) {
3784        None | Some("") => {
3785            return Err(refuse(
3786                "network.authentication.scheme",
3787                format!("is required; the schemes are: {}", schemes.join(", ")),
3788            ))
3789        }
3790        Some(written) => AuthenticationScheme::ALL
3791            .iter()
3792            .copied()
3793            .find(|scheme| scheme.wire_name() == written)
3794            .ok_or_else(|| {
3795                refuse(
3796                    "network.authentication.scheme",
3797                    format!(
3798                        "'{written}' is not a door authentication scheme; the schemes are: {}",
3799                        schemes.join(", ")
3800                    ),
3801                )
3802            })?,
3803    };
3804
3805    let mut credentials = Vec::new();
3806    for (name, credential) in raw.credentials.unwrap_or_default() {
3807        if name == OPERATOR_CREDENTIAL {
3808            return Err(refuse(
3809                "network.authentication.credentials",
3810                format!(
3811                    "'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
3812                     token, which holds every scope"
3813                ),
3814            ));
3815        }
3816        if !is_valid_credential_name(&name) {
3817            return Err(refuse(
3818                "network.authentication.credentials",
3819                format!(
3820                    "'{name}' is not a credential name; a name is a lowercase letter followed by \
3821                     at most {} lowercase letters, digits, '_' or '-'",
3822                    MAX_CREDENTIAL_NAME_LEN - 1
3823                ),
3824            ));
3825        }
3826        let field = format!("network.authentication.credentials.{name}.scopes");
3827        let scopes = door_scope_list(&field, credential.scopes)?;
3828        credentials.push(DoorCredential { name, scopes });
3829    }
3830
3831    Ok(Some(NetworkAuthentication {
3832        scheme,
3833        credentials,
3834    }))
3835}
3836
3837/// `^[a-z][a-z0-9_-]{0,31}$`.
3838fn is_valid_credential_name(name: &str) -> bool {
3839    let mut chars = name.chars();
3840    chars.next().is_some_and(|first| first.is_ascii_lowercase())
3841        && name.len() <= MAX_CREDENTIAL_NAME_LEN
3842        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
3843}
3844
3845/// One credential's `scopes`: required, non-empty, each in [`DOOR_SCOPES`] and listed once.
3846fn door_scope_list(
3847    field: &str,
3848    value: Option<serde_yaml::Value>,
3849) -> Result<Vec<String>, RuntimeManifestError> {
3850    let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
3851        field: field.to_string(),
3852        message,
3853    };
3854    let items = match value {
3855        None | Some(serde_yaml::Value::Null) => {
3856            return Err(refuse(
3857                "is required: a credential reaches only the scopes it lists".to_string(),
3858            ))
3859        }
3860        Some(serde_yaml::Value::Sequence(items)) => items,
3861        Some(_) => return Err(refuse("must be a list of scopes".to_string())),
3862    };
3863    if items.is_empty() {
3864        return Err(refuse(
3865            "lists no scope; a credential must reach at least one".to_string(),
3866        ));
3867    }
3868    let mut scopes: Vec<String> = Vec::new();
3869    for item in items {
3870        let serde_yaml::Value::String(scope) = item else {
3871            return Err(refuse("must be a list of scopes".to_string()));
3872        };
3873        let scope = scope.trim().to_string();
3874        if !DOOR_SCOPES.contains(&scope.as_str()) {
3875            return Err(refuse(format!(
3876                "'{scope}' is not a door scope; the scopes are: {}",
3877                DOOR_SCOPES.join(", ")
3878            )));
3879        }
3880        if scopes.contains(&scope) {
3881            return Err(refuse(format!("'{scope}' is listed more than once")));
3882        }
3883        scopes.push(scope);
3884    }
3885    Ok(scopes)
3886}
3887
3888/// Refuses `network.authentication` on a capsule that declares `capabilities.spawn.allow`.
3889///
3890/// A delegated child posts its completion to its parent's door, and nothing issues a child a token
3891/// for that door, so an authenticated parent would refuse every completion it asked for.
3892fn refuse_authenticated_delegating_parent(
3893    network: Option<&NetworkConfig>,
3894    capabilities: Option<&Capabilities>,
3895) -> Result<(), RuntimeManifestError> {
3896    let authenticated = network.is_some_and(|network| network.authentication.is_some());
3897    let delegates = capabilities
3898        .and_then(|capabilities| capabilities.spawn.as_ref())
3899        .is_some_and(|spawn| !spawn.allow.is_empty());
3900    if authenticated && delegates {
3901        return Err(RuntimeManifestError::InvalidNetworkConfig {
3902            field: "network.authentication".to_string(),
3903            message: "cannot be declared together with capabilities.spawn.allow: a delegated \
3904                      child posts its outcome to this door, and a delegated child cannot yet be \
3905                      issued a token for this door"
3906                .to_string(),
3907        });
3908    }
3909    Ok(())
3910}
3911
3912/// Lowers the top-level `exports:` block, rejecting anything the runtime would otherwise have to
3913/// decide about later.
3914///
3915/// The `root` rules are checked here and never again by a caller: a gateway that "cleans up" a
3916/// path is a gateway that has taken over the containment boundary, so the only legal answer to a
3917/// root naming somewhere outside the workdir is a refusal.
3918fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
3919    let Some(raw_exports) = raw else {
3920        return Ok(None);
3921    };
3922    let files = raw_exports.files.map(parse_file_export).transpose()?;
3923    let peer_files = raw_exports
3924        .peer_files
3925        .map(parse_peer_files_export)
3926        .transpose()?;
3927    let peer_tasks = raw_exports
3928        .peer_tasks
3929        .map(|raw| {
3930            raw.accept
3931                .map(|accept| PeerTasksExport { accept })
3932                .ok_or_else(|| RuntimeManifestError::InvalidExports {
3933                    field: "exports.peer_tasks.accept".to_string(),
3934                    message: "must be set explicitly to true or false — consent to peer tasks is \
3935                              never inferred"
3936                        .to_string(),
3937                })
3938        })
3939        .transpose()?;
3940    Ok(Some(Exports {
3941        files,
3942        peer_files,
3943        peer_tasks,
3944    }))
3945}
3946
3947/// Lowers `exports.peer_files`, on exactly the same terms as [`parse_file_export`]: a root that
3948/// could name somewhere outside the workdir is refused here and never repaired later.
3949///
3950/// `max_ttl` is left as the operator declared it, including absent. Whether absent is legal
3951/// depends on `lifecycle.after_task`, which is a different block of the same manifest and a
3952/// different kind of question — one about what survives teardown rather than about whether a
3953/// value is well-formed. The runtime asks it, at launch, and refuses with `E-CAP-008`.
3954fn parse_peer_files_export(
3955    raw: RawPeerFilesExport,
3956) -> Result<PeerFilesExport, RuntimeManifestError> {
3957    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
3958        field: field.to_string(),
3959        message,
3960    };
3961
3962    let root = raw
3963        .root
3964        .map(|root| root.trim().to_string())
3965        .filter(|root| !root.is_empty())
3966        .ok_or_else(|| {
3967            invalid(
3968                "exports.peer_files.root",
3969                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
3970            )
3971        })?;
3972    check_export_root_shape("exports.peer_files.root", &root)?;
3973
3974    let max_ttl_secs = match raw.max_ttl {
3975        None => None,
3976        Some(value) => {
3977            let text = scalar_text(&value).ok_or_else(|| {
3978                invalid(
3979                    "exports.peer_files.max_ttl",
3980                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
3981                )
3982            })?;
3983            let parsed = parse_duration_secs(&text)
3984                .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
3985            if parsed == 0 {
3986                return Err(invalid(
3987                    "exports.peer_files.max_ttl",
3988                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
3989                ));
3990            }
3991            Some(parsed)
3992        }
3993    };
3994
3995    let max_bytes = match raw.max_bytes {
3996        None => DEFAULT_PEER_FILES_MAX_BYTES,
3997        Some(value) => {
3998            let text = scalar_text(&value).ok_or_else(|| {
3999                invalid(
4000                    "exports.peer_files.max_bytes",
4001                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4002                )
4003            })?;
4004            let parsed = parse_byte_size(&text)
4005                .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
4006            if parsed == 0 {
4007                return Err(invalid(
4008                    "exports.peer_files.max_bytes",
4009                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4010                ));
4011            }
4012            parsed
4013        }
4014    };
4015
4016    Ok(PeerFilesExport {
4017        root,
4018        max_ttl_secs,
4019        max_bytes,
4020    })
4021}
4022
4023/// A YAML scalar as the text the byte-size and duration parsers expect, or `None` for a mapping
4024/// or a sequence — a shape neither parser could report on without quoting the whole node.
4025/// `Some` for a key that is present, whatever its value — including an explicit null.
4026///
4027/// The plain `Option<serde_yaml::Value>` a `#[serde(default)]` field gets collapses `retain:`
4028/// with nothing under it into the same `None` an absent key produces, and those two say opposite
4029/// things: absent means "keep everything", and empty is refused.
4030fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_yaml::Value>, D::Error>
4031where
4032    D: serde::Deserializer<'de>,
4033{
4034    serde_yaml::Value::deserialize(deserializer).map(Some)
4035}
4036
4037fn scalar_text(value: &serde_yaml::Value) -> Option<String> {
4038    match value {
4039        serde_yaml::Value::String(text) => Some(text.clone()),
4040        serde_yaml::Value::Number(number) => Some(number.to_string()),
4041        _ => None,
4042    }
4043}
4044
4045/// The shared `root` shape rule: relative, no `..`, no absolute prefix. Both export blocks
4046/// resolve their root against the same accessible workdir, so both refuse the same shapes with
4047/// the same sentence.
4048fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
4049    for component in Path::new(root).components() {
4050        match component {
4051            std::path::Component::Normal(_) | std::path::Component::CurDir => {}
4052            std::path::Component::ParentDir => {
4053                return Err(RuntimeManifestError::InvalidExports {
4054                    field: field.to_string(),
4055                    message: format!(
4056                        "'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}"
4057                    ),
4058                });
4059            }
4060            std::path::Component::RootDir | std::path::Component::Prefix(_) => {
4061                return Err(RuntimeManifestError::InvalidExports {
4062                    field: field.to_string(),
4063                    message: format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}"),
4064                });
4065            }
4066        }
4067    }
4068    Ok(())
4069}
4070
4071fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
4072    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4073        field: field.to_string(),
4074        message,
4075    };
4076
4077    let root = raw
4078        .root
4079        .map(|root| root.trim().to_string())
4080        .filter(|root| !root.is_empty())
4081        .ok_or_else(|| {
4082            invalid(
4083                "exports.files.root",
4084                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4085            )
4086        })?;
4087    check_export_root_shape("exports.files.root", &root)?;
4088
4089    let mode = match raw.mode.as_deref().map(str::trim) {
4090        None | Some("") => {
4091            return Err(invalid(
4092                "exports.files.mode",
4093                "is required and must be 'read-only'".to_string(),
4094            ));
4095        }
4096        Some("read-only") => ExportMode::ReadOnly,
4097        Some(other) => {
4098            return Err(invalid(
4099                "exports.files.mode",
4100                format!("'{other}' must be 'read-only'"),
4101            ));
4102        }
4103    };
4104
4105    let max_bytes = match raw.max_bytes {
4106        None => DEFAULT_EXPORT_MAX_BYTES,
4107        Some(value) => {
4108            let Some(text) = scalar_text(&value) else {
4109                return Err(invalid(
4110                    "exports.files.max_bytes",
4111                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4112                ));
4113            };
4114            let parsed = parse_byte_size(&text)
4115                .map_err(|message| invalid("exports.files.max_bytes", message))?;
4116            if parsed == 0 {
4117                return Err(invalid(
4118                    "exports.files.max_bytes",
4119                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4120                ));
4121            }
4122            parsed
4123        }
4124    };
4125
4126    Ok(FileExport {
4127        root,
4128        mode,
4129        max_bytes,
4130    })
4131}
4132
4133/// The accepted spelling of `exports.files.root`, stated once so every rejection says the same
4134/// thing about what a legal root looks like.
4135const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
4136
4137/// The accepted spelling of `capabilities.peer_fetch.allow`, stated once.
4138pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
4139
4140/// Structural check on one network destination: an `http`/`https` URL with no path, query or
4141/// fragment, or a bare `host[:port]`.
4142///
4143/// Deliberately shape-only. The authoritative matcher is the runtime's network-rule parser, which
4144/// this crate cannot see; what this catches is the class of entry that could never become a rule
4145/// at all, at the line that wrote it rather than at launch.
4146fn check_network_destination_shape(entry: &str) -> Result<(), String> {
4147    let trimmed = entry.trim();
4148    if trimmed.is_empty() {
4149        return Err(format!(
4150            "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4151        ));
4152    }
4153
4154    if trimmed.contains("://") {
4155        let url = Url::parse(trimmed).map_err(|error| {
4156            format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
4157        })?;
4158        let scheme = url.scheme().to_ascii_lowercase();
4159        if scheme != "http" && scheme != "https" {
4160            return Err(format!(
4161                "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
4162                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4163            ));
4164        }
4165        if url.host_str().is_none_or(str::is_empty) {
4166            return Err(format!(
4167                "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4168            ));
4169        }
4170        if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
4171            return Err(format!(
4172                "'{entry}' must not include a path, query or fragment; \
4173                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4174            ));
4175        }
4176        return Ok(());
4177    }
4178
4179    if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
4180        return Err(format!(
4181            "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4182        ));
4183    }
4184    // Split from the right so an IPv6 literal's own colons are not mistaken for a port separator.
4185    if let Some((host, port)) = trimmed.rsplit_once(':') {
4186        if !host.ends_with(']') {
4187            if host.is_empty() {
4188                return Err(format!(
4189                    "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4190                ));
4191            }
4192            if port.parse::<u16>().is_err() {
4193                return Err(format!(
4194                    "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4195                ));
4196            }
4197        }
4198    }
4199    Ok(())
4200}
4201
4202fn parse_capabilities(
4203    raw: Option<RawCapabilities>,
4204) -> Result<Option<Capabilities>, RuntimeManifestError> {
4205    let Some(raw_caps) = raw else {
4206        return Ok(None);
4207    };
4208
4209    let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
4210        allow: raw_network.allow,
4211        unix_sockets: raw_network.unix_sockets,
4212    });
4213
4214    let peer_fetch = raw_caps
4215        .peer_fetch
4216        .map(|raw_peer_fetch| {
4217            if raw_peer_fetch.allow.is_empty() {
4218                return Err(RuntimeManifestError::InvalidCapabilities {
4219                    field: "capabilities.peer_fetch.allow".to_string(),
4220                    message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
4221                });
4222            }
4223            for entry in &raw_peer_fetch.allow {
4224                check_network_destination_shape(entry).map_err(|message| {
4225                    RuntimeManifestError::InvalidCapabilities {
4226                        field: "capabilities.peer_fetch.allow".to_string(),
4227                        message,
4228                    }
4229                })?;
4230            }
4231            Ok(PeerFetchCapabilities {
4232                allow: raw_peer_fetch.allow,
4233            })
4234        })
4235        .transpose()?;
4236
4237    let filesystem = raw_caps
4238        .filesystem
4239        .map(|raw_filesystem| FilesystemCapabilities {
4240            scope: raw_filesystem.scope.and_then(|scope| {
4241                let trimmed = scope.trim();
4242                (!trimmed.is_empty()).then(|| trimmed.to_string())
4243            }),
4244            // Copied straight across: a bool has no empty/whitespace case to normalise, and there
4245            // is nothing to validate here — the *consequence* of declaring it (no `scoped`, and a
4246            // refusal when `capabilities.containment: scoped` is also declared) belongs to the
4247            // runtime's containment layer, not to manifest parsing.
4248            workdir_exec: raw_filesystem.workdir_exec,
4249            // Normalised on exactly the terms `scope` above is: trimmed, and an entry left empty
4250            // by that trim is dropped rather than carried as a rule covering nothing. Shape is
4251            // not judged here either — an absolute or escaping entry is the runtime's refusal.
4252            read_only: raw_filesystem
4253                .read_only
4254                .into_iter()
4255                .filter_map(|entry| {
4256                    let trimmed = entry.trim();
4257                    (!trimmed.is_empty()).then(|| trimmed.to_string())
4258                })
4259                .collect(),
4260        });
4261
4262    let shell = raw_caps
4263        .shell
4264        .map(|raw_shell| {
4265            if raw_shell.allow.is_empty() {
4266                return Err(RuntimeManifestError::InvalidCapabilities {
4267                    field: "capabilities.shell.allow".to_string(),
4268                    message: "must contain at least one binary".to_string(),
4269                });
4270            }
4271
4272            let interpreter_runtime =
4273                parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
4274            // Parsed after `interpreter_runtime` because the mutual-exclusion rule needs the
4275            // already-validated grant list to test against.
4276            let staged_runtime = parse_staged_runtime(
4277                &raw_shell.allow,
4278                &interpreter_runtime,
4279                raw_shell.staged_runtime,
4280            )?;
4281
4282            Ok(ShellCapabilities {
4283                allow: raw_shell.allow,
4284                strip_env: raw_shell.strip_env,
4285                baseline_env: raw_shell.baseline_env,
4286                interpreter_runtime,
4287                staged_runtime,
4288            })
4289        })
4290        .transpose()?;
4291
4292    let spawn = raw_caps.spawn.map(|raw_spawn| SpawnCapabilities {
4293        allow: raw_spawn.allow,
4294    });
4295
4296    let env = raw_caps.env.map(|raw_env| EnvCapabilities {
4297        allow: raw_env.allow,
4298    });
4299
4300    let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
4301
4302    let resources = raw_caps
4303        .resources
4304        .map(parse_resource_capabilities)
4305        .transpose()?;
4306
4307    let state = raw_caps.state.map(parse_state_capabilities);
4308
4309    let task_io = raw_caps
4310        .task_io
4311        .map(|raw_task_io| {
4312            raw_task_io
4313                .read
4314                .map(|read| TaskIoCapabilities { read })
4315                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4316                    field: "capabilities.task_io.read".to_string(),
4317                    message: "must be set explicitly to true or false — a capability is never \
4318                              inferred"
4319                        .to_string(),
4320                })
4321        })
4322        .transpose()?;
4323
4324    let conversation = raw_caps
4325        .conversation
4326        .map(|raw_conversation| {
4327            raw_conversation
4328                .read
4329                .map(|read| ConversationCapabilities { read })
4330                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4331                    field: "capabilities.conversation.read".to_string(),
4332                    message: "must be set explicitly to true or false — a capability is never \
4333                              inferred"
4334                        .to_string(),
4335                })
4336        })
4337        .transpose()?;
4338
4339    let plan = raw_caps
4340        .plan
4341        .map(|raw_plan| {
4342            raw_plan
4343                .submit
4344                .map(|submit| PlanCapabilities { submit })
4345                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4346                    field: "capabilities.plan.submit".to_string(),
4347                    message: "must be set explicitly to true or false — a capability is never \
4348                              inferred"
4349                        .to_string(),
4350                })
4351        })
4352        .transpose()?;
4353
4354    let install = raw_caps.install.map(parse_install).transpose()?;
4355
4356    let containment = raw_caps
4357        .containment
4358        .as_deref()
4359        .map(str::trim)
4360        .filter(|value| !value.is_empty())
4361        .map(|value| {
4362            value.parse::<ContainmentClass>().map_err(|error| {
4363                RuntimeManifestError::InvalidCapabilities {
4364                    field: "capabilities.containment".to_string(),
4365                    message: error.to_string(),
4366                }
4367            })
4368        })
4369        .transpose()?;
4370
4371    Ok(Some(Capabilities {
4372        network,
4373        peer_fetch,
4374        filesystem,
4375        shell,
4376        spawn,
4377        env,
4378        limits,
4379        resources,
4380        state,
4381        task_io,
4382        conversation,
4383        plan,
4384        install,
4385        containment,
4386    }))
4387}
4388
4389/// Lower `capabilities.install`, refusing a block that names nothing and any entry outside
4390/// [`crate::install_pattern`]'s grammar.
4391fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
4392    if raw.skill.is_empty() && raw.tool.is_empty() {
4393        return Err(RuntimeManifestError::InvalidCapabilities {
4394            field: "capabilities.install".to_string(),
4395            message: "must name at least one 'skill' or 'tool' entry — a capability is never \
4396                      inferred; omit the block to grant no install at all"
4397                .to_string(),
4398        });
4399    }
4400    for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
4401        for entry in entries {
4402            crate::install_pattern::validate(entry).map_err(|reason| {
4403                RuntimeManifestError::InvalidCapabilities {
4404                    field: format!("capabilities.install.{key}"),
4405                    message: format!(
4406                        "entry {entry:?} is invalid ({reason}); expected {}",
4407                        crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
4408                    ),
4409                }
4410            })?;
4411        }
4412    }
4413    Ok(InstallCapabilities {
4414        skill: raw.skill,
4415        tool: raw.tool,
4416    })
4417}
4418
4419/// Lower `capabilities.state`. Infallible: the only field is a name, and a name's *shape* is a
4420/// runtime question (does it resolve to one directory under `~/.murmur/state/`?) rather than a
4421/// manifest-parse one — `capsule_runtime::state_store::validate_store_name` answers it and refuses
4422/// with `E-CAP-009`.
4423///
4424/// Surrounding whitespace is trimmed but an emptied value is *kept*, unlike
4425/// `capabilities.filesystem.scope`, which normalises empty to absent. `store: ""` is a written
4426/// declaration that names nothing, and silently defaulting it to the capsule name would hand a
4427/// store to someone who asked for a different one; it reaches the runtime and is refused there.
4428fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
4429    StateCapabilities {
4430        store: raw.store.map(|store| store.trim().to_string()),
4431    }
4432}
4433
4434/// Lower and validate `capabilities.shell.interpreter_runtime`. Every rejection is an
4435/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending value, matching the rest
4436/// of this file's "structurally present but semantically invalid" reporting. Nothing here can
4437/// expand a whole install prefix: each grant names specific directories, and each directory
4438/// carries an explicit `list_dir` the author had to write — enumerability is never inferred.
4439fn parse_interpreter_runtime(
4440    allow: &[String],
4441    raw_grants: Vec<RawInterpreterRuntimeGrant>,
4442) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
4443    let mut grants = Vec::with_capacity(raw_grants.len());
4444    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
4445        let base = format!("capabilities.shell.interpreter_runtime[{index}]");
4446
4447        let binary = raw_grant
4448            .binary
4449            .filter(|b| !b.trim().is_empty())
4450            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4451                field: format!("{base}.binary"),
4452                message: "must name a binary".to_string(),
4453            })?;
4454
4455        // The whole point of this mechanism: it narrows filesystem access alongside an exec
4456        // grant that already exists. It can never itself grant exec, so the binary must already
4457        // be allowlisted in this same block's `allow`.
4458        if !allow.contains(&binary) {
4459            return Err(RuntimeManifestError::InvalidCapabilities {
4460                field: format!("{base}.binary"),
4461                message: format!(
4462                    "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
4463                     only narrow filesystem access for an already-allowlisted binary, never \
4464                     grant exec"
4465                ),
4466            });
4467        }
4468
4469        if raw_grant.dirs.is_empty() {
4470            return Err(RuntimeManifestError::InvalidCapabilities {
4471                field: format!("{base}.dirs"),
4472                message: "must name at least one directory".to_string(),
4473            });
4474        }
4475
4476        let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
4477        for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
4478            let dir_base = format!("{base}.dirs[{dir_index}]");
4479
4480            let path = raw_dir
4481                .path
4482                .filter(|p| !p.trim().is_empty())
4483                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4484                    field: format!("{dir_base}.path"),
4485                    message: "must name a host directory".to_string(),
4486                })?;
4487            if !path.starts_with('/') {
4488                return Err(RuntimeManifestError::InvalidCapabilities {
4489                    field: format!("{dir_base}.path"),
4490                    message: format!(
4491                        "'{path}' must be an absolute host path (start with '/') — these are \
4492                         filesystem paths outside the workdir"
4493                    ),
4494                });
4495            }
4496
4497            let list_dir =
4498                raw_dir
4499                    .list_dir
4500                    .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4501                        field: format!("{dir_base}.list_dir"),
4502                        message: format!(
4503                            "'{path}' must set list_dir explicitly to true or false — \
4504                         enumerability is never inferred"
4505                        ),
4506                    })?;
4507
4508            dirs.push(InterpreterRuntimeDir { path, list_dir });
4509        }
4510
4511        grants.push(InterpreterRuntimeGrant { binary, dirs });
4512    }
4513
4514    Ok(grants)
4515}
4516
4517/// Lower and validate `capabilities.shell.staged_runtime`, given this same block's `allow` list
4518/// and its already-validated `interpreter_runtime` grants. Every rejection is a
4519/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending field, matching
4520/// [`parse_interpreter_runtime`] and the rest of this file.
4521///
4522/// Four rules, each of them a thing that would otherwise be silently wrong at launch rather than
4523/// loudly wrong here:
4524///
4525///   1. `binary` must already be in `allow` — staging a runtime tree never itself grants exec.
4526///   2. `binary` must not also carry an `interpreter_runtime` grant — the two are alternatives,
4527///      and declaring both means the author expects a host-widening grant that a composed root
4528///      makes both unnecessary and (once Landlock re-installs inside the root) meaningless.
4529///   3. `source_path` must be absolute — it is a host path outside the workdir, resolved by the
4530///      launch host and not relative to anything the runtime knows.
4531///   4. `pin` must be present and non-empty — it is the value a human compares across two hosts,
4532///      so an absent one defeats the field's only purpose.
4533///
4534/// Nothing here touches the filesystem: whether `source_path` exists on *this* host is a launch
4535/// -time fact, not a manifest-validity one, and a manifest must stay parseable on a machine that
4536/// will never run it (`mur build` on a laptop for a Linux fleet).
4537fn parse_staged_runtime(
4538    allow: &[String],
4539    interpreter_runtime: &[InterpreterRuntimeGrant],
4540    raw_grants: Vec<RawStagedRuntimeGrant>,
4541) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
4542    let mut grants = Vec::with_capacity(raw_grants.len());
4543    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
4544        let base = format!("capabilities.shell.staged_runtime[{index}]");
4545
4546        let binary = raw_grant
4547            .binary
4548            .filter(|b| !b.trim().is_empty())
4549            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4550                field: format!("{base}.binary"),
4551                message: "must name a binary".to_string(),
4552            })?;
4553
4554        if !allow.contains(&binary) {
4555            return Err(RuntimeManifestError::InvalidCapabilities {
4556                field: format!("{base}.binary"),
4557                message: format!(
4558                    "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
4559                     stage the runtime tree behind an already-allowlisted binary, never grant exec"
4560                ),
4561            });
4562        }
4563
4564        if interpreter_runtime
4565            .iter()
4566            .any(|grant| grant.binary == binary)
4567        {
4568            return Err(RuntimeManifestError::InvalidCapabilities {
4569                field: format!("{base}.binary"),
4570                message: format!(
4571                    "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
4572                     are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
4573                     tree into the capsule's own root, which is what makes widening the capsule's \
4574                     host filesystem scope unnecessary rather than something to add to it. Remove \
4575                     the interpreter_runtime grant for '{binary}'"
4576                ),
4577            });
4578        }
4579
4580        let source_path = raw_grant
4581            .source_path
4582            .filter(|p| !p.trim().is_empty())
4583            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4584                field: format!("{base}.source_path"),
4585                message: "must name the host directory holding the pinned runtime tree".to_string(),
4586            })?;
4587        if !source_path.starts_with('/') {
4588            return Err(RuntimeManifestError::InvalidCapabilities {
4589                field: format!("{base}.source_path"),
4590                message: format!(
4591                    "'{source_path}' must be an absolute host path (start with '/') — \
4592                     staged-runtime sources are host filesystem paths outside the workdir"
4593                ),
4594            });
4595        }
4596
4597        let pin = raw_grant
4598            .pin
4599            .map(|p| p.trim().to_string())
4600            .filter(|p| !p.is_empty())
4601            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4602                field: format!("{base}.pin"),
4603                message: format!(
4604                    "'{source_path}' must declare a non-empty pin identifying which build this \
4605                     tree is — it is never inferred, because it is the value a human compares \
4606                     across two hosts to confirm the same runtime shipped to both"
4607                ),
4608            })?;
4609
4610        grants.push(StagedRuntimeGrant {
4611            binary,
4612            source_path,
4613            pin,
4614        });
4615    }
4616
4617    Ok(grants)
4618}
4619
4620/// Lower `capabilities.limits`, rejecting values that can only ever produce a guest that
4621/// traps on its first instruction. A zero cap is always a manifest authoring mistake, and
4622/// catching it here means `mur run` fails at parse time with a field name rather than
4623/// surfacing much later as an opaque wasm trap.
4624fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
4625    let reject_zero_usize =
4626        |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
4627            match value {
4628                Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
4629                    field: format!("capabilities.limits.{field}"),
4630                    message: "must be greater than zero".to_string(),
4631                }),
4632                _ => Ok(()),
4633            }
4634        };
4635
4636    reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
4637    reject_zero_usize(raw.table_elements, "table_elements")?;
4638    reject_zero_usize(raw.instances, "instances")?;
4639    if raw.deadline_seconds == Some(0) {
4640        return Err(RuntimeManifestError::InvalidCapabilities {
4641            field: "capabilities.limits.deadline_seconds".to_string(),
4642            message: "must be greater than zero".to_string(),
4643        });
4644    }
4645
4646    Ok(ResourceLimits {
4647        memory_bytes: raw.memory_bytes,
4648        table_elements: raw.table_elements,
4649        instances: raw.instances,
4650        deadline_seconds: raw.deadline_seconds,
4651    })
4652}
4653
4654/// Lower and validate `capabilities.resources`. Mirrors [`parse_resource_limits`]: nothing is
4655/// defaulted here (the runtime owns the defaults, so "omitted" must stay distinguishable from
4656/// "declared"), and a declared `0` is rejected outright on every field — a zero ceiling is
4657/// never what an author means, and letting it through would turn a typo into a subprocess that
4658/// cannot fork, open a file, or run for a single CPU-second.
4659fn parse_resource_capabilities(
4660    raw: RawResourceCapabilities,
4661) -> Result<ResourceCapabilities, RuntimeManifestError> {
4662    let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
4663        match value {
4664            Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
4665                field: format!("capabilities.resources.{field}"),
4666                message: "must be greater than zero".to_string(),
4667            }),
4668            _ => Ok(()),
4669        }
4670    };
4671
4672    reject_zero(raw.max_processes, "max_processes")?;
4673    reject_zero(raw.max_open_files, "max_open_files")?;
4674    reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
4675    reject_zero(raw.cpu_seconds, "cpu_seconds")?;
4676    reject_zero(raw.memory_bytes, "memory_bytes")?;
4677    reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
4678    reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
4679    reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
4680    reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
4681    reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
4682
4683    Ok(ResourceCapabilities {
4684        max_processes: raw.max_processes,
4685        max_open_files: raw.max_open_files,
4686        max_file_size_bytes: raw.max_file_size_bytes,
4687        cpu_seconds: raw.cpu_seconds,
4688        memory_bytes: raw.memory_bytes,
4689        cgroup_memory_bytes: raw.cgroup_memory_bytes,
4690        cgroup_pids_max: raw.cgroup_pids_max,
4691        cgroup_cpu_percent: raw.cgroup_cpu_percent,
4692        cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
4693        workdir_max_bytes: raw.workdir_max_bytes,
4694    })
4695}
4696
4697/// Why a `transport: process` manifest with no `inference.driver` is refused. Names one driver
4698/// artifact by way of example: this is the one message a reader reaches with nothing to go on, and
4699/// an abstract instruction to "name a process driver" leaves them no way to find one.
4700const PROCESS_DRIVER_REQUIRED: &str =
4701    "missing required field; a transport: process capsule must name the process driver that \
4702     knows how to drive its harness. inference.command no longer selects a harness — it only \
4703     overrides the binary the driver names. Add inference.driver.artifact naming a process \
4704     driver, for example murmur-driver-claude-code";
4705
4706fn parse_inference(
4707    raw: Option<RawInferenceConfig>,
4708) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
4709    let Some(raw) = raw else {
4710        return Ok(None);
4711    };
4712
4713    let transport = raw
4714        .transport
4715        .map(|s| s.trim().to_string())
4716        .filter(|s| !s.is_empty())
4717        .unwrap_or_else(|| "http".to_string());
4718
4719    let compaction = parse_compaction(raw.compaction)?;
4720    let system_prompt = optional_trimmed_string(raw.system_prompt);
4721    let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
4722    let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
4723
4724    let prompt_sources_set = [
4725        system_prompt.is_some(),
4726        system_prompt_file.is_some(),
4727        system_prompt_artifact.is_some(),
4728    ]
4729    .iter()
4730    .filter(|&&b| b)
4731    .count();
4732    if prompt_sources_set > 1 {
4733        return Err(RuntimeManifestError::InvalidInferenceConfig {
4734            field: "inference.system_prompt".to_string(),
4735            message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
4736                      may be set"
4737                .to_string(),
4738        });
4739    }
4740
4741    let max_turns = match raw.max_turns {
4742        None => 10,
4743        Some(0) => {
4744            return Err(RuntimeManifestError::InvalidInferenceConfig {
4745                field: "inference.max_turns".to_string(),
4746                message: "must be greater than 0".to_string(),
4747            });
4748        }
4749        Some(n) => n,
4750    };
4751
4752    // The reopen budget is a lifecycle knob, read from `lifecycle.max_task_reopens`. Nothing
4753    // here consumes the `inference` spelling, so it would be silently inert — reject it rather
4754    // than let it look effective.
4755    if raw.max_task_reopens.is_some() {
4756        return Err(RuntimeManifestError::InvalidInferenceConfig {
4757            field: "inference.max_task_reopens".to_string(),
4758            message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
4759                .to_string(),
4760        });
4761    }
4762
4763    // Left to the unknown-key pass, either field would only draw a warning and the capsule would
4764    // launch with no upstream or credential bound, so each is refused by name, pointing at the
4765    // driver entry's `gateway:` block. Nothing translates it.
4766    for (present, removed, moved_to) in [
4767        (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
4768        (raw.api_key.is_some(), "api_key", "gateway.api_key"),
4769    ] {
4770        if !present {
4771            continue;
4772        }
4773        let entry = raw
4774            .driver
4775            .as_ref()
4776            .and_then(|driver| driver.artifact.as_deref())
4777            .map(str::trim)
4778            .filter(|name| !name.is_empty())
4779            .map(|name| format!("on the artifacts: entry '{name}'"))
4780            .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
4781        return Err(RuntimeManifestError::InvalidInferenceConfig {
4782            field: format!("inference.{removed}"),
4783            message: format!("was removed; set {moved_to} {entry} instead"),
4784        });
4785    }
4786
4787    match transport.as_str() {
4788        "http" => {
4789            let model = required_inference_field(raw.model, "model")?;
4790
4791            let driver_raw =
4792                raw.driver
4793                    .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
4794                        field: "inference.driver".to_string(),
4795                        message: "missing required field".to_string(),
4796                    })?;
4797            let driver = parse_inference_driver(driver_raw)?;
4798
4799            // Advisory only: catch an authoring typo at parse time rather than as a confusing
4800            // provider 400. Large values are deliberately NOT clamped — a ceiling here would
4801            // block a model whose real limit is higher than anything we could hard-code.
4802            if raw.max_tokens == Some(0) {
4803                return Err(RuntimeManifestError::InvalidInferenceConfig {
4804                    field: "inference.max_tokens".to_string(),
4805                    message: "must be greater than 0".to_string(),
4806                });
4807            }
4808            if raw.max_session_tokens == Some(0) {
4809                return Err(RuntimeManifestError::InvalidInferenceConfig {
4810                    field: "inference.max_session_tokens".to_string(),
4811                    message: "must be greater than 0".to_string(),
4812                });
4813            }
4814            let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
4815            let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
4816
4817            Ok(Some(InferenceConfig {
4818                transport,
4819                model,
4820                driver: Some(driver),
4821                command: None,
4822                compaction,
4823                system_prompt,
4824                system_prompt_file,
4825                system_prompt_artifact,
4826                max_turns,
4827                max_tokens: raw.max_tokens,
4828                max_session_tokens: raw.max_session_tokens,
4829                tool_refresh,
4830                alternates,
4831            }))
4832        }
4833        "process" => {
4834            // The CLI subprocess path never builds a driver payload, so this value would be
4835            // silently inert here — reject it rather than let it look effective.
4836            if raw.max_tokens.is_some() {
4837                return Err(RuntimeManifestError::InvalidInferenceConfig {
4838                    field: "inference.max_tokens".to_string(),
4839                    message: "is not valid with transport: process".to_string(),
4840                });
4841            }
4842            // The harness owns its tool list and runs its own compaction, so neither trigger has a
4843            // turn boundary here to act on.
4844            if raw.tool_refresh.is_some() {
4845                return Err(RuntimeManifestError::InvalidInferenceConfig {
4846                    field: "inference.tool_refresh".to_string(),
4847                    message: "is not valid with transport: process".to_string(),
4848                });
4849            }
4850            // The harness owns the conversation and the model it runs, so there is no agent-loop
4851            // call for a switch to reroute.
4852            if raw.alternates.is_some() {
4853                return Err(RuntimeManifestError::InvalidInferenceConfig {
4854                    field: "inference.alternates".to_string(),
4855                    message: "is not valid with transport: process; the harness owns the \
4856                              conversation and the model it runs"
4857                        .to_string(),
4858                });
4859            }
4860
4861            // Valid here: the harness's own reported token counts are what this transport's
4862            // turns are metered against, and a driver that reports none is refused at load
4863            // rather than run for ever beneath a ceiling nothing could reach.
4864            if raw.max_session_tokens == Some(0) {
4865                return Err(RuntimeManifestError::InvalidInferenceConfig {
4866                    field: "inference.max_session_tokens".to_string(),
4867                    message: "must be greater than 0".to_string(),
4868                });
4869            }
4870
4871            let driver = raw.driver.map(parse_inference_driver).transpose()?;
4872            let command = optional_trimmed_string(raw.command);
4873            // The driver is what knows how to drive a harness CLI: its flags, its output format,
4874            // its version probe. `command` only says which executable to run instead of the one
4875            // the driver names, so it selects nothing on its own.
4876            if driver.is_none() {
4877                return Err(RuntimeManifestError::InvalidInferenceConfig {
4878                    field: "inference.driver.artifact".to_string(),
4879                    message: PROCESS_DRIVER_REQUIRED.to_string(),
4880                });
4881            }
4882            // model is OPTIONAL for transport: process — an empty string leaves the choice of
4883            // model to the harness, which has an account default of its own. The driver is handed
4884            // `none` rather than an empty string, so it never passes a model flag with no value.
4885            let model = optional_trimmed_string(raw.model).unwrap_or_default();
4886
4887            Ok(Some(InferenceConfig {
4888                transport,
4889                model,
4890                driver,
4891                command,
4892                compaction,
4893                system_prompt,
4894                system_prompt_file,
4895                system_prompt_artifact,
4896                max_turns,
4897                max_tokens: None,
4898                max_session_tokens: raw.max_session_tokens,
4899                tool_refresh: ToolRefresh::default(),
4900                alternates: Vec::new(),
4901            }))
4902        }
4903        other => Err(RuntimeManifestError::InvalidInferenceConfig {
4904            field: "inference.transport".to_string(),
4905            message: format!("unknown value '{other}'"),
4906        }),
4907    }
4908}
4909
4910/// Whether `name` is an alternate's name: a lowercase letter, then up to 62 lowercase letters,
4911/// digits and hyphens.
4912fn is_valid_driver_choice_name(name: &str) -> bool {
4913    let mut chars = name.chars();
4914    chars.next().is_some_and(|first| first.is_ascii_lowercase())
4915        && name.len() <= 63
4916        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
4917}
4918
4919/// Lowers `inference.alternates` under `transport: http`, with the rules one entry and the list
4920/// can be judged on alone. What needs `artifacts:` is [`validate_driver_choices`]'s.
4921///
4922/// `primary_driver` and `primary_model` are the primary choice's pair, which no alternate may
4923/// repeat: two names for one pair would make a switch between them a change of nothing.
4924fn parse_alternates(
4925    raw: Option<Vec<RawInferenceAlternate>>,
4926    primary_driver: &str,
4927    primary_model: &str,
4928) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
4929    let mut alternates: Vec<InferenceAlternate> = Vec::new();
4930    for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
4931        let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
4932            field: if key.is_empty() {
4933                format!("inference.alternates[{index}]")
4934            } else {
4935                format!("inference.alternates[{index}].{key}")
4936            },
4937            message,
4938        };
4939        let name = optional_trimmed_string(entry.name)
4940            .ok_or_else(|| refuse("name", "missing required field".to_string()))?;
4941        if name == PRIMARY_DRIVER_CHOICE {
4942            return Err(refuse(
4943                "name",
4944                format!(
4945                    "'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
4946                     inference.model; name the alternate something else"
4947                ),
4948            ));
4949        }
4950        if !is_valid_driver_choice_name(&name) {
4951            return Err(refuse(
4952                "name",
4953                format!(
4954                    "'{name}' is not a driver choice name; a name is a lowercase letter followed \
4955                     by up to 62 lowercase letters, digits and hyphens"
4956                ),
4957            ));
4958        }
4959        if alternates.iter().any(|alternate| alternate.name == name) {
4960            return Err(refuse(
4961                "name",
4962                format!("'{name}' names more than one alternate"),
4963            ));
4964        }
4965        let model = optional_trimmed_string(entry.model)
4966            .ok_or_else(|| refuse("model", "missing required field".to_string()))?;
4967        let Some(driver) = entry.driver else {
4968            return Err(refuse(
4969                "driver.artifact",
4970                "missing required field".to_string(),
4971            ));
4972        };
4973        if driver
4974            .config
4975            .as_ref()
4976            .is_some_and(|config| !matches!(config, serde_yaml::Value::Null))
4977        {
4978            return Err(refuse(
4979                "driver.config",
4980                "is not valid on an alternate; inference.driver.config applies to every \
4981                 declared driver, and per-driver settings belong in that driver's \
4982                 artifacts[].config"
4983                    .to_string(),
4984            ));
4985        }
4986        let driver = optional_trimmed_string(driver.artifact)
4987            .ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
4988        if driver == primary_driver && model == primary_model {
4989            return Err(refuse(
4990                "",
4991                format!(
4992                    "'{name}' is driver '{driver}' with model '{model}', which is the primary \
4993                     choice; an alternate names a different pair"
4994                ),
4995            ));
4996        }
4997        if let Some(twin) = alternates
4998            .iter()
4999            .find(|alternate| alternate.driver == driver && alternate.model == model)
5000        {
5001            return Err(refuse(
5002                "",
5003                format!(
5004                    "'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
5005                     already is; an alternate names a different pair",
5006                    twin.name
5007                ),
5008            ));
5009        }
5010        alternates.push(InferenceAlternate {
5011            name,
5012            model,
5013            driver,
5014        });
5015    }
5016    Ok(alternates)
5017}
5018
5019/// `inference.tool_refresh` under `transport: http`: absent is [`ToolRefresh::Compaction`].
5020fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
5021    let Some(value) = raw else {
5022        return Ok(ToolRefresh::default());
5023    };
5024    ToolRefresh::ALL
5025        .into_iter()
5026        .find(|refresh| refresh.wire_name() == value)
5027        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5028            field: "inference.tool_refresh".to_string(),
5029            message: format!(
5030                "must be one of: {}; got '{value}'",
5031                ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
5032            ),
5033        })
5034}
5035
5036fn parse_context(
5037    raw: Option<RawContextConfig>,
5038) -> Result<Option<ContextConfig>, RuntimeManifestError> {
5039    let Some(raw) = raw else {
5040        return Ok(None);
5041    };
5042
5043    if let Some(max_tokens) = raw.max_tokens {
5044        if max_tokens == 0 {
5045            return Err(RuntimeManifestError::InvalidInferenceConfig {
5046                field: "context.max_tokens".to_string(),
5047                message: "must be greater than 0".to_string(),
5048            });
5049        }
5050    }
5051
5052    let fraction = |field: &str, value: Option<f32>, default: f32| match value {
5053        None => Ok(default),
5054        Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
5055        Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
5056            field: field.to_string(),
5057            message: "must be between 0.0 and 1.0".to_string(),
5058        }),
5059    };
5060
5061    let record = match raw.record.as_deref().map(str::trim) {
5062        None => true,
5063        Some("on") => true,
5064        Some("off") => false,
5065        Some(other) => {
5066            return Err(RuntimeManifestError::InvalidInferenceConfig {
5067                field: "context.record".to_string(),
5068                message: format!("unknown value '{other}'; expected: on, off"),
5069            })
5070        }
5071    };
5072
5073    Ok(Some(ContextConfig {
5074        max_tokens: raw.max_tokens,
5075        record,
5076        record_store: raw.record_store.map(|store| store.trim().to_string()),
5077        seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
5078        seed_overflow_margin: fraction(
5079            "context.seed_overflow_margin",
5080            raw.seed_overflow_margin,
5081            DEFAULT_SEED_OVERFLOW_MARGIN,
5082        )?,
5083        retain: parse_context_retain(raw.retain)?,
5084    }))
5085}
5086
5087/// The keys `trace.retain` accepts, in the order an error lists them.
5088const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
5089
5090/// The keys `context.retain` accepts, in the order an error lists them.
5091const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
5092
5093/// What both `retain:` blocks say when they carry nothing. Stated once so the two errors, which
5094/// travel through different `RuntimeManifestError` variants, cannot drift apart.
5095const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
5096                                  keep everything, which is what no policy means";
5097
5098/// One `retain:` block, reduced to its two recognized keys.
5099///
5100/// Shared by `trace.retain` and `context.retain`: they differ only in the name of the count key,
5101/// so the shape check, the unknown-key refusal and the empty-block refusal are written once.
5102/// `count_key` is `max_sessions` or `max_messages`; `block` is the dotted path an error names.
5103fn parse_retain_block(
5104    block: &str,
5105    count_key: &str,
5106    accepted: &[&str],
5107    raw: serde_yaml::Value,
5108    invalid: &dyn Fn(String, String) -> RuntimeManifestError,
5109) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
5110    let mapping = match raw {
5111        serde_yaml::Value::Mapping(mapping) => mapping,
5112        // `retain:` with nothing under it parses as null and means exactly `retain: {}`.
5113        serde_yaml::Value::Null => {
5114            return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
5115        }
5116        other => {
5117            return Err(invalid(
5118                block.to_string(),
5119                format!(
5120                    "must be a block of keys ({}), got '{other:?}'",
5121                    accepted.join(", ")
5122                ),
5123            ))
5124        }
5125    };
5126    if mapping.is_empty() {
5127        return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
5128    }
5129
5130    let mut count: Option<u32> = None;
5131    let mut max_age_secs: Option<u64> = None;
5132    for (key, value) in &mapping {
5133        let key = key.as_str().unwrap_or_default();
5134        let field = format!("{block}.{key}");
5135        if key == count_key {
5136            let parsed = value
5137                .as_u64()
5138                .and_then(|n| u32::try_from(n).ok())
5139                .ok_or_else(|| {
5140                    invalid(
5141                        field.clone(),
5142                        "must be a whole number of at least 1".to_string(),
5143                    )
5144                })?;
5145            if parsed == 0 {
5146                return Err(invalid(
5147                    field,
5148                    "must be at least 1 — a limit of zero would delete what retention exists \
5149                     to keep; remove the key to leave it unbounded"
5150                        .to_string(),
5151                ));
5152            }
5153            count = Some(parsed);
5154        } else if key == "max_age" {
5155            let text = scalar_text(value).ok_or_else(|| {
5156                invalid(
5157                    field.clone(),
5158                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
5159                )
5160            })?;
5161            let parsed =
5162                parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
5163            if parsed == 0 {
5164                return Err(invalid(
5165                    field,
5166                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
5167                ));
5168            }
5169            max_age_secs = Some(parsed);
5170        } else {
5171            return Err(invalid(
5172                block.to_string(),
5173                format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
5174            ));
5175        }
5176    }
5177    Ok((count, max_age_secs))
5178}
5179
5180fn parse_trace_retain(
5181    raw: Option<serde_yaml::Value>,
5182) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
5183    let Some(raw) = raw else {
5184        return Ok(None);
5185    };
5186    let (max_sessions, max_age_secs) = parse_retain_block(
5187        "trace.retain",
5188        "max_sessions",
5189        &TRACE_RETAIN_KEYS,
5190        raw,
5191        &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
5192    )?;
5193    Ok(Some(TraceRetainConfig {
5194        max_sessions,
5195        max_age_secs,
5196    }))
5197}
5198
5199fn parse_context_retain(
5200    raw: Option<serde_yaml::Value>,
5201) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
5202    let Some(raw) = raw else {
5203        return Ok(None);
5204    };
5205    let (max_messages, max_age_secs) = parse_retain_block(
5206        "context.retain",
5207        "max_messages",
5208        &CONTEXT_RETAIN_KEYS,
5209        raw,
5210        &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
5211    )?;
5212    Ok(Some(ContextRetainConfig {
5213        max_messages,
5214        max_age_secs,
5215    }))
5216}
5217
5218fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
5219    raw.map(|raw| ObservabilityConfig {
5220        otel_endpoint: raw
5221            .otel_endpoint
5222            .map(|value| value.trim().to_string())
5223            .filter(|value| !value.is_empty()),
5224        eval: raw.eval.map(parse_eval_config),
5225    })
5226}
5227
5228fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
5229    let scorers = raw
5230        .scorers
5231        .into_iter()
5232        .filter_map(|raw| {
5233            let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
5234            match raw.scorer_type.as_str() {
5235                "exit_ok" => Some(ScorerConfig::ExitOk { name }),
5236                "max_turns" => Some(ScorerConfig::MaxTurns {
5237                    name,
5238                    max: raw.max.unwrap_or(10) as u32,
5239                }),
5240                "max_tokens" => Some(ScorerConfig::MaxTokens {
5241                    name,
5242                    max: raw.max.unwrap_or(100_000),
5243                }),
5244                "tool_sequence" => Some(ScorerConfig::ToolSequence {
5245                    name,
5246                    expected: raw.expected.unwrap_or_default(),
5247                }),
5248                "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
5249                other => {
5250                    crate::warn_line(format_args!(
5251                        "[murmur-artifact] unknown scorer type '{other}' — skipping"
5252                    ));
5253                    None
5254                }
5255            }
5256        })
5257        .collect();
5258
5259    EvalConfig {
5260        dataset_id: raw.dataset_id,
5261        scorers,
5262    }
5263}
5264
5265fn parse_compaction(
5266    raw: Option<RawCompactionConfig>,
5267) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
5268    let Some(raw) = raw else {
5269        return Ok(None);
5270    };
5271
5272    if let Some(threshold) = raw.threshold {
5273        if threshold <= 0.0 || threshold > 1.0 {
5274            return Err(RuntimeManifestError::InvalidInferenceConfig {
5275                field: "inference.compaction.threshold".to_string(),
5276                message: format!("must be in (0.0, 1.0] but got {threshold}"),
5277            });
5278        }
5279    }
5280
5281    if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
5282        return Err(RuntimeManifestError::InvalidInferenceConfig {
5283            field: "inference.compaction.system_prompt".to_string(),
5284            message: "at most one of inference.compaction.system_prompt, \
5285                      inference.compaction.system_prompt_file may be set"
5286                .to_string(),
5287        });
5288    }
5289
5290    Ok(Some(CompactionConfig {
5291        threshold: raw.threshold,
5292        model: raw.model,
5293        system_prompt: raw.system_prompt,
5294        system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
5295        // A plain optional boolean: nothing to validate, and deliberately independent of the
5296        // threshold-range and prompt-source checks above.
5297        dump_summaries: raw.dump_summaries,
5298    }))
5299}
5300
5301fn optional_trimmed_string(value: Option<String>) -> Option<String> {
5302    value
5303        .map(|s| s.trim().to_string())
5304        .filter(|s| !s.is_empty())
5305}
5306
5307fn required_inference_field(
5308    value: Option<String>,
5309    key: &str,
5310) -> Result<String, RuntimeManifestError> {
5311    value
5312        .map(|value| value.trim().to_string())
5313        .filter(|value| !value.is_empty())
5314        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5315            field: format!("inference.{key}"),
5316            message: "missing required field".to_string(),
5317        })
5318}
5319
5320/// One artifact entry's `gateway:` block, checked and with its key reference kept unresolved.
5321///
5322/// The credential rule reads what the manifest wrote, never a resolved value, so it decides the
5323/// same under [`SecretResolution::Resolve`] and [`SecretResolution::Skip`]: a non-blank `api_key`
5324/// is keyed, `keyless: true` with no `api_key` key is keyless, both together is
5325/// [`RuntimeManifestError::InvalidArtifact`], and anything else is
5326/// [`RuntimeManifestError::GatewayWithoutCredential`]. The endpoint's address plays no part.
5327fn parse_artifact_gateway(
5328    index: usize,
5329    name: &str,
5330    raw: Option<RawArtifactGateway>,
5331    secrets: SecretResolution,
5332) -> Result<ArtifactGateway, RuntimeManifestError> {
5333    let (endpoint, api_key, keyless) = match raw {
5334        Some(raw) => (
5335            optional_trimmed_string(raw.endpoint),
5336            raw.api_key,
5337            raw.keyless,
5338        ),
5339        None => (None, None, None),
5340    };
5341    let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
5342        index,
5343        message: format!(
5344            "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
5345             the upstream URL the runtime sends this artifact's keyed requests to"
5346        ),
5347    })?;
5348    validate_gateway_endpoint(name, &endpoint)
5349        .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
5350
5351    let keyless = match keyless {
5352        None | Some(serde_yaml::Value::Null) | Some(serde_yaml::Value::Bool(false)) => false,
5353        Some(serde_yaml::Value::Bool(true)) => true,
5354        Some(_) => {
5355            return Err(RuntimeManifestError::InvalidArtifact {
5356                index,
5357                message: format!(
5358                    "artifact '{name}' declares gateway.keyless with a value that is not a \
5359                     boolean; write keyless: true for an upstream that takes no key"
5360                ),
5361            })
5362        }
5363    };
5364    if keyless && api_key.is_some() {
5365        return Err(RuntimeManifestError::InvalidArtifact {
5366            index,
5367            message: format!(
5368                "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
5369                 gateway either presents a key or is keyless"
5370            ),
5371        });
5372    }
5373    if keyless {
5374        return Ok(ArtifactGateway {
5375            endpoint,
5376            api_key: None,
5377            keyless: true,
5378        });
5379    }
5380
5381    let written = api_key
5382        .flatten()
5383        .map(|value| value.trim().to_string())
5384        .filter(|value| !value.is_empty());
5385    let Some(written) = written else {
5386        return Err(RuntimeManifestError::GatewayWithoutCredential {
5387            index,
5388            name: name.to_string(),
5389            endpoint,
5390        });
5391    };
5392    let api_key = match secrets {
5393        SecretResolution::Skip => None,
5394        SecretResolution::Resolve => Some(match parse_env_reference(&written) {
5395            Some(variable) => ApiKeyReference::Environment(variable.to_string()),
5396            None => ApiKeyReference::Literal(written),
5397        }),
5398    };
5399
5400    Ok(ArtifactGateway {
5401        endpoint,
5402        api_key,
5403        keyless: false,
5404    })
5405}
5406
5407/// Checks a `gateway.endpoint`, the only control on where an artifact's key is sent: a
5408/// gateway-addressed request is readdressed before any `network.allow` rule is consulted, so
5409/// nothing else confines the destination.
5410///
5411/// Accepted: an `https://` URL with a host, or an `http://` URL whose host is `localhost` or an
5412/// IP literal for which [`IpAddr::is_loopback`] returns true (`127.0.0.0/8`, `::1`). An optional
5413/// port and path are kept.
5414///
5415/// Refused:
5416/// - `${` anywhere, checked before parsing: the field is never interpolated, and the URL parser
5417///   accepts `$`, `{` and `}` in a host, so `https://${HOST}` would otherwise parse as a literal
5418///   host.
5419/// - A malformed URL, a scheme other than `http`/`https`, or no host.
5420/// - Userinfo (`user@`, `user:pass@`): `https://api.example.com@evil.example` addresses
5421///   `evil.example`, and the gateway never sends userinfo, so a password there is a second,
5422///   unmanaged credential. The message names the host the URL addresses.
5423/// - A query or a fragment: the gateway readdresses each request by its own path and query, so
5424///   either would be silently dropped.
5425/// - `http://` to any host that is not loopback, including `0.0.0.0` and `::ffff:127.0.0.1`.
5426///
5427/// The endpoint confines the origin only. The guest chooses the whole path and query of every
5428/// gateway request.
5429///
5430/// The error is the whole message, naming `gateway.endpoint` and the artifact entry `artifact`.
5431/// It quotes the endpoint with any userinfo masked, so a password written into the URL is never
5432/// echoed.
5433fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
5434    let shown = mask_endpoint_userinfo(endpoint);
5435    let refuse =
5436        |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
5437    if endpoint.contains("${") {
5438        return Err(refuse(
5439            "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
5440             literally"
5441                .to_string(),
5442        ));
5443    }
5444    let url = Url::parse(endpoint)
5445        .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
5446    let host = match url.scheme() {
5447        "http" | "https" => url
5448            .host_str()
5449            .filter(|host| !host.is_empty())
5450            .ok_or_else(|| refuse("which has no host".to_string()))?,
5451        other => {
5452            return Err(refuse(format!(
5453                "whose scheme '{other}' is unsupported — expected http or https"
5454            )))
5455        }
5456    };
5457    if !url.username().is_empty() || url.password().is_some() {
5458        return Err(refuse(format!(
5459            "which carries userinfo before '@' — the gateway never sends it, and this URL \
5460             addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
5461             the key through gateway.api_key"
5462        )));
5463    }
5464    if url.query().is_some() {
5465        return Err(refuse(
5466            "which carries a query — the gateway keeps each request's own query, so write the \
5467             upstream without one"
5468                .to_string(),
5469        ));
5470    }
5471    if url.fragment().is_some() {
5472        return Err(refuse(
5473            "which carries a fragment — write the upstream without one".to_string(),
5474        ));
5475    }
5476    if url.scheme() == "http" {
5477        let bare = host.trim_start_matches('[').trim_end_matches(']');
5478        let is_loopback = bare == "localhost"
5479            || bare
5480                .parse::<IpAddr>()
5481                .map(|ip| ip.is_loopback())
5482                .unwrap_or(false);
5483        if !is_loopback {
5484            return Err(refuse(format!(
5485                "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
5486                 remote upstream, or http://localhost or a loopback IP literal for a local one"
5487            )));
5488        }
5489    }
5490    Ok(())
5491}
5492
5493/// `endpoint` with everything before the last `@` of its authority replaced by `***`.
5494///
5495/// Textual rather than parsed, so it also masks an endpoint the URL parser rejects.
5496fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
5497    let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
5498    let rest = &endpoint[authority_start..];
5499    let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
5500    match rest[..authority_len].rfind('@') {
5501        Some(at) => format!(
5502            "{}***{}",
5503            &endpoint[..authority_start],
5504            &endpoint[authority_start + at..]
5505        )
5506        .into(),
5507        None => endpoint.into(),
5508    }
5509}
5510
5511/// The cross-entry rules for a `gateway:` on a driver, which need the parsed `inference:` block.
5512///
5513/// A driver's gateway is a metered inference gateway only when that driver is the configured
5514/// `transport: http` driver or an `inference.alternates` entry's driver. On any other driver entry
5515/// it could be spent only through tool dispatch, where nothing meters it, so it is refused. Under
5516/// `transport: http` the configured driver's entry must exist and must carry the upstream.
5517fn validate_driver_gateways(
5518    artifacts: &[RuntimeArtifact],
5519    inference: Option<&InferenceConfig>,
5520) -> Result<(), RuntimeManifestError> {
5521    let configured_driver = inference
5522        .filter(|inference| inference.transport == "http")
5523        .and_then(|inference| inference.driver.as_ref())
5524        .map(|driver| driver.artifact.as_str());
5525    let alternate_drivers: Vec<&str> = inference
5526        .map(|inference| {
5527            inference
5528                .alternates
5529                .iter()
5530                .map(|alternate| alternate.driver.as_str())
5531                .collect()
5532        })
5533        .unwrap_or_default();
5534    let under_process = inference.is_some_and(|inference| inference.transport == "process");
5535
5536    for (index, artifact) in artifacts.iter().enumerate() {
5537        if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
5538            continue;
5539        }
5540        if under_process {
5541            return Err(RuntimeManifestError::InvalidArtifact {
5542                index,
5543                message: format!(
5544                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
5545                     valid with transport: process — the CLI subprocess holds its own \
5546                     credentials and a process driver is granted nothing",
5547                    artifact.name
5548                ),
5549            });
5550        }
5551        if configured_driver != Some(artifact.name.as_str())
5552            && !alternate_drivers.contains(&artifact.name.as_str())
5553        {
5554            return Err(RuntimeManifestError::InvalidArtifact {
5555                index,
5556                message: format!(
5557                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
5558                     the configured inference driver; a driver's gateway is accepted only on the \
5559                     entry inference.driver.artifact names under transport: http, or on one an \
5560                     inference.alternates entry names",
5561                    artifact.name
5562                ),
5563            });
5564        }
5565    }
5566
5567    if let Some(driver) = configured_driver {
5568        let Some((index, entry)) = artifacts
5569            .iter()
5570            .enumerate()
5571            .find(|(_, artifact)| artifact.name == driver)
5572        else {
5573            return Err(RuntimeManifestError::InvalidInferenceConfig {
5574                field: "inference.driver.artifact".to_string(),
5575                message: format!("artifact '{driver}' is not declared in artifacts:"),
5576            });
5577        };
5578        if entry.gateway.is_none() {
5579            return Err(RuntimeManifestError::InvalidArtifact {
5580                index,
5581                message: format!(
5582                    "artifact '{driver}' is the transport: http inference driver but declares no \
5583                     gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
5584                     on its entry"
5585                ),
5586            });
5587        }
5588    }
5589    Ok(())
5590}
5591
5592/// The cross-entry rules for `inference.alternates`, which need `artifacts:`.
5593///
5594/// Each alternate's `driver.artifact` must be an `artifacts:` entry with `runtime: driver`, and
5595/// that entry must carry its own `gateway:` — the credential a switch attaches — unless it is the
5596/// primary's own driver, whose gateway [`validate_driver_gateways`] already requires.
5597fn validate_driver_choices(
5598    artifacts: &[RuntimeArtifact],
5599    inference: Option<&InferenceConfig>,
5600) -> Result<(), RuntimeManifestError> {
5601    let Some(inference) = inference else {
5602        return Ok(());
5603    };
5604    let primary_driver = inference
5605        .driver
5606        .as_ref()
5607        .map(|driver| driver.artifact.as_str());
5608    for (index, alternate) in inference.alternates.iter().enumerate() {
5609        let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
5610            field: format!("inference.alternates[{index}].driver.artifact"),
5611            message,
5612        };
5613        let driver = alternate.driver.as_str();
5614        let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
5615            return Err(refuse(format!(
5616                "artifact '{driver}' is not declared in artifacts:"
5617            )));
5618        };
5619        if entry.runtime != ArtifactRuntime::Driver {
5620            return Err(refuse(format!(
5621                "artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
5622                 runtime: driver entry",
5623                entry.runtime.as_str()
5624            )));
5625        }
5626        if entry.gateway.is_none() && primary_driver != Some(driver) {
5627            return Err(refuse(format!(
5628                "artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
5629                 its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
5630                 URL>, api_key: ... }}' on its entry"
5631            )));
5632        }
5633    }
5634    Ok(())
5635}
5636
5637/// Refuses alternates nothing could ever select: `inference.driver` is in neither
5638/// `control.settings` nor `control.agent_settings`.
5639fn validate_alternates_selectable(
5640    inference: Option<&InferenceConfig>,
5641    control: Option<&ControlConfig>,
5642) -> Result<(), RuntimeManifestError> {
5643    if inference.is_none_or(|inference| inference.alternates.is_empty()) {
5644        return Ok(());
5645    }
5646    let selectable = control.is_some_and(|control| {
5647        control.controller_may_set(ControllableSetting::InferenceDriver)
5648            || control.agent_may_set(ControllableSetting::InferenceDriver)
5649    });
5650    if selectable {
5651        return Ok(());
5652    }
5653    Err(RuntimeManifestError::InvalidInferenceConfig {
5654        field: "inference.alternates".to_string(),
5655        message: "declares alternates that nothing could ever select; list inference.driver in \
5656                  control.settings for a controller to switch, or in control.agent_settings for \
5657                  the agent to"
5658            .to_string(),
5659    })
5660}
5661
5662/// The cross-entry rules for the `transport: process` driver, which need the parsed
5663/// `inference:` block.
5664///
5665/// The artifact `inference.driver.artifact` names must be declared in `artifacts:` with
5666/// `runtime: driver`. A process driver is granted nothing, so its entry may carry neither
5667/// `capabilities:` nor `config:`; its settings travel in `inference.driver.config`. A `gateway:`
5668/// on it is refused by [`validate_driver_gateways`].
5669fn validate_process_driver_entry(
5670    artifacts: &[RuntimeArtifact],
5671    inference: Option<&InferenceConfig>,
5672) -> Result<(), RuntimeManifestError> {
5673    let Some(driver) = inference
5674        .filter(|inference| inference.transport == "process")
5675        .and_then(|inference| inference.driver.as_ref())
5676        .map(|driver| driver.artifact.as_str())
5677    else {
5678        return Ok(());
5679    };
5680    let Some((index, entry)) = artifacts
5681        .iter()
5682        .enumerate()
5683        .find(|(_, artifact)| artifact.name == driver)
5684    else {
5685        return Err(RuntimeManifestError::InvalidInferenceConfig {
5686            field: "inference.driver.artifact".to_string(),
5687            message: format!("artifact '{driver}' is not declared in artifacts:"),
5688        });
5689    };
5690    if entry.runtime != ArtifactRuntime::Driver {
5691        return Err(RuntimeManifestError::InvalidArtifact {
5692            index,
5693            message: format!(
5694                "artifact '{driver}' is the transport: process driver and must be declared with \
5695                 runtime: driver"
5696            ),
5697        });
5698    }
5699    if entry.capabilities.is_some() || entry.config.is_some() {
5700        return Err(RuntimeManifestError::InvalidArtifact {
5701            index,
5702            message: format!(
5703                "artifact '{driver}' is the transport: process driver, which is granted nothing; \
5704                 remove capabilities:/config: from its entry — driver settings go in \
5705                 inference.driver.config"
5706            ),
5707        });
5708    }
5709    Ok(())
5710}
5711
5712fn parse_inference_driver(
5713    raw: RawInferenceDriver,
5714) -> Result<InferenceDriver, RuntimeManifestError> {
5715    let artifact = raw
5716        .artifact
5717        .map(|value| value.trim().to_string())
5718        .filter(|value| !value.is_empty())
5719        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5720            field: "inference.driver.artifact".to_string(),
5721            message: "missing required field".to_string(),
5722        })?;
5723
5724    let config = parse_inference_driver_config(raw.config)?;
5725
5726    Ok(InferenceDriver { artifact, config })
5727}
5728
5729fn parse_inference_driver_config(
5730    raw: Option<serde_yaml::Value>,
5731) -> Result<Option<String>, RuntimeManifestError> {
5732    let Some(raw) = raw else {
5733        return Ok(None);
5734    };
5735
5736    if matches!(raw, serde_yaml::Value::Null) {
5737        return Ok(None);
5738    }
5739
5740    if !matches!(raw, serde_yaml::Value::Mapping(_)) {
5741        return Err(RuntimeManifestError::InvalidInferenceConfig {
5742            field: "inference.driver.config".to_string(),
5743            message: "expected mapping/object".to_string(),
5744        });
5745    }
5746
5747    let value =
5748        serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
5749            field: "inference.driver.config".to_string(),
5750            message: format!("failed to convert to JSON: {err}"),
5751        })?;
5752
5753    serde_json::to_string(&value).map(Some).map_err(|err| {
5754        RuntimeManifestError::InvalidInferenceConfig {
5755            field: "inference.driver.config".to_string(),
5756            message: format!("failed to encode JSON: {err}"),
5757        }
5758    })
5759}
5760
5761fn parse_env_reference(value: &str) -> Option<&str> {
5762    if !value.starts_with("${") || !value.ends_with('}') {
5763        return None;
5764    }
5765
5766    let variable = &value[2..value.len() - 1];
5767    is_valid_env_variable(variable).then_some(variable)
5768}
5769
5770fn is_valid_env_variable(value: &str) -> bool {
5771    let mut chars = value.chars();
5772    let Some(first) = chars.next() else {
5773        return false;
5774    };
5775
5776    if !(first == '_' || first.is_ascii_uppercase()) {
5777        return false;
5778    }
5779
5780    chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
5781}
5782
5783#[cfg(test)]
5784mod tests {
5785    use super::*;
5786    use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
5787
5788    fn manifest_with_trace(block: &str) -> String {
5789        format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
5790    }
5791
5792    fn lifecycle(task_acceptance: TaskAcceptance, queue_depth: usize) -> LifecycleConfig {
5793        LifecycleConfig {
5794            task_acceptance,
5795            queue_depth,
5796            ..Default::default()
5797        }
5798    }
5799
5800    #[test]
5801    fn tasks_held_at_once_under_queue_is_one_running_plus_queue_depth_waiting() {
5802        assert_eq!(lifecycle(TaskAcceptance::Queue, 1).tasks_held_at_once(), 2);
5803        assert_eq!(lifecycle(TaskAcceptance::Queue, 9).tasks_held_at_once(), 10);
5804    }
5805
5806    #[test]
5807    fn tasks_held_at_once_under_queue_with_depth_zero_is_zero() {
5808        assert_eq!(lifecycle(TaskAcceptance::Queue, 0).tasks_held_at_once(), 0);
5809    }
5810
5811    #[test]
5812    fn tasks_held_at_once_under_single_is_one_whatever_the_depth() {
5813        assert_eq!(lifecycle(TaskAcceptance::Single, 1).tasks_held_at_once(), 1);
5814        assert_eq!(lifecycle(TaskAcceptance::Single, 5).tasks_held_at_once(), 1);
5815    }
5816
5817    #[test]
5818    fn tasks_held_at_once_under_none_is_zero_whatever_the_depth() {
5819        assert_eq!(lifecycle(TaskAcceptance::None, 1).tasks_held_at_once(), 0);
5820        assert_eq!(lifecycle(TaskAcceptance::None, 5).tasks_held_at_once(), 0);
5821    }
5822
5823    /// The default is `single` at `queue_depth: 1`, which holds one task; switching only
5824    /// `task_acceptance` to `queue` keeps the default depth and holds two.
5825    #[test]
5826    fn tasks_held_at_once_of_the_default_lifecycle_and_default_depth() {
5827        assert_eq!(LifecycleConfig::default().tasks_held_at_once(), 1);
5828        let queue = RuntimeManifest::from_yaml_str(
5829            "name: cap\nversion: 0.0.1\nlifecycle:\n  task_acceptance: queue\n",
5830        )
5831        .unwrap()
5832        .effective_lifecycle();
5833        assert_eq!(queue.queue_depth, 1);
5834        assert_eq!(queue.tasks_held_at_once(), 2);
5835    }
5836
5837    /// No `trace:` block at all leaves the config absent; the runtime substitutes
5838    /// [`TraceCapture::default`] rather than the manifest carrying a synthesized one.
5839    #[test]
5840    fn absent_trace_block_stays_absent_and_defaults_to_meta() {
5841        let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
5842        assert_eq!(manifest.trace, None);
5843        assert_eq!(TraceCapture::default(), TraceCapture::Meta);
5844    }
5845
5846    #[test]
5847    fn each_capture_mode_parses_from_the_trace_block() {
5848        for (yaml, expected) in [
5849            ("none", TraceCapture::None),
5850            ("meta", TraceCapture::Meta),
5851            ("content", TraceCapture::Content),
5852        ] {
5853            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
5854                "  capture: {yaml}\n"
5855            )))
5856            .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
5857            assert_eq!(
5858                manifest.trace,
5859                Some(TraceConfig {
5860                    capture: expected,
5861                    retain: None
5862                })
5863            );
5864        }
5865    }
5866
5867    // ── retain ───────────────────────────────────────────────────────────────
5868
5869    /// A `trace:` block with only `capture` and a `context:` block with only `record` declare no
5870    /// retention at all: an absent `retain:` is the only way to say "keep everything", and it is
5871    /// what a capsule that never mentions retention gets.
5872    #[test]
5873    fn no_retain_block_anywhere_parses_as_no_policy() {
5874        let manifest = RuntimeManifest::from_yaml_str(
5875            "name: cap\nversion: 0.0.1\ntrace:\n  capture: content\ncontext:\n  record: on\n",
5876        )
5877        .unwrap();
5878        assert_eq!(manifest.trace.unwrap().retain, None);
5879        assert_eq!(manifest.context.unwrap().retain, None);
5880    }
5881
5882    #[test]
5883    fn trace_retain_parses_both_keys_and_the_day_suffix() {
5884        let manifest = RuntimeManifest::from_yaml_str(
5885            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 50\n    max_age: 14d\n",
5886        )
5887        .unwrap();
5888        assert_eq!(
5889            manifest.trace.unwrap().retain,
5890            Some(TraceRetainConfig {
5891                max_sessions: Some(50),
5892                max_age_secs: Some(14 * 86_400),
5893            })
5894        );
5895    }
5896
5897    #[test]
5898    fn context_retain_parses_both_keys_and_either_alone() {
5899        let both = RuntimeManifest::from_yaml_str(
5900            "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n    max_messages: 2000\n    max_age: 90d\n",
5901        )
5902        .unwrap();
5903        assert_eq!(
5904            both.context.unwrap().retain,
5905            Some(ContextRetainConfig {
5906                max_messages: Some(2000),
5907                max_age_secs: Some(90 * 86_400),
5908            })
5909        );
5910
5911        let count_only = RuntimeManifest::from_yaml_str(
5912            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 10\n",
5913        )
5914        .unwrap();
5915        assert_eq!(
5916            count_only.context.unwrap().retain,
5917            Some(ContextRetainConfig {
5918                max_messages: Some(10),
5919                max_age_secs: None,
5920            })
5921        );
5922    }
5923
5924    /// An empty block is not a way to declare no policy — omitting the block is. Both spellings
5925    /// of empty are refused, naming the block an operator has to go and change.
5926    #[test]
5927    fn an_empty_retain_block_is_refused_naming_the_block() {
5928        for (yaml, expect_trace) in [
5929            (
5930                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain: {}\n",
5931                true,
5932            ),
5933            (
5934                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n",
5935                true,
5936            ),
5937            (
5938                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain: {}\n",
5939                false,
5940            ),
5941            (
5942                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n",
5943                false,
5944            ),
5945        ] {
5946            let err = RuntimeManifest::from_yaml_str(yaml)
5947                .expect_err("an empty retain block must be refused");
5948            let (field, message) = match (&err, expect_trace) {
5949                (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
5950                    (field, message)
5951                }
5952                (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
5953                    (field, message)
5954                }
5955                _ => panic!("wrong variant for {yaml:?}: {err:?}"),
5956            };
5957            assert_eq!(
5958                field,
5959                if expect_trace {
5960                    "trace.retain"
5961                } else {
5962                    "context.retain"
5963                }
5964            );
5965            assert!(message.contains("at least one key"), "{message}");
5966        }
5967    }
5968
5969    /// A limit of zero would delete what retention exists to keep, so it is
5970    /// refused naming the key rather than accepted as "keep nothing".
5971    #[test]
5972    fn a_zero_retain_limit_is_refused_naming_the_key() {
5973        let trace = RuntimeManifest::from_yaml_str(
5974            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 0\n",
5975        )
5976        .expect_err("max_sessions: 0 must be refused");
5977        assert!(
5978            matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
5979                     if field == "trace.retain.max_sessions"),
5980            "{trace:?}"
5981        );
5982
5983        let context = RuntimeManifest::from_yaml_str(
5984            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 0\n",
5985        )
5986        .expect_err("max_messages: 0 must be refused");
5987        assert!(
5988            matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
5989                     if field == "context.retain.max_messages"),
5990            "{context:?}"
5991        );
5992
5993        let age = RuntimeManifest::from_yaml_str(
5994            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_age: 0\n",
5995        )
5996        .expect_err("max_age: 0 must be refused");
5997        assert!(
5998            matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
5999                     if field == "trace.retain.max_age"),
6000            "{age:?}"
6001        );
6002    }
6003
6004    #[test]
6005    fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
6006        let err = RuntimeManifest::from_yaml_str(
6007            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_traces: 3\n",
6008        )
6009        .expect_err("an unknown key must be refused");
6010        match err {
6011            RuntimeManifestError::InvalidTraceConfig { field, message } => {
6012                assert_eq!(field, "trace.retain");
6013                assert!(message.contains("max_traces"), "{message}");
6014                assert!(message.contains("max_sessions"), "{message}");
6015            }
6016            other => panic!("expected InvalidTraceConfig, got {other:?}"),
6017        }
6018    }
6019
6020    #[test]
6021    fn an_unparseable_retain_duration_reports_the_accepted_form() {
6022        let err = RuntimeManifest::from_yaml_str(
6023            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_age: 5 weeks\n",
6024        )
6025        .expect_err("'5 weeks' must be refused");
6026        match err {
6027            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
6028                assert_eq!(field, "context.retain.max_age");
6029                assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
6030            }
6031            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
6032        }
6033    }
6034
6035    #[test]
6036    fn unparseable_capture_value_names_the_field_and_accepted_values() {
6037        let err = RuntimeManifest::from_yaml_str(&manifest_with_trace("  capture: verbose\n"))
6038            .expect_err("'verbose' is not a capture mode");
6039        let rendered = err.to_string();
6040        assert!(rendered.contains("trace.capture"), "{rendered}");
6041        for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
6042            assert!(rendered.contains(accepted), "{rendered}");
6043        }
6044    }
6045
6046    #[test]
6047    fn resources_block_is_optional_and_absent_stays_absent() {
6048        let manifest = RuntimeManifest::from_yaml_str(
6049            r#"
6050name: cap
6051version: 0.0.1
6052capabilities:
6053  shell:
6054    allow: [bash]
6055"#,
6056        )
6057        .unwrap();
6058
6059        // `None` here is "the manifest said nothing", not "unlimited" — the runtime substitutes
6060        // its own defaults (see `capsule_runtime::resources::HostResourceLimits::resolve`), and
6061        // keeping the two states distinct is what lets it own them in one place.
6062        let caps = manifest.capabilities.expect("shell block must parse");
6063        assert_eq!(caps.resources, None);
6064    }
6065
6066    #[test]
6067    fn resources_fields_parse_and_each_stays_independently_optional() {
6068        let manifest = RuntimeManifest::from_yaml_str(
6069            r#"
6070name: cap
6071version: 0.0.1
6072capabilities:
6073  resources:
6074    max_processes: 32
6075    max_open_files: 16
6076    max_file_size_bytes: 1048576
6077    cpu_seconds: 30
6078    memory_bytes: 268435456
6079    cgroup_memory_bytes: 536870912
6080    cgroup_pids_max: 64
6081    cgroup_cpu_percent: 50
6082    cgroup_io_bytes_per_sec: 1048576
6083    workdir_max_bytes: 2097152
6084"#,
6085        )
6086        .unwrap();
6087
6088        let resources = manifest
6089            .capabilities
6090            .and_then(|caps| caps.resources)
6091            .expect("resources block must parse");
6092        assert_eq!(resources.max_processes, Some(32));
6093        assert_eq!(resources.max_open_files, Some(16));
6094        assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
6095        assert_eq!(resources.cpu_seconds, Some(30));
6096        assert_eq!(resources.memory_bytes, Some(268_435_456));
6097        assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
6098        assert_eq!(resources.cgroup_pids_max, Some(64));
6099        assert_eq!(resources.cgroup_cpu_percent, Some(50));
6100        assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
6101        assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
6102    }
6103
6104    #[test]
6105    fn state_block_is_optional_and_absent_stays_absent() {
6106        let manifest = RuntimeManifest::from_yaml_str(
6107            r#"
6108name: cap
6109version: 0.0.1
6110artifacts:
6111  - name: notes-tool
6112    version: 1.0.0
6113    runtime: tool
6114    capabilities:
6115      filesystem:
6116        scope: cache
6117"#,
6118        )
6119        .unwrap();
6120
6121        // Absent is deny: no store name to default, and nothing downstream creates a directory.
6122        let caps = manifest.artifacts[0]
6123            .capabilities
6124            .as_ref()
6125            .expect("filesystem block must parse");
6126        assert_eq!(caps.state, None);
6127    }
6128
6129    /// A declared-but-empty `state:` block is a real grant — it means "give me a store, named
6130    /// after the capsule" — so it must lower to `Some(..)` with no name, distinguishable from
6131    /// the absent block above.
6132    #[test]
6133    fn state_block_declared_empty_grants_the_capsule_named_store() {
6134        let manifest = RuntimeManifest::from_yaml_str(
6135            r#"
6136name: cap
6137version: 0.0.1
6138artifacts:
6139  - name: notes-tool
6140    version: 1.0.0
6141    runtime: tool
6142    capabilities:
6143      state: {}
6144"#,
6145        )
6146        .unwrap();
6147
6148        let state = manifest.artifacts[0]
6149            .capabilities
6150            .as_ref()
6151            .and_then(|caps| caps.state.as_ref())
6152            .expect("state block must parse");
6153        assert_eq!(state.store, None);
6154    }
6155
6156    #[test]
6157    fn state_store_name_parses_and_stays_independently_optional() {
6158        let manifest = RuntimeManifest::from_yaml_str(
6159            r#"
6160name: cap
6161version: 0.0.1
6162artifacts:
6163  - name: notes-tool
6164    version: 1.0.0
6165    runtime: tool
6166    capabilities:
6167      state:
6168        store: "  shey  "
6169      network:
6170        allow: [https://api.example.com]
6171"#,
6172        )
6173        .unwrap();
6174
6175        let caps = manifest.artifacts[0]
6176            .capabilities
6177            .as_ref()
6178            .expect("capabilities must parse");
6179        // Trimmed, but otherwise verbatim: whether the name is a usable directory segment is
6180        // decided by the runtime (`E-CAP-009`), not here.
6181        assert_eq!(
6182            caps.state.as_ref().and_then(|state| state.store.as_deref()),
6183            Some("shey")
6184        );
6185        // Declaring a store never touches any sibling sub-block.
6186        assert_eq!(
6187            caps.network
6188                .as_ref()
6189                .map(|network| network.allow.as_slice()),
6190            Some(["https://api.example.com".to_string()].as_slice())
6191        );
6192        assert_eq!(caps.filesystem, None);
6193    }
6194
6195    /// An explicitly empty name is kept rather than normalised to "default to the capsule name":
6196    /// the operator wrote a name, and handing them a different store than the one they wrote
6197    /// would be worse than the refusal the runtime raises for it.
6198    #[test]
6199    fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
6200        let manifest = RuntimeManifest::from_yaml_str(
6201            r#"
6202name: cap
6203version: 0.0.1
6204artifacts:
6205  - name: notes-tool
6206    version: 1.0.0
6207    runtime: tool
6208    capabilities:
6209      state:
6210        store: ""
6211"#,
6212        )
6213        .unwrap();
6214
6215        assert_eq!(
6216            manifest.artifacts[0]
6217                .capabilities
6218                .as_ref()
6219                .and_then(|caps| caps.state.as_ref())
6220                .and_then(|state| state.store.as_deref()),
6221            Some("")
6222        );
6223    }
6224
6225    #[test]
6226    fn resources_partial_block_leaves_undeclared_fields_none() {
6227        let manifest = RuntimeManifest::from_yaml_str(
6228            r#"
6229name: cap
6230version: 0.0.1
6231capabilities:
6232  resources:
6233    max_open_files: 16
6234"#,
6235        )
6236        .unwrap();
6237
6238        let resources = manifest
6239            .capabilities
6240            .and_then(|caps| caps.resources)
6241            .expect("resources block must parse");
6242        assert_eq!(resources.max_open_files, Some(16));
6243        assert_eq!(resources.max_processes, None);
6244        assert_eq!(resources.workdir_max_bytes, None);
6245    }
6246
6247    /// Every field rejects `0` at parse time, naming its own path. A zero ceiling is never what
6248    /// an author means, and accepting one would turn a typo into a subprocess that cannot fork,
6249    /// open a file, or run for a single CPU-second — discovered at runtime instead of at parse.
6250    #[test]
6251    fn resources_zero_is_rejected_on_every_field() {
6252        for field in [
6253            "max_processes",
6254            "max_open_files",
6255            "max_file_size_bytes",
6256            "cpu_seconds",
6257            "memory_bytes",
6258            "cgroup_memory_bytes",
6259            "cgroup_pids_max",
6260            "cgroup_cpu_percent",
6261            "cgroup_io_bytes_per_sec",
6262            "workdir_max_bytes",
6263        ] {
6264            let yaml =
6265                format!("name: cap\nversion: 0.0.1\ncapabilities:\n  resources:\n    {field}: 0\n");
6266            let error =
6267                RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
6268
6269            match error {
6270                RuntimeManifestError::InvalidCapabilities {
6271                    field: reported,
6272                    message,
6273                } => {
6274                    assert_eq!(reported, format!("capabilities.resources.{field}"));
6275                    assert_eq!(message, "must be greater than zero");
6276                }
6277                other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
6278            }
6279        }
6280    }
6281
6282    #[test]
6283    fn artifact_runtime_defaults_to_tool() {
6284        let manifest = RuntimeManifest::from_yaml_str(
6285            r#"
6286name: cap
6287version: 0.0.1
6288artifacts:
6289  - name: echo
6290    version: 1.2.3
6291"#,
6292        )
6293        .unwrap();
6294
6295        assert_eq!(manifest.artifacts.len(), 1);
6296        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
6297    }
6298
6299    #[test]
6300    fn artifact_runtime_tool_explicit() {
6301        let manifest = RuntimeManifest::from_yaml_str(
6302            r#"
6303name: cap
6304version: 0.0.1
6305artifacts:
6306  - name: echo
6307    version: 1.2.3
6308    runtime: tool
6309"#,
6310        )
6311        .unwrap();
6312
6313        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
6314    }
6315
6316    #[test]
6317    fn artifact_runtime_wasm_is_error() {
6318        let err = RuntimeManifest::from_yaml_str(
6319            r#"
6320name: cap
6321version: 0.0.1
6322artifacts:
6323  - name: echo
6324    version: 1.2.3
6325    runtime: wasm
6326"#,
6327        )
6328        .unwrap_err();
6329
6330        let msg = err.to_string();
6331        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
6332        assert!(msg.contains("wasm"), "error was: {msg}");
6333    }
6334
6335    #[test]
6336    fn artifact_runtime_native_is_error() {
6337        let err = RuntimeManifest::from_yaml_str(
6338            r#"
6339name: cap
6340version: 0.0.1
6341artifacts:
6342  - name: echo
6343    version: 1.2.3
6344    runtime: native
6345"#,
6346        )
6347        .unwrap_err();
6348
6349        let msg = err.to_string();
6350        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
6351        assert!(msg.contains("native"), "error was: {msg}");
6352    }
6353
6354    #[test]
6355    fn invalid_artifact_reports_index() {
6356        let err = RuntimeManifest::from_yaml_str(
6357            r#"
6358name: cap
6359version: 0.0.1
6360artifacts:
6361  - name: ""
6362    version: 1.2.3
6363"#,
6364        )
6365        .unwrap_err();
6366
6367        let msg = err.to_string();
6368        assert!(msg.contains("index 0"));
6369        assert!(msg.contains("missing required field 'name'"));
6370    }
6371
6372    #[test]
6373    fn capabilities_parse_when_all_fields_present() {
6374        let manifest = RuntimeManifest::from_yaml_str(
6375            r#"
6376name: cap
6377version: 0.0.1
6378artifacts:
6379  - name: echo
6380    version: 1.2.3
6381capabilities:
6382  network:
6383    allow:
6384      - https://api.anthropic.com
6385      - http://127.0.0.1:8080
6386  filesystem:
6387    scope: ./sandbox
6388"#,
6389        )
6390        .unwrap();
6391
6392        let capabilities = manifest.capabilities.expect("capabilities should exist");
6393        assert_eq!(
6394            capabilities.network.unwrap().allow,
6395            vec![
6396                "https://api.anthropic.com".to_string(),
6397                "http://127.0.0.1:8080".to_string()
6398            ]
6399        );
6400        assert_eq!(
6401            capabilities.filesystem.unwrap().scope,
6402            Some("./sandbox".to_string())
6403        );
6404    }
6405
6406    #[test]
6407    fn env_capabilities_parse_allowlist() {
6408        let manifest = RuntimeManifest::from_yaml_str(
6409            r#"
6410name: cap
6411version: 0.0.1
6412artifacts:
6413  - name: echo
6414    version: 1.2.3
6415capabilities:
6416  env:
6417    allow:
6418      - MY_APP_REGION
6419      - GITHUB_TOKEN
6420"#,
6421        )
6422        .unwrap();
6423
6424        let capabilities = manifest.capabilities.expect("capabilities should exist");
6425        assert_eq!(
6426            capabilities.env.unwrap().allow,
6427            vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
6428        );
6429    }
6430
6431    #[test]
6432    fn env_capabilities_absent_when_block_omitted() {
6433        let manifest = RuntimeManifest::from_yaml_str(
6434            r#"
6435name: cap
6436version: 0.0.1
6437artifacts:
6438  - name: echo
6439    version: 1.2.3
6440capabilities:
6441  network:
6442    allow:
6443      - https://api.anthropic.com
6444"#,
6445        )
6446        .unwrap();
6447
6448        assert!(manifest.capabilities.unwrap().env.is_none());
6449    }
6450
6451    /// Unlike `capabilities.shell.allow`, an empty `env.allow` is a legitimate no-op rather
6452    /// than a manifest error.
6453    #[test]
6454    fn env_capabilities_allow_empty_list_is_accepted() {
6455        let manifest = RuntimeManifest::from_yaml_str(
6456            r#"
6457name: cap
6458version: 0.0.1
6459artifacts:
6460  - name: echo
6461    version: 1.2.3
6462capabilities:
6463  env: {}
6464"#,
6465        )
6466        .unwrap();
6467
6468        assert_eq!(
6469            manifest.capabilities.unwrap().env,
6470            Some(EnvCapabilities { allow: vec![] })
6471        );
6472    }
6473
6474    #[test]
6475    fn missing_capabilities_block_is_backward_compatible() {
6476        let manifest = RuntimeManifest::from_yaml_str(
6477            r#"
6478name: cap
6479version: 0.0.1
6480artifacts:
6481  - name: echo
6482    version: 1.2.3
6483"#,
6484        )
6485        .unwrap();
6486
6487        assert!(manifest.capabilities.is_none());
6488    }
6489
6490    #[test]
6491    fn network_only_capabilities_parse() {
6492        let manifest = RuntimeManifest::from_yaml_str(
6493            r#"
6494name: cap
6495version: 0.0.1
6496artifacts:
6497  - name: echo
6498    version: 1.2.3
6499capabilities:
6500  network:
6501    allow:
6502      - https://allowed.example.com
6503"#,
6504        )
6505        .unwrap();
6506
6507        let capabilities = manifest.capabilities.unwrap();
6508        assert_eq!(
6509            capabilities.network.unwrap().allow,
6510            vec!["https://allowed.example.com".to_string()]
6511        );
6512        assert!(capabilities.filesystem.is_none());
6513    }
6514
6515    #[test]
6516    fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
6517        let manifest = RuntimeManifest::from_yaml_str(
6518            r#"
6519name: cap
6520version: 0.0.1
6521artifacts:
6522  - name: echo
6523    version: 1.2.3
6524capabilities:
6525  network: {}
6526"#,
6527        )
6528        .unwrap();
6529
6530        let capabilities = manifest.capabilities.unwrap();
6531        assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
6532    }
6533
6534    /// `unix_sockets` is optional and denies by default. A capsule that never heard of the key
6535    /// must not silently get `AF_UNIX` sockets — that is the whole `/var/run/docker.sock` escape.
6536    #[test]
6537    fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
6538        let manifest = RuntimeManifest::from_yaml_str(
6539            r#"
6540name: cap
6541version: 0.0.1
6542artifacts:
6543  - name: echo
6544    version: 1.2.3
6545capabilities:
6546  network:
6547    allow:
6548      - https://api.anthropic.com
6549"#,
6550        )
6551        .unwrap();
6552
6553        let network = manifest.capabilities.unwrap().network.unwrap();
6554        assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
6555        assert!(!network.unix_sockets);
6556    }
6557
6558    #[test]
6559    fn network_unix_sockets_parses_explicit_false() {
6560        let manifest = RuntimeManifest::from_yaml_str(
6561            r#"
6562name: cap
6563version: 0.0.1
6564artifacts:
6565  - name: echo
6566    version: 1.2.3
6567capabilities:
6568  network:
6569    unix_sockets: false
6570"#,
6571        )
6572        .unwrap();
6573
6574        assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
6575    }
6576
6577    #[test]
6578    fn network_unix_sockets_parses_explicit_true() {
6579        let manifest = RuntimeManifest::from_yaml_str(
6580            r#"
6581name: cap
6582version: 0.0.1
6583artifacts:
6584  - name: echo
6585    version: 1.2.3
6586capabilities:
6587  network:
6588    unix_sockets: true
6589"#,
6590        )
6591        .unwrap();
6592
6593        let network = manifest.capabilities.unwrap().network.unwrap();
6594        assert!(network.unix_sockets);
6595        // The opt-in is orthogonal to the IP allowlist — declaring it does not imply any host.
6596        assert_eq!(network.allow, Vec::<String>::new());
6597    }
6598
6599    /// `read_only` is normalised on exactly the terms `scope` is — trimmed, with an entry left
6600    /// empty by that trim dropped rather than carried as a rule covering the whole workdir — and
6601    /// path shape is deliberately not judged here.
6602    #[test]
6603    fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
6604        let manifest = RuntimeManifest::from_yaml_str(
6605            r#"
6606name: cap
6607version: 0.0.1
6608capabilities:
6609  filesystem:
6610    read_only:
6611      - tests
6612      - "  bench/fixtures  "
6613      - ""
6614      - "   "
6615"#,
6616        )
6617        .unwrap();
6618
6619        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
6620        assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
6621    }
6622
6623    /// An absent key and an empty list are one declaration: nothing is protected.
6624    #[test]
6625    fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
6626        let manifest = RuntimeManifest::from_yaml_str(
6627            r#"
6628name: cap
6629version: 0.0.1
6630capabilities:
6631  filesystem:
6632    scope: workdir
6633"#,
6634        )
6635        .unwrap();
6636
6637        assert!(manifest
6638            .capabilities
6639            .unwrap()
6640            .filesystem
6641            .unwrap()
6642            .read_only
6643            .is_empty());
6644    }
6645
6646    /// Path shape is the runtime's refusal, not the parser's: an absolute or escaping entry parses
6647    /// here exactly as an absolute `scope` does, and fails at launch with `E-CAP-012`.
6648    #[test]
6649    fn filesystem_read_only_does_not_judge_path_shape() {
6650        let manifest = RuntimeManifest::from_yaml_str(
6651            r#"
6652name: cap
6653version: 0.0.1
6654capabilities:
6655  filesystem:
6656    read_only:
6657      - /etc
6658      - ../outside
6659"#,
6660        )
6661        .expect("shape is judged by the runtime, not the parser");
6662
6663        assert_eq!(
6664            manifest.capabilities.unwrap().filesystem.unwrap().read_only,
6665            vec!["/etc", "../outside"]
6666        );
6667    }
6668
6669    /// The default every pre-existing manifest gets: a `filesystem:` block that has never heard of
6670    /// `workdir_exec` must not silently keep the workdir's `Execute` right, because that right is
6671    /// exactly what makes `capabilities.shell.allow` bypassable from inside the workdir.
6672    #[test]
6673    fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
6674        let manifest = RuntimeManifest::from_yaml_str(
6675            r#"
6676name: cap
6677version: 0.0.1
6678artifacts:
6679  - name: echo
6680    version: 1.2.3
6681capabilities:
6682  filesystem:
6683    scope: workdir
6684"#,
6685        )
6686        .unwrap();
6687
6688        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
6689        assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
6690        assert!(!filesystem.workdir_exec);
6691    }
6692
6693    /// The other absent case: no `filesystem:` block at all. There is nothing to default here —
6694    /// the whole sub-block stays `None` — but a capsule with no filesystem declaration must still
6695    /// read as "workdir exec denied" downstream, which `capability_policy_from_runtime_manifest`
6696    /// covers with its own test.
6697    #[test]
6698    fn filesystem_block_stays_absent_when_undeclared() {
6699        let manifest = RuntimeManifest::from_yaml_str(
6700            r#"
6701name: cap
6702version: 0.0.1
6703artifacts:
6704  - name: echo
6705    version: 1.2.3
6706capabilities:
6707  network:
6708    allow:
6709      - https://api.anthropic.com
6710"#,
6711        )
6712        .unwrap();
6713
6714        assert!(manifest.capabilities.unwrap().filesystem.is_none());
6715    }
6716
6717    #[test]
6718    fn filesystem_workdir_exec_parses_explicit_false() {
6719        let manifest = RuntimeManifest::from_yaml_str(
6720            r#"
6721name: cap
6722version: 0.0.1
6723artifacts:
6724  - name: echo
6725    version: 1.2.3
6726capabilities:
6727  filesystem:
6728    workdir_exec: false
6729"#,
6730        )
6731        .unwrap();
6732
6733        assert!(
6734            !manifest
6735                .capabilities
6736                .unwrap()
6737                .filesystem
6738                .unwrap()
6739                .workdir_exec
6740        );
6741    }
6742
6743    #[test]
6744    fn filesystem_workdir_exec_parses_explicit_true() {
6745        let manifest = RuntimeManifest::from_yaml_str(
6746            r#"
6747name: cap
6748version: 0.0.1
6749artifacts:
6750  - name: echo
6751    version: 1.2.3
6752capabilities:
6753  filesystem:
6754    workdir_exec: true
6755"#,
6756        )
6757        .unwrap();
6758
6759        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
6760        assert!(filesystem.workdir_exec);
6761        // Orthogonal to `scope`, exactly as `unix_sockets` is orthogonal to `network.allow`:
6762        // declaring one says nothing about the other.
6763        assert_eq!(filesystem.scope, None);
6764    }
6765
6766    #[test]
6767    fn network_internal_port_parses() {
6768        let manifest = RuntimeManifest::from_yaml_str(
6769            r#"
6770name: worker
6771version: 0.1.0
6772artifacts: []
6773
6774network:
6775  internal_port: 58172
6776"#,
6777        )
6778        .unwrap();
6779
6780        assert_eq!(
6781            manifest.network.unwrap().internal_port,
6782            Some(58172),
6783            "network.internal_port should parse to Some(58172)"
6784        );
6785    }
6786
6787    #[test]
6788    fn network_internal_port_absent_when_omitted() {
6789        let manifest = RuntimeManifest::from_yaml_str(
6790            r#"
6791name: worker
6792version: 0.1.0
6793artifacts: []
6794"#,
6795        )
6796        .unwrap();
6797
6798        assert!(manifest.network.is_none());
6799    }
6800
6801    #[test]
6802    fn shell_capabilities_parse_with_allow_and_env_fields() {
6803        let manifest = RuntimeManifest::from_yaml_str(
6804            r#"
6805name: cap
6806version: 0.0.1
6807artifacts: []
6808capabilities:
6809  shell:
6810    allow:
6811      - bash
6812      - git
6813    strip_env:
6814      - "*_TOKEN"
6815      - SECRET_KEY
6816    baseline_env:
6817      - SSH_AUTH_SOCK
6818"#,
6819        )
6820        .unwrap();
6821
6822        let shell = manifest
6823            .capabilities
6824            .unwrap()
6825            .shell
6826            .expect("shell capabilities should be present");
6827        assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
6828        assert_eq!(
6829            shell.strip_env,
6830            Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
6831        );
6832        assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
6833    }
6834
6835    #[test]
6836    fn shell_capabilities_require_non_empty_allowlist() {
6837        let err = RuntimeManifest::from_yaml_str(
6838            r#"
6839name: cap
6840version: 0.0.1
6841artifacts: []
6842capabilities:
6843  shell:
6844    allow: []
6845"#,
6846        )
6847        .unwrap_err();
6848
6849        let msg = err.to_string();
6850        assert!(msg.contains("capabilities.shell.allow"));
6851        assert!(msg.contains("at least one"));
6852    }
6853
6854    #[test]
6855    fn interpreter_runtime_parses_accepted_shape() {
6856        let manifest = RuntimeManifest::from_yaml_str(
6857            r#"
6858name: cap
6859version: 0.0.1
6860artifacts: []
6861capabilities:
6862  shell:
6863    allow:
6864      - python3
6865    interpreter_runtime:
6866      - binary: python3
6867        dirs:
6868          - path: /usr/lib/python3.11
6869            list_dir: true
6870          - path: /usr/lib/python3.11/lib-dynload
6871            list_dir: false
6872"#,
6873        )
6874        .unwrap();
6875
6876        let shell = manifest.capabilities.unwrap().shell.unwrap();
6877        assert_eq!(
6878            shell.interpreter_runtime,
6879            vec![InterpreterRuntimeGrant {
6880                binary: "python3".to_string(),
6881                dirs: vec![
6882                    InterpreterRuntimeDir {
6883                        path: "/usr/lib/python3.11".to_string(),
6884                        list_dir: true,
6885                    },
6886                    InterpreterRuntimeDir {
6887                        path: "/usr/lib/python3.11/lib-dynload".to_string(),
6888                        list_dir: false,
6889                    },
6890                ],
6891            }]
6892        );
6893    }
6894
6895    #[test]
6896    fn interpreter_runtime_defaults_to_empty_when_absent() {
6897        let manifest = RuntimeManifest::from_yaml_str(
6898            r#"
6899name: cap
6900version: 0.0.1
6901artifacts: []
6902capabilities:
6903  shell:
6904    allow:
6905      - python3
6906"#,
6907        )
6908        .unwrap();
6909
6910        let shell = manifest.capabilities.unwrap().shell.unwrap();
6911        assert!(shell.interpreter_runtime.is_empty());
6912    }
6913
6914    /// Shared helper: parse a manifest whose sole `interpreter_runtime` grant is `grant_yaml`
6915    /// (indented to sit under `interpreter_runtime:`), returning the error message.
6916    fn interpreter_runtime_reject(grant_yaml: &str) -> String {
6917        let yaml = format!(
6918            r#"
6919name: cap
6920version: 0.0.1
6921artifacts: []
6922capabilities:
6923  shell:
6924    allow:
6925      - python3
6926    interpreter_runtime:
6927{grant_yaml}
6928"#,
6929        );
6930        RuntimeManifest::from_yaml_str(&yaml)
6931            .expect_err("grant should be rejected")
6932            .to_string()
6933    }
6934
6935    #[test]
6936    fn interpreter_runtime_rejects_binary_not_in_allow() {
6937        let msg = interpreter_runtime_reject(
6938            "      - binary: ruby\n        dirs:\n          - path: /usr/lib/ruby\n            list_dir: true\n",
6939        );
6940        assert!(
6941            msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
6942            "{msg}"
6943        );
6944        assert!(msg.contains("ruby"), "{msg}");
6945        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
6946    }
6947
6948    #[test]
6949    fn interpreter_runtime_rejects_relative_path() {
6950        let msg = interpreter_runtime_reject(
6951            "      - binary: python3\n        dirs:\n          - path: usr/lib/python3.11\n            list_dir: true\n",
6952        );
6953        assert!(
6954            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
6955            "{msg}"
6956        );
6957        assert!(msg.contains("absolute"), "{msg}");
6958    }
6959
6960    // ── capabilities.plan (capsule-wide) ─────────────────────────────────────
6961
6962    /// A capsule manifest whose capsule-wide capabilities carry `plan: <block>`.
6963    fn manifest_with_plan(block: &str) -> String {
6964        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n{block}")
6965    }
6966
6967    #[test]
6968    fn plan_submit_true_grants_the_capsule() {
6969        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n"))
6970            .expect("a plan block parses");
6971        assert_eq!(
6972            manifest.capabilities.unwrap().plan,
6973            Some(PlanCapabilities { submit: true })
6974        );
6975    }
6976
6977    /// `submit: false` and an absent `plan:` block both leave the capsule ungranted; the
6978    /// difference between them is only whether the operator wrote the denial down.
6979    #[test]
6980    fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
6981        let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: false\n"))
6982            .expect("an explicit denial parses");
6983        assert_eq!(
6984            explicit.capabilities.unwrap().plan,
6985            Some(PlanCapabilities { submit: false })
6986        );
6987
6988        let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
6989            .expect("no capabilities block parses");
6990        assert!(absent.capabilities.is_none());
6991    }
6992
6993    /// The capability is never inferred: a `plan:` block that omits `submit:` is a parse error
6994    /// naming the key, exactly as `task_io:` without `read:` is.
6995    #[test]
6996    fn plan_without_submit_is_rejected() {
6997        let err = RuntimeManifest::from_yaml_str(&manifest_with_plan("    {}\n"))
6998            .expect_err("an empty plan block is refused");
6999        let msg = err.to_string();
7000        assert!(msg.contains("capabilities.plan.submit"), "{msg}");
7001        assert!(msg.contains("never"), "{msg}");
7002    }
7003
7004    /// An unknown key inside the block is reported by path, like every other capability block.
7005    #[test]
7006    fn an_unknown_plan_key_is_reported_by_path() {
7007        let manifest =
7008            RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n    depth: 3\n"))
7009                .expect("an unknown key is a warning, not a parse error");
7010        assert!(
7011            manifest
7012                .unknown_keys
7013                .iter()
7014                .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
7015            "{:?}",
7016            manifest.unknown_keys
7017        );
7018    }
7019
7020    // ── capabilities.install (capsule-wide) ──────────────────────────────────
7021
7022    /// A capsule manifest whose capsule-wide capabilities carry `install: <block>`.
7023    fn manifest_with_install(block: &str) -> String {
7024        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  install:\n{block}")
7025    }
7026
7027    #[test]
7028    fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
7029        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
7030            "    skill: [code-review, \"style-*\"]\n    tool: [\"b-*\", a, \"*\"]\n",
7031        ))
7032        .expect("an install block parses");
7033        assert_eq!(
7034            manifest.capabilities.unwrap().install,
7035            Some(InstallCapabilities {
7036                skill: vec!["code-review".into(), "style-*".into()],
7037                tool: vec!["b-*".into(), "a".into(), "*".into()],
7038            })
7039        );
7040    }
7041
7042    /// Either list alone is a grant; the other defaults to empty, which admits nothing of its
7043    /// kind.
7044    #[test]
7045    fn capabilities_install_accepts_either_list_alone() {
7046        let skill_only = RuntimeManifest::from_yaml_str(&manifest_with_install("    skill: [a]\n"))
7047            .expect("skill alone parses");
7048        assert_eq!(
7049            skill_only.capabilities.unwrap().install,
7050            Some(InstallCapabilities {
7051                skill: vec!["a".into()],
7052                tool: vec![],
7053            })
7054        );
7055        let tool_only =
7056            RuntimeManifest::from_yaml_str(&manifest_with_install("    tool: [\"b-*\"]\n"))
7057                .expect("tool alone parses");
7058        assert_eq!(
7059            tool_only.capabilities.unwrap().install,
7060            Some(InstallCapabilities {
7061                skill: vec![],
7062                tool: vec!["b-*".into()],
7063            })
7064        );
7065    }
7066
7067    #[test]
7068    fn capabilities_install_absent_is_none() {
7069        let manifest = RuntimeManifest::from_yaml_str(
7070            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n    submit: true\n",
7071        )
7072        .expect("a capabilities block without install parses");
7073        assert_eq!(manifest.capabilities.unwrap().install, None);
7074    }
7075
7076    /// A block that names nothing is refused rather than read as "grant nothing": omitting the
7077    /// block is how a capsule grants nothing.
7078    #[test]
7079    fn capabilities_install_that_names_nothing_is_refused() {
7080        for block in [
7081            "    {}\n",
7082            "    skill: []\n",
7083            "    skill: []\n    tool: []\n",
7084            "    driver: [x]\n",
7085        ] {
7086            let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
7087                .expect_err("an install block naming nothing is refused");
7088            match &err {
7089                RuntimeManifestError::InvalidCapabilities { field, message } => {
7090                    assert_eq!(field, "capabilities.install", "{block:?}");
7091                    assert!(message.contains("at least one"), "{message}");
7092                    assert!(message.contains("omit the block"), "{message}");
7093                }
7094                other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
7095            }
7096        }
7097    }
7098
7099    #[test]
7100    fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
7101        for entry in ["a*b", "*x", "**", "Foo", "-a", "a/b", " "] {
7102            for key in ["skill", "tool"] {
7103                let block = format!("    {key}: [{entry:?}]\n");
7104                let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
7105                    .expect_err("a bad entry is refused");
7106                match &err {
7107                    RuntimeManifestError::InvalidCapabilities { field, message } => {
7108                        assert_eq!(field, &format!("capabilities.install.{key}"));
7109                        assert!(message.contains(&format!("{entry:?}")), "{message}");
7110                        assert!(
7111                            message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
7112                            "{message}"
7113                        );
7114                    }
7115                    other => panic!("{entry:?}: expected InvalidCapabilities, got {other:?}"),
7116                }
7117            }
7118        }
7119    }
7120
7121    /// The grant is held by the session, so a per-artifact block is refused on every role.
7122    #[test]
7123    fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
7124        for runtime in ["tool", "hook", "driver", "skill"] {
7125            let yaml = format!(
7126                "name: cap\nversion: 0.0.1\nartifacts:\n  - name: a\n    version: 0.1.0\n    \
7127                 runtime: {runtime}\n    capabilities:\n      install:\n        skill: [x]\n"
7128            );
7129            let err = RuntimeManifest::from_yaml_str(&yaml)
7130                .expect_err("a per-artifact install block is refused");
7131            match &err {
7132                RuntimeManifestError::InvalidArtifact { index, message } => {
7133                    assert_eq!(*index, 0);
7134                    assert!(message.contains("capabilities.install"), "{message}");
7135                    assert!(message.contains("capsule-wide"), "{message}");
7136                }
7137                other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
7138            }
7139        }
7140    }
7141
7142    /// An unknown key inside the block is reported by path and grants nothing.
7143    #[test]
7144    fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
7145        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
7146            "    skill: [a]\n    driver: [x]\n",
7147        ))
7148        .expect("an unknown key beside a real entry is a warning, not a parse error");
7149        assert!(
7150            manifest
7151                .unknown_keys
7152                .iter()
7153                .any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
7154            "{:?}",
7155            manifest.unknown_keys
7156        );
7157        assert_eq!(
7158            manifest.capabilities.unwrap().install,
7159            Some(InstallCapabilities {
7160                skill: vec!["a".into()],
7161                tool: vec![],
7162            })
7163        );
7164    }
7165
7166    // ── capabilities.task_io (per-hook, honored only on runtime: hook) ────────
7167
7168    /// A capsule manifest with one artifact entry carrying `capabilities: task_io: <block>`.
7169    fn manifest_with_task_io(runtime: &str, block: &str) -> String {
7170        format!(
7171            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: gate\n    version: 1.2.3\n    \
7172             runtime: {runtime}\n    capabilities:\n      task_io:\n{block}"
7173        )
7174    }
7175
7176    #[test]
7177    fn task_io_read_true_grants_the_hook() {
7178        let manifest =
7179            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: true\n"))
7180                .expect("task_io on a hook entry parses");
7181        assert_eq!(
7182            manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
7183            Some(TaskIoCapabilities { read: true })
7184        );
7185    }
7186
7187    /// `read: false` and an absent `task_io:` block both leave the hook ungranted; the
7188    /// difference between them is only whether the operator wrote the denial down.
7189    #[test]
7190    fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
7191        let explicit =
7192            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: false\n"))
7193                .expect("an explicit denial parses");
7194        assert_eq!(
7195            explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
7196            Some(TaskIoCapabilities { read: false })
7197        );
7198
7199        let absent = RuntimeManifest::from_yaml_str(
7200            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: gate\n    version: 1.2.3\n    \
7201             runtime: hook\n",
7202        )
7203        .expect("no capabilities block parses");
7204        assert!(absent.artifacts[0].capabilities.is_none());
7205    }
7206
7207    /// The capability is never inferred: a `task_io:` block that omits `read:` is a parse
7208    /// error naming the key, following `interpreter_runtime[].dirs[].list_dir`.
7209    #[test]
7210    fn task_io_without_read_is_rejected() {
7211        let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        {}\n"))
7212            .unwrap_err();
7213        let msg = err.to_string();
7214        assert!(
7215            msg.contains("capabilities.task_io.read"),
7216            "error was: {msg}"
7217        );
7218        assert!(msg.contains("explicitly"), "error was: {msg}");
7219    }
7220
7221    /// Nothing outside a hook can be handed the import, so declaring the key there is a parse
7222    /// error rather than a silently inert grant.
7223    #[test]
7224    fn task_io_outside_a_hook_entry_is_rejected() {
7225        for runtime in ["tool", "driver", "skill"] {
7226            let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
7227                runtime,
7228                "        read: true\n",
7229            ))
7230            .unwrap_err();
7231            let msg = err.to_string();
7232            assert!(
7233                msg.contains("capabilities") && msg.contains("runtime: hook"),
7234                "{runtime} entry: error was: {msg}"
7235            );
7236        }
7237    }
7238
7239    /// The capsule-wide block reaches capsule, tool and driver components, none of which can
7240    /// receive the import — so it is rejected there too, naming the key.
7241    #[test]
7242    fn task_io_in_the_capsule_wide_block_is_rejected() {
7243        let err = RuntimeManifest::from_yaml_str(
7244            "name: cap\nversion: 0.0.1\ncapabilities:\n  task_io:\n    read: true\n",
7245        )
7246        .unwrap_err();
7247        let msg = err.to_string();
7248        assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
7249        assert!(msg.contains("runtime: hook"), "error was: {msg}");
7250    }
7251
7252    // ── capabilities.conversation (per-hook, honored only on runtime: hook) ───
7253
7254    /// A capsule manifest with one artifact entry carrying `capabilities: conversation: <block>`.
7255    fn manifest_with_conversation(runtime: &str, block: &str) -> String {
7256        format!(
7257            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: memory\n    version: 1.2.3\n    \
7258             runtime: {runtime}\n    capabilities:\n      conversation:\n{block}"
7259        )
7260    }
7261
7262    #[test]
7263    fn conversation_read_lowers_onto_the_hook_entry() {
7264        for (declared, expected) in [("true", true), ("false", false)] {
7265            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
7266                "hook",
7267                &format!("        read: {declared}\n"),
7268            ))
7269            .expect("conversation on a hook entry parses");
7270            assert_eq!(
7271                manifest.artifacts[0]
7272                    .capabilities
7273                    .as_ref()
7274                    .unwrap()
7275                    .conversation,
7276                Some(ConversationCapabilities { read: expected })
7277            );
7278        }
7279    }
7280
7281    /// The capability is never inferred: a block that omits `read:` is a parse error naming the
7282    /// key, exactly as `capabilities.task_io.read` is.
7283    #[test]
7284    fn conversation_without_read_is_rejected() {
7285        let err =
7286            RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", "        {}\n"))
7287                .unwrap_err();
7288        let msg = err.to_string();
7289        assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
7290        assert!(msg.contains("explicitly"), "was: {msg}");
7291    }
7292
7293    /// Only a hook's world can import the interface, so declaring the grant on any other role is
7294    /// a parse error rather than a silently inert block.
7295    #[test]
7296    fn conversation_outside_a_hook_entry_is_rejected() {
7297        for runtime in ["tool", "driver", "skill"] {
7298            let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
7299                runtime,
7300                "        read: true\n",
7301            ))
7302            .unwrap_err();
7303            let msg = err.to_string();
7304            assert!(
7305                msg.contains("capabilities") && msg.contains("runtime: hook"),
7306                "{runtime} entry: error was: {msg}"
7307            );
7308        }
7309    }
7310
7311    /// Unlike `task_io`, the capsule-wide block parses: it is inert, and the runtime says so once
7312    /// as `W-SEC-016` rather than refusing a manifest an operator can fix at leisure.
7313    #[test]
7314    fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
7315        let manifest = RuntimeManifest::from_yaml_str(
7316            "name: cap\nversion: 0.0.1\ncapabilities:\n  conversation:\n    read: true\n",
7317        )
7318        .expect("a capsule-wide block is accepted");
7319        assert_eq!(
7320            manifest.capabilities.unwrap().conversation,
7321            Some(ConversationCapabilities { read: true })
7322        );
7323    }
7324
7325    // ── context.record / context.record_store ────────────────────────────────
7326
7327    /// The record is on unless the manifest says otherwise, and `record_store` defaults to
7328    /// nothing — the runtime substitutes the capsule name.
7329    #[test]
7330    fn a_context_block_records_by_default() {
7331        let context = context_of("context:\n  max_tokens: 1000\n").unwrap();
7332        assert!(context.record);
7333        assert_eq!(context.record_store, None);
7334    }
7335
7336    #[test]
7337    fn record_off_and_on_are_the_two_accepted_values() {
7338        assert!(!context_of("context:\n  record: off\n").unwrap().record);
7339        assert!(context_of("context:\n  record: on\n").unwrap().record);
7340
7341        let err = RuntimeManifest::from_yaml_str(
7342            "name: cap\nversion: 0.0.1\ncontext:\n  record: sometimes\n",
7343        )
7344        .unwrap_err();
7345        let msg = err.to_string();
7346        assert!(msg.contains("context.record"), "was: {msg}");
7347        assert!(msg.contains("sometimes"), "was: {msg}");
7348    }
7349
7350    /// `record_store` is a name the runtime turns into a directory, so it is kept verbatim apart
7351    /// from surrounding whitespace; its *shape* is refused at launch, where the path is built.
7352    #[test]
7353    fn record_store_is_kept_verbatim_and_trimmed() {
7354        assert_eq!(
7355            context_of("context:\n  record_store: '  shey  '\n")
7356                .unwrap()
7357                .record_store,
7358            Some("shey".to_string())
7359        );
7360        assert_eq!(
7361            context_of("context:\n  record: off\n  record_store: shey\n")
7362                .unwrap()
7363                .record_store,
7364            Some("shey".to_string()),
7365            "a store declared beside record: off is kept and simply never used"
7366        );
7367    }
7368
7369    #[test]
7370    fn interpreter_runtime_rejects_missing_list_dir() {
7371        let msg = interpreter_runtime_reject(
7372            "      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.11\n",
7373        );
7374        assert!(
7375            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
7376            "{msg}"
7377        );
7378        assert!(msg.contains("explicitly"), "{msg}");
7379    }
7380
7381    #[test]
7382    fn interpreter_runtime_rejects_empty_dirs() {
7383        let msg = interpreter_runtime_reject("      - binary: python3\n        dirs: []\n");
7384        assert!(
7385            msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
7386            "{msg}"
7387        );
7388        assert!(msg.contains("at least one"), "{msg}");
7389    }
7390
7391    #[test]
7392    fn staged_runtime_parses_accepted_shape() {
7393        let manifest = RuntimeManifest::from_yaml_str(
7394            r#"
7395name: cap
7396version: 0.0.1
7397artifacts: []
7398capabilities:
7399  containment: sealed
7400  shell:
7401    allow:
7402      - python3
7403      - bash
7404    staged_runtime:
7405      - binary: python3
7406        source_path: /opt/testbed/conda/envs/django__django
7407        pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
7408"#,
7409        )
7410        .unwrap();
7411
7412        let shell = manifest.capabilities.unwrap().shell.unwrap();
7413        assert_eq!(
7414            shell.staged_runtime,
7415            vec![StagedRuntimeGrant {
7416                binary: "python3".to_string(),
7417                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
7418                pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
7419            }]
7420        );
7421        // The two mechanisms are alternatives, so an accepted `staged_runtime` grant leaves the
7422        // interpreter_runtime list untouched rather than populating it as a side effect.
7423        assert!(shell.interpreter_runtime.is_empty());
7424    }
7425
7426    #[test]
7427    fn staged_runtime_defaults_to_empty_when_absent() {
7428        let manifest = RuntimeManifest::from_yaml_str(
7429            r#"
7430name: cap
7431version: 0.0.1
7432artifacts: []
7433capabilities:
7434  shell:
7435    allow:
7436      - python3
7437"#,
7438        )
7439        .unwrap();
7440
7441        let shell = manifest.capabilities.unwrap().shell.unwrap();
7442        assert!(shell.staged_runtime.is_empty());
7443    }
7444
7445    /// Shared helper: parse a manifest whose sole `staged_runtime` grant is `grant_yaml` (indented
7446    /// to sit under `staged_runtime:`), returning the error message. `extra` is spliced into the
7447    /// same `shell` block, which is how the mutual-exclusion case adds an `interpreter_runtime`.
7448    fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
7449        let yaml = format!(
7450            r#"
7451name: cap
7452version: 0.0.1
7453artifacts: []
7454capabilities:
7455  shell:
7456    allow:
7457      - python3
7458{extra}    staged_runtime:
7459{grant_yaml}
7460"#,
7461        );
7462        RuntimeManifest::from_yaml_str(&yaml)
7463            .expect_err("grant should be rejected")
7464            .to_string()
7465    }
7466
7467    #[test]
7468    fn staged_runtime_rejects_binary_not_in_allow() {
7469        let msg = staged_runtime_reject(
7470            "      - binary: ruby\n        source_path: /opt/ruby\n        pin: ruby-3.2.2\n",
7471            "",
7472        );
7473        assert!(
7474            msg.contains("capabilities.shell.staged_runtime[0].binary"),
7475            "{msg}"
7476        );
7477        assert!(msg.contains("ruby"), "{msg}");
7478        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
7479    }
7480
7481    #[test]
7482    fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
7483        let msg = staged_runtime_reject(
7484            "      - binary: python3\n        source_path: /opt/py\n        pin: cpython-3.9.19\n",
7485            "    interpreter_runtime:\n      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.9\n            list_dir: true\n",
7486        );
7487        assert!(
7488            msg.contains("capabilities.shell.staged_runtime[0].binary"),
7489            "{msg}"
7490        );
7491        assert!(msg.contains("python3"), "{msg}");
7492        assert!(msg.contains("interpreter_runtime"), "{msg}");
7493        assert!(msg.contains("mutually exclusive"), "{msg}");
7494    }
7495
7496    #[test]
7497    fn staged_runtime_rejects_relative_source_path() {
7498        let msg = staged_runtime_reject(
7499            "      - binary: python3\n        source_path: opt/testbed/conda\n        pin: cpython-3.9.19\n",
7500            "",
7501        );
7502        assert!(
7503            msg.contains("capabilities.shell.staged_runtime[0].source_path"),
7504            "{msg}"
7505        );
7506        assert!(msg.contains("absolute"), "{msg}");
7507    }
7508
7509    #[test]
7510    fn staged_runtime_rejects_missing_or_empty_pin() {
7511        // Omitted entirely, and present-but-blank: both are "unpinned", and the field exists
7512        // precisely so that state is unrepresentable.
7513        for grant in [
7514            "      - binary: python3\n        source_path: /opt/testbed/conda\n",
7515            "      - binary: python3\n        source_path: /opt/testbed/conda\n        pin: \"   \"\n",
7516        ] {
7517            let msg = staged_runtime_reject(grant, "");
7518            assert!(
7519                msg.contains("capabilities.shell.staged_runtime[0].pin"),
7520                "{msg}"
7521            );
7522            assert!(msg.contains("never inferred"), "{msg}");
7523        }
7524    }
7525
7526    #[test]
7527    fn parses_inference_block() {
7528        let manifest = RuntimeManifest::from_yaml_str(
7529            r#"
7530name: cap
7531version: 0.0.1
7532artifacts:
7533  - name: murmur-driver-anthropic
7534    version: 0.1.0
7535    runtime: driver
7536    gateway:
7537      endpoint: http://127.0.0.1:8080
7538      api_key: literal-key
7539inference:
7540  transport: http
7541  model: test-model
7542  driver:
7543    artifact: murmur-driver-anthropic
7544    config:
7545      temperature: 0.2
7546      headers:
7547        x-test: true
7548"#,
7549        )
7550        .unwrap();
7551
7552        assert_eq!(
7553            manifest.artifacts[0].gateway,
7554            Some(ArtifactGateway {
7555                endpoint: "http://127.0.0.1:8080".to_string(),
7556                api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
7557                keyless: false,
7558            })
7559        );
7560        let inference = manifest.inference.expect("inference should exist");
7561        assert_eq!(inference.transport, "http");
7562        assert_eq!(inference.model, "test-model");
7563        let driver = inference.driver.as_ref().expect("driver should be present");
7564        assert_eq!(driver.artifact, "murmur-driver-anthropic");
7565        assert_eq!(
7566            driver.config,
7567            Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
7568        );
7569        assert!(inference.system_prompt.is_none());
7570        assert!(inference.system_prompt_file.is_none());
7571    }
7572
7573    /// A capsule whose one tool entry declares `gateway:` with `block` as its body, indented
7574    /// under the key.
7575    fn tool_gateway_manifest(block: &str) -> String {
7576        format!(
7577            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: web-search\n    version: 0.1.0\n    runtime: tool\n    gateway:{block}\n"
7578        )
7579    }
7580
7581    fn gateway_endpoint_error(endpoint: &str) -> String {
7582        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
7583            "\n      endpoint: \"{endpoint}\""
7584        )))
7585        .unwrap_err()
7586        .to_string()
7587    }
7588
7589    #[test]
7590    fn gateway_endpoint_https_is_accepted() {
7591        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7592            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
7593        ))
7594        .unwrap();
7595        assert_eq!(
7596            manifest.artifacts[0].gateway,
7597            Some(ArtifactGateway {
7598                endpoint: "https://api.tavily.com".to_string(),
7599                api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
7600                keyless: false,
7601            })
7602        );
7603    }
7604
7605    #[test]
7606    fn gateway_endpoint_http_localhost_is_accepted() {
7607        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7608            "\n      endpoint: http://localhost:11434\n      keyless: true",
7609        ))
7610        .unwrap();
7611        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
7612        assert_eq!(gateway.endpoint, "http://localhost:11434");
7613        assert_eq!(gateway.api_key, None);
7614        assert!(gateway.keyless);
7615    }
7616
7617    #[test]
7618    fn gateway_endpoint_http_loopback_ip_is_accepted() {
7619        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7620            "\n      endpoint: http://127.0.0.1:8080\n      api_key: ${LOCAL_KEY}",
7621        ))
7622        .unwrap();
7623        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
7624        assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
7625        assert_eq!(
7626            gateway.api_key,
7627            Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
7628        );
7629    }
7630
7631    #[test]
7632    fn gateway_endpoint_without_scheme_is_refused() {
7633        let msg = gateway_endpoint_error("api.tavily.com");
7634        assert!(msg.contains("gateway.endpoint"), "{msg}");
7635        assert!(msg.contains("artifact 'web-search'"), "{msg}");
7636        assert!(msg.contains("does not parse as a URL"), "{msg}");
7637    }
7638
7639    #[test]
7640    fn gateway_endpoint_ftp_scheme_is_refused() {
7641        let msg = gateway_endpoint_error("ftp://example.com");
7642        assert!(msg.contains("gateway.endpoint"), "{msg}");
7643        assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
7644    }
7645
7646    #[test]
7647    fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
7648        let msg = gateway_endpoint_error("http://api.attacker.example.com");
7649        assert!(msg.contains("gateway.endpoint"), "{msg}");
7650        assert!(msg.contains("api.attacker.example.com"), "{msg}");
7651        assert!(msg.contains("non-loopback"), "{msg}");
7652    }
7653
7654    #[test]
7655    fn gateway_endpoint_with_a_query_is_refused() {
7656        let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
7657        assert!(msg.contains("gateway.endpoint"), "{msg}");
7658        assert!(msg.contains("carries a query"), "{msg}");
7659    }
7660
7661    #[test]
7662    fn gateway_endpoint_with_a_fragment_is_refused() {
7663        let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
7664        assert!(msg.contains("gateway.endpoint"), "{msg}");
7665        assert!(msg.contains("carries a fragment"), "{msg}");
7666    }
7667
7668    #[test]
7669    fn gateway_endpoint_with_userinfo_is_refused() {
7670        let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
7671        assert!(msg.contains("artifact 'web-search'"), "{msg}");
7672        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
7673        assert!(msg.contains("host 'api.example.com'"), "{msg}");
7674        assert!(msg.contains("gateway.api_key"), "{msg}");
7675        assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
7676        assert!(
7677            !msg.contains("sk-in-url"),
7678            "a password in the URL is never echoed: {msg}"
7679        );
7680    }
7681
7682    #[test]
7683    fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
7684        let msg = gateway_endpoint_error("https://user:sk-in-url@");
7685        assert!(msg.contains("does not parse as a URL"), "{msg}");
7686        assert!(
7687            !msg.contains("sk-in-url"),
7688            "a password in the URL is never echoed: {msg}"
7689        );
7690    }
7691
7692    #[test]
7693    fn gateway_endpoint_with_a_masquerading_host_is_refused() {
7694        let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
7695        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
7696        assert!(
7697            msg.contains("addresses host '127.0.0.1'"),
7698            "the message names the host the URL really addresses: {msg}"
7699        );
7700    }
7701
7702    #[test]
7703    fn gateway_endpoint_with_interpolation_is_refused() {
7704        let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
7705        assert!(msg.contains("artifact 'web-search'"), "{msg}");
7706        assert!(msg.contains("contains '${'"), "{msg}");
7707        assert!(msg.contains("not interpolated"), "{msg}");
7708    }
7709
7710    #[test]
7711    fn gateway_endpoint_http_unspecified_address_is_refused() {
7712        let msg = gateway_endpoint_error("http://0.0.0.0:8080");
7713        assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
7714    }
7715
7716    #[test]
7717    fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
7718        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7719            "\n      endpoint: http://[::1]:8080\n      api_key: ${LOCAL_KEY}",
7720        ))
7721        .unwrap();
7722        assert_eq!(
7723            manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
7724            "http://[::1]:8080"
7725        );
7726    }
7727
7728    /// The error a tool entry whose `gateway:` body is `block` draws, parsed with secrets resolved.
7729    fn gateway_credential_error(block: &str) -> RuntimeManifestError {
7730        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
7731    }
7732
7733    fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
7734        match &error {
7735            RuntimeManifestError::GatewayWithoutCredential {
7736                index,
7737                name,
7738                endpoint: refused,
7739            } => {
7740                assert_eq!(*index, 0);
7741                assert_eq!(name, "web-search");
7742                assert_eq!(refused, endpoint);
7743            }
7744            other => panic!("expected GatewayWithoutCredential, got {other:?}"),
7745        }
7746        let msg = error.to_string();
7747        assert!(
7748            msg.contains(&format!(
7749                "artifact 'web-search' declares gateway.endpoint '{endpoint}' but binds no \
7750                 credential"
7751            )),
7752            "{msg}"
7753        );
7754    }
7755
7756    #[test]
7757    fn gateway_credential_absent_is_refused() {
7758        assert_without_credential(
7759            gateway_credential_error("\n      endpoint: https://api.tavily.com"),
7760            "https://api.tavily.com",
7761        );
7762    }
7763
7764    #[test]
7765    fn gateway_credential_blank_is_refused() {
7766        for key in ["\"\"", "\"   \"", "''"] {
7767            assert_without_credential(
7768                gateway_credential_error(&format!(
7769                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
7770                )),
7771                "https://api.tavily.com",
7772            );
7773        }
7774    }
7775
7776    #[test]
7777    fn gateway_credential_null_is_refused() {
7778        for key in ["~", "null", ""] {
7779            assert_without_credential(
7780                gateway_credential_error(&format!(
7781                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
7782                )),
7783                "https://api.tavily.com",
7784            );
7785        }
7786    }
7787
7788    #[test]
7789    fn gateway_credential_keyless_false_is_refused() {
7790        assert_without_credential(
7791            gateway_credential_error(
7792                "\n      endpoint: https://api.tavily.com\n      keyless: false",
7793            ),
7794            "https://api.tavily.com",
7795        );
7796    }
7797
7798    #[test]
7799    fn gateway_credential_keyless_is_accepted() {
7800        for (parse, mode) in [
7801            (
7802                RuntimeManifest::from_yaml_str as fn(&str) -> _,
7803                "with secrets",
7804            ),
7805            (
7806                RuntimeManifest::from_yaml_str_without_secrets,
7807                "without secrets",
7808            ),
7809        ] {
7810            let manifest = parse(&tool_gateway_manifest(
7811                "\n      endpoint: https://api.example.com\n      keyless: true",
7812            ))
7813            .unwrap();
7814            assert_eq!(
7815                manifest.artifacts[0].gateway,
7816                Some(ArtifactGateway {
7817                    endpoint: "https://api.example.com".to_string(),
7818                    api_key: None,
7819                    keyless: true,
7820                }),
7821                "{mode}"
7822            );
7823        }
7824    }
7825
7826    #[test]
7827    fn gateway_credential_keyless_not_a_boolean_is_refused() {
7828        for value in ["\"true\"", "yes please", "1"] {
7829            let error = gateway_credential_error(&format!(
7830                "\n      endpoint: https://api.tavily.com\n      keyless: {value}"
7831            ));
7832            assert!(
7833                matches!(
7834                    error,
7835                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
7836                ),
7837                "{value}: {error:?}"
7838            );
7839            assert!(
7840                error
7841                    .to_string()
7842                    .contains("gateway.keyless with a value that is not a boolean"),
7843                "{error}"
7844            );
7845        }
7846    }
7847
7848    #[test]
7849    fn gateway_credential_and_keyless_together_are_refused() {
7850        for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
7851            let error = gateway_credential_error(&format!(
7852                "\n      endpoint: https://api.tavily.com\n      api_key: {key}\n      keyless: true"
7853            ));
7854            assert!(
7855                matches!(
7856                    error,
7857                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
7858                ),
7859                "{error:?}"
7860            );
7861            assert!(
7862                error.to_string().contains(
7863                    "artifact 'web-search' declares both gateway.api_key and gateway.keyless: \
7864                     true; a gateway either presents a key or is keyless"
7865                ),
7866                "{error}"
7867            );
7868        }
7869    }
7870
7871    #[test]
7872    fn gateway_credential_absent_is_refused_without_secrets() {
7873        for block in [
7874            "\n      endpoint: https://api.tavily.com",
7875            "\n      endpoint: https://api.tavily.com\n      api_key: \"\"",
7876            "\n      endpoint: https://api.tavily.com\n      keyless: false",
7877        ] {
7878            assert_without_credential(
7879                RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
7880                    .unwrap_err(),
7881                "https://api.tavily.com",
7882            );
7883        }
7884        let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
7885            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
7886        ))
7887        .unwrap();
7888        let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
7889        assert_eq!(gateway.api_key, None, "the key reference is left unread");
7890        assert!(!gateway.keyless, "a keyed gateway is never keyless");
7891    }
7892
7893    /// The refusal reads what the manifest wrote, never the address: a loopback or private
7894    /// endpoint with no credential is refused exactly as a public one is.
7895    #[test]
7896    fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
7897        for endpoint in [
7898            "http://127.0.0.1:11434",
7899            "http://localhost:11434",
7900            "http://[::1]:11434",
7901            "https://10.0.0.5",
7902            "https://192.168.1.10:8443",
7903        ] {
7904            assert_without_credential(
7905                gateway_credential_error(&format!("\n      endpoint: {endpoint}")),
7906                endpoint,
7907            );
7908        }
7909    }
7910
7911    #[test]
7912    fn gateway_endpoint_missing_from_the_block_is_refused() {
7913        for block in [
7914            "\n      api_key: ${TAVILY_API_KEY}",
7915            "",
7916            " {}",
7917            "\n      endpoint: \"  \"",
7918        ] {
7919            let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
7920                .unwrap_err()
7921                .to_string();
7922            assert!(
7923                msg.contains("without 'gateway.endpoint'"),
7924                "{block:?}: {msg}"
7925            );
7926            assert!(msg.contains("artifact 'web-search'"), "{block:?}: {msg}");
7927        }
7928    }
7929
7930    #[test]
7931    fn gateway_block_unknown_key_is_reported() {
7932        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7933            "\n      endpoint: https://api.tavily.com\n      keyless: true\n      api_kee: x",
7934        ))
7935        .unwrap();
7936        let unknown = manifest.unknown_keys;
7937        assert_eq!(unknown.len(), 1, "{unknown:?}");
7938        assert_eq!(unknown[0].key, "api_kee");
7939        assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
7940    }
7941
7942    #[test]
7943    fn gateway_on_skill_entry_is_refused() {
7944        let msg = RuntimeManifest::from_yaml_str(
7945            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: notes\n    version: 0.1.0\n    runtime: skill\n    gateway:\n      endpoint: https://api.tavily.com\n      api_key: test-key\n",
7946        )
7947        .unwrap_err()
7948        .to_string();
7949        assert!(
7950            msg.contains("artifact 'notes' declares 'gateway:'"),
7951            "{msg}"
7952        );
7953        assert!(msg.contains("runtime: skill"), "{msg}");
7954    }
7955
7956    #[test]
7957    fn gateway_on_hook_entry_is_accepted() {
7958        let manifest = RuntimeManifest::from_yaml_str(
7959            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: grafana\n    version: 0.1.0\n    runtime: hook\n    gateway:\n      endpoint: https://grafana.example.com/otlp\n      api_key: glc_literal\n",
7960        )
7961        .unwrap();
7962        assert!(manifest.artifacts[0].gateway.is_some());
7963    }
7964
7965    /// An `http` driver capsule. `driver_entry` is the YAML of the driver's artifact entry, or
7966    /// empty for none.
7967    fn http_driver_manifest(artifacts: &str) -> String {
7968        format!(
7969            "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n  transport: http\n  model: test-model\n  driver:\n    artifact: murmur-driver-anthropic\n"
7970        )
7971    }
7972
7973    #[test]
7974    fn http_driver_entry_without_gateway_endpoint_is_refused() {
7975        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
7976            "\n  - name: murmur-driver-anthropic\n    version: 0.1.0\n    runtime: driver",
7977        ))
7978        .unwrap_err()
7979        .to_string();
7980        assert!(msg.contains("'murmur-driver-anthropic'"), "{msg}");
7981        assert!(msg.contains("gateway.endpoint"), "{msg}");
7982    }
7983
7984    #[test]
7985    fn http_driver_absent_from_artifacts_is_refused() {
7986        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
7987            .unwrap_err()
7988            .to_string();
7989        assert!(msg.contains("inference.driver.artifact"), "{msg}");
7990        assert!(msg.contains("not declared in artifacts:"), "{msg}");
7991    }
7992
7993    #[test]
7994    fn gateway_on_unconfigured_driver_entry_is_refused() {
7995        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
7996            "\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",
7997        ))
7998        .unwrap_err()
7999        .to_string();
8000        assert!(msg.contains("'murmur-driver-openai'"), "{msg}");
8001        assert!(
8002            msg.contains("accepted only on the entry inference.driver.artifact names"),
8003            "{msg}"
8004        );
8005
8006        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";
8007        let msg = RuntimeManifest::from_yaml_str(no_inference)
8008            .unwrap_err()
8009            .to_string();
8010        assert!(msg.contains("not the configured inference driver"), "{msg}");
8011    }
8012
8013    #[test]
8014    fn gateway_on_driver_entry_under_process_transport_is_refused() {
8015        let msg = RuntimeManifest::from_yaml_str(
8016            "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",
8017        )
8018        .unwrap_err()
8019        .to_string();
8020        assert!(msg.contains("'murmur-driver-anthropic'"), "{msg}");
8021        assert!(msg.contains("transport: process"), "{msg}");
8022    }
8023
8024    #[test]
8025    fn inference_api_key_is_refused_naming_gateway_api_key() {
8026        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";
8027        for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
8028            let manifest = http_driver_manifest(entry)
8029                .replace("  model:", &format!("  api_key: {value}\n  model:"));
8030            let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
8031            let msg = err.to_string();
8032            assert!(msg.contains("'inference.api_key'"), "{msg}");
8033            assert!(msg.contains("was removed"), "{msg}");
8034            assert!(
8035                msg.contains(
8036                    "set gateway.api_key on the artifacts: entry 'murmur-driver-anthropic'"
8037                ),
8038                "{msg}"
8039            );
8040            assert!(!msg.contains("sk-literal"), "{msg}");
8041        }
8042
8043        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  api_key: ${KEY}\n";
8044        let msg = RuntimeManifest::from_yaml_str(process)
8045            .unwrap_err()
8046            .to_string();
8047        assert!(msg.contains("'inference.api_key'"), "{msg}");
8048        assert!(
8049            msg.contains("on the artifacts: entry inference.driver.artifact names"),
8050            "{msg}"
8051        );
8052        assert!(
8053            RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
8054            "the refusal does not depend on resolving secrets"
8055        );
8056    }
8057
8058    #[test]
8059    fn inference_endpoint_is_refused_naming_gateway_endpoint() {
8060        let manifest = http_driver_manifest(
8061            "\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",
8062        )
8063        .replace("  model:", "  endpoint: https://api.anthropic.com\n  model:");
8064        let msg = RuntimeManifest::from_yaml_str(&manifest)
8065            .unwrap_err()
8066            .to_string();
8067        assert!(msg.contains("'inference.endpoint'"), "{msg}");
8068        assert!(
8069            msg.contains("set gateway.endpoint on the artifacts: entry 'murmur-driver-anthropic'"),
8070            "{msg}"
8071        );
8072
8073        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  endpoint: http://localhost:8080\n";
8074        let msg = RuntimeManifest::from_yaml_str(process)
8075            .unwrap_err()
8076            .to_string();
8077        assert!(msg.contains("'inference.endpoint'"), "{msg}");
8078        assert!(msg.contains("was removed"), "{msg}");
8079    }
8080
8081    #[test]
8082    fn without_secrets_leaves_gateway_api_key_unread() {
8083        let manifest = tool_gateway_manifest(
8084            "\n      endpoint: https://api.tavily.com\n      api_key: tvly-literal",
8085        );
8086        let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
8087        assert_eq!(
8088            resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
8089            Some(ApiKeyReference::Literal("tvly-literal".to_string()))
8090        );
8091        let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
8092        let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
8093        assert_eq!(gateway.endpoint, "https://api.tavily.com");
8094        assert_eq!(gateway.api_key, None);
8095    }
8096
8097    #[test]
8098    fn referenced_env_variables_reads_gateway_api_key() {
8099        let manifest = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: 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: 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";
8100        let referenced = referenced_env_variables(manifest);
8101        assert_eq!(
8102            referenced,
8103            vec![
8104                ReferencedEnvVariable {
8105                    field: "artifacts.murmur-driver-anthropic.gateway.api_key".to_string(),
8106                    variable: "ANTHROPIC_API_KEY".to_string(),
8107                },
8108                ReferencedEnvVariable {
8109                    field: "artifacts.web-search.gateway.api_key".to_string(),
8110                    variable: "TAVILY_API_KEY".to_string(),
8111                },
8112            ]
8113        );
8114        assert!(referenced
8115            .iter()
8116            .all(ReferencedEnvVariable::is_gateway_credential));
8117    }
8118
8119    #[test]
8120    fn parses_inline_system_prompt() {
8121        let manifest = RuntimeManifest::from_yaml_str(
8122            r#"
8123name: cap
8124version: 0.0.1
8125artifacts:
8126  - name: murmur-driver-anthropic
8127    version: 0.1.0
8128    runtime: driver
8129    gateway:
8130      endpoint: http://127.0.0.1:8080
8131      keyless: true
8132inference:
8133  model: test-model
8134  system_prompt: "Always begin with CONFIRMED:"
8135  driver:
8136    artifact: murmur-driver-anthropic
8137"#,
8138        )
8139        .unwrap();
8140
8141        let inference = manifest.inference.unwrap();
8142        assert_eq!(
8143            inference.system_prompt,
8144            Some("Always begin with CONFIRMED:".to_string())
8145        );
8146        assert!(inference.system_prompt_file.is_none());
8147    }
8148
8149    #[test]
8150    fn parses_compaction_system_prompt() {
8151        let manifest = RuntimeManifest::from_yaml_str(
8152            r#"
8153name: cap
8154version: 0.0.1
8155artifacts:
8156  - name: murmur-driver-anthropic
8157    version: 0.1.0
8158    runtime: driver
8159    gateway:
8160      endpoint: http://127.0.0.1:8080
8161      keyless: true
8162inference:
8163  model: test-model
8164  compaction:
8165    model: compaction-model
8166    system_prompt: "task = X, currently editing Y, already tried Z."
8167  driver:
8168    artifact: murmur-driver-anthropic
8169"#,
8170        )
8171        .unwrap();
8172
8173        let compaction = manifest.inference.unwrap().compaction.unwrap();
8174        assert_eq!(
8175            compaction.system_prompt,
8176            Some("task = X, currently editing Y, already tried Z.".to_string())
8177        );
8178        assert_eq!(compaction.model, Some("compaction-model".to_string()));
8179    }
8180
8181    /// The two override fields resolve independently: `model` set, `system_prompt`
8182    /// absent leaves the latter `None` with no default string substituted.
8183    #[test]
8184    fn compaction_system_prompt_absent_stays_none() {
8185        let manifest = RuntimeManifest::from_yaml_str(
8186            r#"
8187name: cap
8188version: 0.0.1
8189artifacts:
8190  - name: murmur-driver-anthropic
8191    version: 0.1.0
8192    runtime: driver
8193    gateway:
8194      endpoint: http://127.0.0.1:8080
8195      keyless: true
8196inference:
8197  model: test-model
8198  compaction:
8199    model: compaction-model
8200  driver:
8201    artifact: murmur-driver-anthropic
8202"#,
8203        )
8204        .unwrap();
8205
8206        let compaction = manifest.inference.unwrap().compaction.unwrap();
8207        assert!(compaction.system_prompt.is_none());
8208        assert!(compaction.system_prompt_file.is_none());
8209        assert_eq!(compaction.model, Some("compaction-model".to_string()));
8210    }
8211
8212    /// `system_prompt_file` parses as the second, independent prompt source: the path is
8213    /// carried through verbatim (resolution against the manifest dir happens in the
8214    /// runtime), the inline field stays `None`, and `model` is unaffected.
8215    #[test]
8216    fn parses_compaction_system_prompt_file() {
8217        let manifest = RuntimeManifest::from_yaml_str(
8218            r#"
8219name: cap
8220version: 0.0.1
8221artifacts:
8222  - name: murmur-driver-anthropic
8223    version: 0.1.0
8224    runtime: driver
8225    gateway:
8226      endpoint: http://127.0.0.1:8080
8227      keyless: true
8228inference:
8229  model: test-model
8230  compaction:
8231    model: compaction-model
8232    system_prompt_file: "compaction-instructions.md"
8233  driver:
8234    artifact: murmur-driver-anthropic
8235"#,
8236        )
8237        .unwrap();
8238
8239        let compaction = manifest.inference.unwrap().compaction.unwrap();
8240        assert_eq!(
8241            compaction.system_prompt_file,
8242            Some("compaction-instructions.md".to_string())
8243        );
8244        assert!(compaction.system_prompt.is_none());
8245        assert_eq!(compaction.model, Some("compaction-model".to_string()));
8246    }
8247
8248    /// `system_prompt_file` alone, with no `model`, still parses — the two fields do not
8249    /// depend on each other in either direction.
8250    #[test]
8251    fn compaction_system_prompt_file_independent_of_model() {
8252        let manifest = RuntimeManifest::from_yaml_str(
8253            r#"
8254name: cap
8255version: 0.0.1
8256artifacts:
8257  - name: murmur-driver-anthropic
8258    version: 0.1.0
8259    runtime: driver
8260    gateway:
8261      endpoint: http://127.0.0.1:8080
8262      keyless: true
8263inference:
8264  model: test-model
8265  compaction:
8266    threshold: 0.5
8267    system_prompt_file: "compaction-instructions.md"
8268  driver:
8269    artifact: murmur-driver-anthropic
8270"#,
8271        )
8272        .unwrap();
8273
8274        let compaction = manifest.inference.unwrap().compaction.unwrap();
8275        assert_eq!(
8276            compaction.system_prompt_file,
8277            Some("compaction-instructions.md".to_string())
8278        );
8279        assert!(compaction.model.is_none());
8280        assert_eq!(compaction.threshold, Some(0.5));
8281    }
8282
8283    /// `dump_summaries` is a plain optional boolean: present-true, present-false and absent
8284    /// are three distinguishable states, so the runtime can tell "explicitly off" from
8285    /// "never configured" even though both resolve to the same behavior.
8286    #[test]
8287    fn parses_compaction_dump_summaries() {
8288        let parse = |line: &str| {
8289            RuntimeManifest::from_yaml_str(&format!(
8290                r#"
8291name: cap
8292version: 0.0.1
8293artifacts:
8294  - name: murmur-driver-anthropic
8295    version: 0.1.0
8296    runtime: driver
8297    gateway:
8298      endpoint: http://127.0.0.1:8080
8299      keyless: true
8300inference:
8301  model: test-model
8302  compaction:
8303    model: compaction-model
8304{line}  driver:
8305    artifact: murmur-driver-anthropic
8306"#
8307            ))
8308            .unwrap()
8309            .inference
8310            .unwrap()
8311            .compaction
8312            .unwrap()
8313        };
8314
8315        assert_eq!(
8316            parse("    dump_summaries: true\n").dump_summaries,
8317            Some(true)
8318        );
8319        assert_eq!(
8320            parse("    dump_summaries: false\n").dump_summaries,
8321            Some(false)
8322        );
8323        assert_eq!(parse("").dump_summaries, None);
8324    }
8325
8326    /// `dump_summaries` is orthogonal to every other compaction field: it neither trips the
8327    /// prompt-source exclusivity check nor the threshold range check, and does not disturb
8328    /// the values those fields parse to.
8329    #[test]
8330    fn compaction_dump_summaries_does_not_interact_with_other_fields() {
8331        let compaction = RuntimeManifest::from_yaml_str(
8332            r#"
8333name: cap
8334version: 0.0.1
8335artifacts:
8336  - name: murmur-driver-anthropic
8337    version: 0.1.0
8338    runtime: driver
8339    gateway:
8340      endpoint: http://127.0.0.1:8080
8341      keyless: true
8342inference:
8343  model: test-model
8344  compaction:
8345    threshold: 0.5
8346    model: compaction-model
8347    system_prompt_file: "compaction-instructions.md"
8348    dump_summaries: true
8349  driver:
8350    artifact: murmur-driver-anthropic
8351"#,
8352        )
8353        .unwrap()
8354        .inference
8355        .unwrap()
8356        .compaction
8357        .unwrap();
8358
8359        assert_eq!(compaction.dump_summaries, Some(true));
8360        assert_eq!(compaction.threshold, Some(0.5));
8361        assert_eq!(compaction.model, Some("compaction-model".to_string()));
8362        assert_eq!(
8363            compaction.system_prompt_file,
8364            Some("compaction-instructions.md".to_string())
8365        );
8366        assert!(compaction.system_prompt.is_none());
8367
8368        // Still mutually exclusive with dump_summaries in the mix...
8369        let err = RuntimeManifest::from_yaml_str(
8370            r#"
8371name: cap
8372version: 0.0.1
8373artifacts:
8374  - name: murmur-driver-anthropic
8375    version: 0.1.0
8376    runtime: driver
8377    gateway:
8378      endpoint: http://127.0.0.1:8080
8379      keyless: true
8380inference:
8381  model: test-model
8382  compaction:
8383    dump_summaries: true
8384    system_prompt: "inline"
8385    system_prompt_file: "compaction-instructions.md"
8386  driver:
8387    artifact: murmur-driver-anthropic
8388"#,
8389        )
8390        .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
8391        assert!(matches!(
8392            err,
8393            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
8394                if field == "inference.compaction.system_prompt"
8395        ));
8396
8397        // ...and the threshold range is still enforced.
8398        let err = RuntimeManifest::from_yaml_str(
8399            r#"
8400name: cap
8401version: 0.0.1
8402artifacts:
8403  - name: murmur-driver-anthropic
8404    version: 0.1.0
8405    runtime: driver
8406    gateway:
8407      endpoint: http://127.0.0.1:8080
8408      keyless: true
8409inference:
8410  model: test-model
8411  compaction:
8412    dump_summaries: true
8413    threshold: 1.5
8414  driver:
8415    artifact: murmur-driver-anthropic
8416"#,
8417        )
8418        .expect_err("dump_summaries must not suppress the threshold range check");
8419        assert!(matches!(
8420            err,
8421            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
8422                if field == "inference.compaction.threshold"
8423        ));
8424    }
8425
8426    /// Setting both compaction prompt sources is a parse error naming the compaction
8427    /// fields — distinguishable from the top-level three-way `inference.system_prompt`
8428    /// exclusivity error, and with neither value silently preferred.
8429    #[test]
8430    fn compaction_prompt_sources_are_mutually_exclusive() {
8431        let err = RuntimeManifest::from_yaml_str(
8432            r#"
8433name: cap
8434version: 0.0.1
8435artifacts:
8436  - name: murmur-driver-anthropic
8437    version: 0.1.0
8438    runtime: driver
8439    gateway:
8440      endpoint: http://127.0.0.1:8080
8441      keyless: true
8442inference:
8443  model: test-model
8444  compaction:
8445    system_prompt: "inline"
8446    system_prompt_file: "compaction-instructions.md"
8447  driver:
8448    artifact: murmur-driver-anthropic
8449"#,
8450        )
8451        .expect_err("both compaction prompt sources set must fail");
8452
8453        match err {
8454            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
8455                assert_eq!(field, "inference.compaction.system_prompt");
8456                assert_eq!(
8457                    message,
8458                    "at most one of inference.compaction.system_prompt, \
8459                     inference.compaction.system_prompt_file may be set"
8460                );
8461            }
8462            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
8463        }
8464    }
8465
8466    /// The compaction-level exclusivity check is separate from the top-level one: an
8467    /// inline top-level `system_prompt` alongside a compaction `system_prompt_file` is a
8468    /// perfectly legal manifest.
8469    #[test]
8470    fn top_level_and_compaction_prompt_sources_do_not_collide() {
8471        let manifest = RuntimeManifest::from_yaml_str(
8472            r#"
8473name: cap
8474version: 0.0.1
8475artifacts:
8476  - name: murmur-driver-anthropic
8477    version: 0.1.0
8478    runtime: driver
8479    gateway:
8480      endpoint: http://127.0.0.1:8080
8481      keyless: true
8482inference:
8483  model: test-model
8484  system_prompt: "be helpful"
8485  compaction:
8486    system_prompt_file: "compaction-instructions.md"
8487  driver:
8488    artifact: murmur-driver-anthropic
8489"#,
8490        )
8491        .unwrap();
8492
8493        let inference = manifest.inference.unwrap();
8494        assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
8495        assert_eq!(
8496            inference.compaction.unwrap().system_prompt_file,
8497            Some("compaction-instructions.md".to_string())
8498        );
8499    }
8500
8501    #[test]
8502    fn parses_system_prompt_file() {
8503        let manifest = RuntimeManifest::from_yaml_str(
8504            r#"
8505name: cap
8506version: 0.0.1
8507artifacts:
8508  - name: murmur-driver-anthropic
8509    version: 0.1.0
8510    runtime: driver
8511    gateway:
8512      endpoint: http://127.0.0.1:8080
8513      keyless: true
8514inference:
8515  model: test-model
8516  system_prompt_file: "conventions.md"
8517  driver:
8518    artifact: murmur-driver-anthropic
8519"#,
8520        )
8521        .unwrap();
8522
8523        let inference = manifest.inference.unwrap();
8524        assert_eq!(
8525            inference.system_prompt_file,
8526            Some("conventions.md".to_string())
8527        );
8528        assert!(inference.system_prompt.is_none());
8529    }
8530
8531    #[test]
8532    fn rejects_both_inline_and_file_system_prompt() {
8533        let err = RuntimeManifest::from_yaml_str(
8534            r#"
8535name: cap
8536version: 0.0.1
8537artifacts:
8538  - name: murmur-driver-anthropic
8539    version: 0.1.0
8540    runtime: driver
8541    gateway:
8542      endpoint: http://127.0.0.1:8080
8543      keyless: true
8544inference:
8545  model: test-model
8546  system_prompt: "inline"
8547  system_prompt_file: "conventions.md"
8548  driver:
8549    artifact: murmur-driver-anthropic
8550"#,
8551        )
8552        .unwrap_err();
8553
8554        let msg = err.to_string();
8555        assert!(msg.contains("inference.system_prompt"));
8556        assert!(msg.contains("system_prompt_file"));
8557    }
8558
8559    /// A `${NAME}` is kept as the name it is, whether or not this process holds the variable: the
8560    /// parse never reads the environment.
8561    #[test]
8562    fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
8563        let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
8564        let yaml = format!(
8565            "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"
8566        );
8567
8568        assert_eq!(
8569            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
8570                .gateway
8571                .clone()
8572                .unwrap()
8573                .api_key,
8574            Some(ApiKeyReference::Environment(key.to_string()))
8575        );
8576        assert_eq!(
8577            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
8578                .unwrap()
8579                .artifacts[0]
8580                .gateway
8581                .clone()
8582                .unwrap()
8583                .api_key,
8584            None
8585        );
8586    }
8587
8588    #[test]
8589    fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
8590        let literal = ["sk-", "ant-", "debug"].concat();
8591        let debug = format!(
8592            "{:?} {:?}",
8593            ApiKeyReference::Environment("SOME_KEY".to_string()),
8594            ApiKeyReference::Literal(literal.clone())
8595        );
8596        assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
8597        assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
8598        assert!(!debug.contains(&literal), "{debug}");
8599    }
8600
8601    #[test]
8602    fn parse_without_secrets_drops_a_literal_api_key() {
8603        // Key assembled at runtime so the source never contains a
8604        // credential-shaped literal that secret scanners could flag.
8605        let literal = ["sk-", "ant-", "literal"].concat();
8606        let yaml = format!(
8607            "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"
8608        );
8609
8610        assert_eq!(
8611            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
8612                .gateway
8613                .clone()
8614                .unwrap()
8615                .api_key,
8616            Some(ApiKeyReference::Literal(literal))
8617        );
8618        assert_eq!(
8619            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
8620                .unwrap()
8621                .artifacts[0]
8622                .gateway
8623                .clone()
8624                .unwrap()
8625                .api_key,
8626            None
8627        );
8628    }
8629
8630    /// The non-resolving parse is narrower only in what it resolves, never in what it validates:
8631    /// a manifest roost accepts is a manifest `mur run` would also accept.
8632    #[test]
8633    fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
8634        let process_with_key = format!(
8635            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  model: test-model\n  api_key: {}\n",
8636            ["sk-", "ant-", "secret"].concat()
8637        );
8638        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";
8639
8640        for yaml in [process_with_key.as_str(), http_without_model] {
8641            let resolving = RuntimeManifest::from_yaml_str(yaml)
8642                .unwrap_err()
8643                .to_string();
8644            let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
8645                .unwrap_err()
8646                .to_string();
8647            assert_eq!(resolving, without_secrets);
8648        }
8649    }
8650
8651    /// The reference scan answers from the manifest text alone: it reports a reference, ignores
8652    /// a literal, and has nothing to say about a manifest that carries no `inference` block or
8653    /// does not parse at all.
8654    #[test]
8655    fn referenced_env_variables_reports_a_reference_and_nothing_else() {
8656        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";
8657        let referenced = referenced_env_variables(referencing);
8658        assert_eq!(referenced.len(), 1);
8659        assert_eq!(
8660            referenced[0].field,
8661            "artifacts.murmur-driver-anthropic.gateway.api_key"
8662        );
8663        assert!(referenced[0].is_gateway_credential());
8664        assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
8665
8666        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";
8667        assert!(referenced_env_variables(literal).is_empty());
8668
8669        let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
8670        assert!(referenced_env_variables(no_inference).is_empty());
8671
8672        let unparseable = "name: broken\nversion: [\n";
8673        assert!(referenced_env_variables(unparseable).is_empty());
8674    }
8675
8676    #[test]
8677    fn inference_requires_driver_artifact() {
8678        let err = RuntimeManifest::from_yaml_str(
8679            r#"
8680name: cap
8681version: 0.0.1
8682artifacts: []
8683inference:
8684  transport: http
8685  endpoint: http://127.0.0.1:8080
8686  model: test-model
8687"#,
8688        )
8689        .unwrap_err();
8690
8691        assert!(err.to_string().contains("inference.driver"));
8692    }
8693
8694    #[test]
8695    fn test_runtime_driver_deserializes() {
8696        let manifest = RuntimeManifest::from_yaml_str(
8697            r#"
8698name: cap
8699version: 0.0.1
8700artifacts:
8701  - name: my-provider
8702    version: 1.0.0
8703    runtime: driver
8704"#,
8705        )
8706        .unwrap();
8707
8708        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
8709    }
8710
8711    #[test]
8712    fn test_runtime_unknown_is_error() {
8713        let err = RuntimeManifest::from_yaml_str(
8714            r#"
8715name: cap
8716version: 0.0.1
8717artifacts:
8718  - name: foo
8719    version: 1.0.0
8720    runtime: unknown_type
8721"#,
8722        )
8723        .unwrap_err();
8724
8725        let msg = err.to_string();
8726        assert!(msg.contains("runtime"), "error should mention 'runtime'");
8727        assert!(
8728            msg.contains("unknown_type"),
8729            "error should identify the bad value"
8730        );
8731    }
8732
8733    #[test]
8734    fn test_runtime_all_variants_roundtrip() {
8735        for variant in [
8736            ArtifactRuntime::Tool,
8737            ArtifactRuntime::Driver,
8738            ArtifactRuntime::Hook,
8739            ArtifactRuntime::Skill,
8740        ] {
8741            let json = serde_json::to_string(&variant).unwrap();
8742            let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
8743            assert_eq!(variant, back);
8744        }
8745    }
8746
8747    #[test]
8748    fn skill_source_without_version_defaults_to_local() {
8749        let manifest = RuntimeManifest::from_yaml_str(
8750            r#"
8751name: cap
8752version: 0.0.1
8753artifacts:
8754  - name: my-skill
8755    source: ./skills/my-skill/skill.md
8756    runtime: skill
8757"#,
8758        )
8759        .unwrap();
8760
8761        assert_eq!(manifest.artifacts.len(), 1);
8762        let artifact = &manifest.artifacts[0];
8763        assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
8764        assert_eq!(
8765            artifact.source.as_deref(),
8766            Some("./skills/my-skill/skill.md")
8767        );
8768        assert_eq!(artifact.version, "local");
8769    }
8770
8771    #[test]
8772    fn skill_source_directory_path_is_accepted() {
8773        let manifest = RuntimeManifest::from_yaml_str(
8774            r#"
8775name: cap
8776version: 0.0.1
8777artifacts:
8778  - name: my-skill
8779    source: ./skills/my-skill/
8780    runtime: skill
8781"#,
8782        )
8783        .unwrap();
8784
8785        assert_eq!(
8786            manifest.artifacts[0].source.as_deref(),
8787            Some("./skills/my-skill/")
8788        );
8789        assert_eq!(manifest.artifacts[0].version, "local");
8790    }
8791
8792    #[test]
8793    fn skill_source_with_explicit_version_substitutes_local() {
8794        // Both set is allowed; version is ignored and "local" is substituted.
8795        let manifest = RuntimeManifest::from_yaml_str(
8796            r#"
8797name: cap
8798version: 0.0.1
8799artifacts:
8800  - name: my-skill
8801    source: ./skills/my-skill/skill.md
8802    version: 9.9.9
8803    runtime: skill
8804"#,
8805        )
8806        .unwrap();
8807
8808        assert_eq!(manifest.artifacts[0].version, "local");
8809    }
8810
8811    #[test]
8812    fn local_source_true_allows_source_on_non_skill_runtime() {
8813        for runtime in ["tool", "driver", "hook"] {
8814            let yaml = format!(
8815                r#"
8816name: cap
8817version: 0.0.1
8818artifacts:
8819  - name: my-thing
8820    source: ./local/path
8821    local_source: true
8822    runtime: {runtime}
8823"#
8824            );
8825            let manifest = RuntimeManifest::from_yaml_str(&yaml)
8826                .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
8827            let artifact = &manifest.artifacts[0];
8828            assert!(artifact.local_source, "runtime {runtime}");
8829            assert_eq!(artifact.source.as_deref(), Some("./local/path"));
8830            assert_eq!(artifact.version, "local", "runtime {runtime}");
8831        }
8832    }
8833
8834    #[test]
8835    fn local_source_false_on_skill_with_source_is_error() {
8836        // The gate reads the declared field, not the role: an explicit `local_source: false`
8837        // overrides the skill default and rejects `source:`.
8838        let err = RuntimeManifest::from_yaml_str(
8839            r#"
8840name: cap
8841version: 0.0.1
8842artifacts:
8843  - name: my-skill
8844    source: ./skills/my-skill
8845    local_source: false
8846    runtime: skill
8847"#,
8848        )
8849        .unwrap_err();
8850        let msg = err.to_string();
8851        assert!(msg.contains("'local_source: true'"), "error was: {msg}");
8852        assert!(msg.contains("my-skill"), "error was: {msg}");
8853    }
8854
8855    #[test]
8856    fn local_source_defaults_from_role_when_absent() {
8857        let manifest = RuntimeManifest::from_yaml_str(
8858            r#"
8859name: cap
8860version: 0.0.1
8861artifacts:
8862  - name: my-skill
8863    source: ./skills/my-skill
8864    runtime: skill
8865  - name: my-tool
8866    version: 1.0.0
8867    runtime: tool
8868"#,
8869        )
8870        .unwrap();
8871        assert!(manifest.artifacts[0].local_source, "skill defaults to true");
8872        assert!(
8873            !manifest.artifacts[1].local_source,
8874            "tool defaults to false"
8875        );
8876    }
8877
8878    #[test]
8879    fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
8880        let manifest = RuntimeManifest::from_yaml_str(
8881            r#"
8882name: cap
8883version: 0.0.1
8884artifacts:
8885  - name: my-tool
8886    version: 1.0.0
8887    runtime: tool
8888    prompt_payload: true
8889  - name: murmur-driver-anthropic
8890    version: 0.1.0
8891    runtime: driver
8892    gateway:
8893      endpoint: http://127.0.0.1:8080
8894      keyless: true
8895inference:
8896  model: test-model
8897  system_prompt_artifact: my-tool
8898  driver:
8899    artifact: murmur-driver-anthropic
8900"#,
8901        )
8902        .unwrap();
8903        assert!(manifest.artifacts[0].prompt_payload);
8904    }
8905
8906    #[test]
8907    fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
8908        let err = RuntimeManifest::from_yaml_str(
8909            r#"
8910name: cap
8911version: 0.0.1
8912artifacts:
8913  - name: my-skill
8914    version: 1.0.0
8915    runtime: skill
8916    prompt_payload: false
8917  - name: murmur-driver-anthropic
8918    version: 0.1.0
8919    runtime: driver
8920    gateway:
8921      endpoint: http://127.0.0.1:8080
8922      keyless: true
8923inference:
8924  model: test-model
8925  system_prompt_artifact: my-skill
8926  driver:
8927    artifact: murmur-driver-anthropic
8928"#,
8929        )
8930        .unwrap_err();
8931        let msg = err.to_string();
8932        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
8933    }
8934
8935    #[test]
8936    fn prompt_payload_defaults_from_role_when_absent() {
8937        let manifest = RuntimeManifest::from_yaml_str(
8938            r#"
8939name: cap
8940version: 0.0.1
8941artifacts:
8942  - name: my-skill
8943    version: 1.0.0
8944    runtime: skill
8945  - name: my-tool
8946    version: 1.0.0
8947    runtime: tool
8948"#,
8949        )
8950        .unwrap();
8951        assert!(
8952            manifest.artifacts[0].prompt_payload,
8953            "skill defaults to true"
8954        );
8955        assert!(
8956            !manifest.artifacts[1].prompt_payload,
8957            "tool defaults to false"
8958        );
8959    }
8960
8961    #[test]
8962    fn source_on_non_skill_runtime_is_error() {
8963        for runtime in ["tool", "driver", "hook"] {
8964            let yaml = format!(
8965                r#"
8966name: cap
8967version: 0.0.1
8968artifacts:
8969  - name: my-thing
8970    source: ./local/path
8971    runtime: {runtime}
8972"#
8973            );
8974            let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
8975            let msg = err.to_string();
8976            assert!(
8977                msg.contains("'local_source: true'"),
8978                "runtime {runtime}: error was: {msg}"
8979            );
8980            assert!(
8981                msg.contains("my-thing"),
8982                "runtime {runtime}: error was: {msg}"
8983            );
8984        }
8985    }
8986
8987    #[test]
8988    fn registry_skill_still_requires_version() {
8989        let err = RuntimeManifest::from_yaml_str(
8990            r#"
8991name: cap
8992version: 0.0.1
8993artifacts:
8994  - name: my-skill
8995    runtime: skill
8996"#,
8997        )
8998        .unwrap_err();
8999
9000        let msg = err.to_string();
9001        assert!(
9002            msg.contains("missing required field 'version'"),
9003            "error was: {msg}"
9004        );
9005    }
9006
9007    #[test]
9008    fn rejects_unknown_inference_transport() {
9009        let err = RuntimeManifest::from_yaml_str(
9010            r#"
9011name: cap
9012version: 0.0.1
9013artifacts: []
9014inference:
9015  transport: grpc
9016  model: test-model
9017  driver:
9018    artifact: murmur-driver-anthropic
9019"#,
9020        )
9021        .unwrap_err();
9022
9023        let msg = err.to_string();
9024        assert!(msg.contains("inference.transport"), "error was: {msg}");
9025        assert!(msg.contains("unknown value"), "error was: {msg}");
9026        assert!(msg.contains("grpc"), "error was: {msg}");
9027    }
9028
9029    /// The driver is what knows a harness, so `command:` alone selects nothing: a manifest that
9030    /// names only a command is refused and told which field to add.
9031    #[test]
9032    fn process_transport_with_command_and_no_driver_is_refused() {
9033        let err = RuntimeManifest::from_yaml_str(
9034            r#"
9035name: cap
9036version: 0.0.1
9037artifacts: []
9038inference:
9039  transport: process
9040  command: some-cli
9041  model: some-model
9042  max_turns: 20
9043"#,
9044        )
9045        .unwrap_err();
9046
9047        let msg = err.to_string();
9048        assert!(
9049            msg.contains("inference.driver.artifact"),
9050            "error was: {msg}"
9051        );
9052        assert!(msg.contains("inference.command"), "error was: {msg}");
9053        assert!(
9054            msg.contains("murmur-driver-claude-code"),
9055            "error was: {msg}"
9056        );
9057    }
9058
9059    #[test]
9060    fn process_transport_with_neither_driver_nor_command_is_refused() {
9061        let err = RuntimeManifest::from_yaml_str(
9062            r#"
9063name: cap
9064version: 0.0.1
9065artifacts: []
9066inference:
9067  transport: process
9068  model: some-model
9069"#,
9070        )
9071        .unwrap_err();
9072
9073        let msg = err.to_string();
9074        assert!(
9075            msg.contains("inference.driver.artifact"),
9076            "error was: {msg}"
9077        );
9078        assert!(
9079            msg.contains("murmur-driver-claude-code"),
9080            "error was: {msg}"
9081        );
9082    }
9083
9084    #[test]
9085    fn process_transport_allows_absent_model() {
9086        // model is optional for transport: process — an absent model leaves the choice to the
9087        // harness, which has an account default of its own.
9088        let manifest =
9089            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
9090                .expect("process transport should parse without a model");
9091        let inference = manifest.inference.expect("inference present");
9092        assert_eq!(inference.transport, "process");
9093        assert!(inference.command.is_none());
9094        assert_eq!(
9095            inference.model, "",
9096            "absent model resolves to empty (harness default)"
9097        );
9098    }
9099
9100    fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
9101        format!(
9102            "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n  transport: process\n  \
9103             driver:\n    artifact: fixture-process-driver\n{inference_extra}"
9104        )
9105    }
9106
9107    const PROCESS_DRIVER_ENTRY: &str =
9108        "  - name: fixture-process-driver\n    version: 0.1.0\n    runtime: driver";
9109
9110    #[test]
9111    fn process_transport_accepts_driver() {
9112        let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9113            PROCESS_DRIVER_ENTRY,
9114            "    config:\n      profile: work\n  command: /usr/local/bin/fixture-cli\n",
9115        ))
9116        .unwrap();
9117
9118        let inference = manifest.inference.expect("inference should exist");
9119        assert_eq!(inference.transport, "process");
9120        let driver = inference.driver.expect("driver should be parsed");
9121        assert_eq!(driver.artifact, "fixture-process-driver");
9122        assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
9123        assert_eq!(
9124            inference.command.as_deref(),
9125            Some("/usr/local/bin/fixture-cli")
9126        );
9127    }
9128
9129    #[test]
9130    fn process_transport_accepts_driver_without_command() {
9131        let manifest =
9132            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
9133                .unwrap();
9134
9135        let inference = manifest.inference.expect("inference should exist");
9136        assert_eq!(
9137            inference.driver.map(|d| d.artifact).as_deref(),
9138            Some("fixture-process-driver")
9139        );
9140        assert!(inference.command.is_none());
9141        assert_eq!(inference.model, "");
9142    }
9143
9144    #[test]
9145    fn process_driver_entry_must_be_declared() {
9146        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9147            "  - name: other\n    version: 0.1.0\n    runtime: tool",
9148            "",
9149        ))
9150        .unwrap_err()
9151        .to_string();
9152        assert!(msg.contains("inference.driver.artifact"), "{msg}");
9153        assert!(
9154            msg.contains("artifact 'fixture-process-driver' is not declared in artifacts:"),
9155            "{msg}"
9156        );
9157    }
9158
9159    #[test]
9160    fn process_driver_entry_must_be_runtime_driver() {
9161        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9162            "  - name: fixture-process-driver\n    version: 0.1.0\n    runtime: tool",
9163            "",
9164        ))
9165        .unwrap_err()
9166        .to_string();
9167        assert!(
9168            msg.contains("must be declared with runtime: driver"),
9169            "{msg}"
9170        );
9171    }
9172
9173    #[test]
9174    fn process_driver_entry_refuses_capabilities() {
9175        let entry =
9176            format!("{PROCESS_DRIVER_ENTRY}\n    capabilities:\n      env:\n        allow: [HOME]");
9177        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
9178            .unwrap_err()
9179            .to_string();
9180        assert!(msg.contains("granted nothing"), "{msg}");
9181        assert!(msg.contains("inference.driver.config"), "{msg}");
9182    }
9183
9184    #[test]
9185    fn process_driver_entry_refuses_config() {
9186        let entry = format!("{PROCESS_DRIVER_ENTRY}\n    config:\n      profile: work");
9187        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
9188            .unwrap_err()
9189            .to_string();
9190        assert!(msg.contains("granted nothing"), "{msg}");
9191        assert!(msg.contains("remove capabilities:/config:"), "{msg}");
9192    }
9193
9194    #[test]
9195    fn process_transport_rejects_endpoint() {
9196        let err = RuntimeManifest::from_yaml_str(
9197            r#"
9198name: cap
9199version: 0.0.1
9200artifacts: []
9201inference:
9202  transport: process
9203  command: claude
9204  model: claude-haiku-4-5-20251001
9205  endpoint: http://localhost:8080
9206"#,
9207        )
9208        .unwrap_err();
9209
9210        let msg = err.to_string();
9211        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
9212        assert!(msg.contains("was removed"), "error was: {msg}");
9213    }
9214
9215    #[test]
9216    fn process_transport_rejects_api_key() {
9217        // Key assembled at runtime so the source never contains a
9218        // credential-shaped literal that secret scanners could flag.
9219        let key = ["sk-", "ant-", "secret"].concat();
9220        let err = RuntimeManifest::from_yaml_str(&format!(
9221            r#"
9222name: cap
9223version: 0.0.1
9224artifacts: []
9225inference:
9226  transport: process
9227  command: claude
9228  model: claude-haiku-4-5-20251001
9229  api_key: {key}
9230"#
9231        ))
9232        .unwrap_err();
9233
9234        let msg = err.to_string();
9235        assert!(msg.contains("inference.api_key"), "error was: {msg}");
9236        assert!(msg.contains("was removed"), "error was: {msg}");
9237        assert!(!msg.contains(&key), "error was: {msg}");
9238    }
9239
9240    #[test]
9241    fn process_transport_rejects_max_tokens() {
9242        // Rejected even at a perfectly valid value: the CLI subprocess path never builds a
9243        // driver payload, so accepting it would leave it silently inert.
9244        let err = RuntimeManifest::from_yaml_str(
9245            r#"
9246name: cap
9247version: 0.0.1
9248artifacts: []
9249inference:
9250  transport: process
9251  command: claude
9252  model: claude-haiku-4-5-20251001
9253  max_tokens: 4096
9254"#,
9255        )
9256        .unwrap_err();
9257
9258        let msg = err.to_string();
9259        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
9260        assert!(
9261            msg.contains("not valid with transport: process"),
9262            "error was: {msg}"
9263        );
9264    }
9265
9266    /// The harness's own reported counts are what this transport meters, so the ceiling parses
9267    /// here as it does under `http`. A driver that reports no counts is refused at load, not
9268    /// here: the manifest cannot see which driver it names.
9269    #[test]
9270    fn process_transport_carries_max_session_tokens() {
9271        let manifest = RuntimeManifest::from_yaml_str(
9272            r#"
9273name: cap
9274version: 0.0.1
9275artifacts:
9276  - name: murmur-driver-claude-code
9277    version: 0.1.0
9278    runtime: driver
9279inference:
9280  transport: process
9281  driver:
9282    artifact: murmur-driver-claude-code
9283  model: claude-haiku-4-5-20251001
9284  max_session_tokens: 200000
9285"#,
9286        )
9287        .unwrap();
9288
9289        assert_eq!(
9290            manifest.inference.unwrap().max_session_tokens,
9291            Some(200_000)
9292        );
9293    }
9294
9295    #[test]
9296    fn process_transport_rejects_a_zero_max_session_tokens() {
9297        let err = RuntimeManifest::from_yaml_str(
9298            r#"
9299name: cap
9300version: 0.0.1
9301artifacts:
9302  - name: murmur-driver-claude-code
9303    version: 0.1.0
9304    runtime: driver
9305inference:
9306  transport: process
9307  driver:
9308    artifact: murmur-driver-claude-code
9309  model: claude-haiku-4-5-20251001
9310  max_session_tokens: 0
9311"#,
9312        )
9313        .unwrap_err();
9314
9315        let msg = err.to_string();
9316        assert!(
9317            msg.contains("inference.max_session_tokens"),
9318            "error was: {msg}"
9319        );
9320        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
9321    }
9322
9323    #[test]
9324    fn inference_max_session_tokens_round_trips() {
9325        let manifest = RuntimeManifest::from_yaml_str(
9326            r#"
9327name: cap
9328version: 0.0.1
9329artifacts:
9330  - name: murmur-driver-anthropic
9331    version: 0.1.0
9332    runtime: driver
9333    gateway:
9334      endpoint: https://api.anthropic.com
9335      api_key: test-key
9336inference:
9337  transport: http
9338  model: claude-opus-4-5
9339  max_session_tokens: 5000000000
9340  driver:
9341    artifact: murmur-driver-anthropic
9342"#,
9343        )
9344        .unwrap();
9345
9346        assert_eq!(
9347            manifest.inference.unwrap().max_session_tokens,
9348            Some(5_000_000_000)
9349        );
9350    }
9351
9352    #[test]
9353    fn inference_max_session_tokens_absent_is_none() {
9354        let manifest = RuntimeManifest::from_yaml_str(
9355            r#"
9356name: cap
9357version: 0.0.1
9358artifacts:
9359  - name: murmur-driver-anthropic
9360    version: 0.1.0
9361    runtime: driver
9362    gateway:
9363      endpoint: https://api.anthropic.com
9364      api_key: test-key
9365inference:
9366  transport: http
9367  model: claude-opus-4-5
9368  driver:
9369    artifact: murmur-driver-anthropic
9370"#,
9371        )
9372        .unwrap();
9373
9374        assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
9375    }
9376
9377    #[test]
9378    fn inference_max_session_tokens_zero_is_rejected() {
9379        let err = RuntimeManifest::from_yaml_str(
9380            r#"
9381name: cap
9382version: 0.0.1
9383artifacts:
9384  - name: murmur-driver-anthropic
9385    version: 0.1.0
9386    runtime: driver
9387    gateway:
9388      endpoint: https://api.anthropic.com
9389      api_key: test-key
9390inference:
9391  transport: http
9392  model: claude-opus-4-5
9393  max_session_tokens: 0
9394  driver:
9395    artifact: murmur-driver-anthropic
9396"#,
9397        )
9398        .unwrap_err();
9399
9400        let msg = err.to_string();
9401        assert!(
9402            msg.contains("inference.max_session_tokens"),
9403            "error was: {msg}"
9404        );
9405        assert!(msg.contains("greater than 0"), "error was: {msg}");
9406    }
9407
9408    /// An http capsule manifest with `extra` appended to its `inference:` block.
9409    fn http_manifest_with_inference(extra: &str) -> String {
9410        format!(
9411            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur-driver-anthropic\n    \
9412             version: 0.1.0\n    runtime: driver\n    gateway:\n      \
9413             endpoint: https://api.anthropic.com\n      api_key: test-key\n\
9414             inference:\n  transport: http\n  model: claude-opus-4-5\n  driver:\n    \
9415             artifact: murmur-driver-anthropic\n{extra}"
9416        )
9417    }
9418
9419    #[test]
9420    fn inference_tool_refresh_absent_is_compaction() {
9421        let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
9422        assert_eq!(
9423            manifest.inference.unwrap().tool_refresh,
9424            ToolRefresh::Compaction
9425        );
9426        assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
9427    }
9428
9429    #[test]
9430    fn inference_tool_refresh_parses_both_values() {
9431        for (value, expected) in [
9432            ("compaction", ToolRefresh::Compaction),
9433            ("immediate", ToolRefresh::Immediate),
9434        ] {
9435            let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
9436                "  tool_refresh: {value}\n"
9437            )))
9438            .unwrap();
9439            assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
9440            assert_eq!(expected.wire_name(), value);
9441            assert!(
9442                manifest.unknown_keys.is_empty(),
9443                "tool_refresh is a known key: {:?}",
9444                manifest.unknown_keys
9445            );
9446        }
9447    }
9448
9449    #[test]
9450    fn inference_tool_refresh_rejects_an_unknown_value() {
9451        let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
9452            "  tool_refresh: sometimes\n",
9453        ))
9454        .unwrap_err();
9455        match err {
9456            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9457                assert_eq!(field, "inference.tool_refresh");
9458                assert_eq!(
9459                    message,
9460                    "must be one of: compaction, immediate; got 'sometimes'"
9461                );
9462            }
9463            other => panic!("unexpected error: {other:?}"),
9464        }
9465    }
9466
9467    /// Refused whatever its value: the harness owns its tool list under `transport: process`,
9468    /// so the key would be silently inert there.
9469    #[test]
9470    fn process_transport_rejects_tool_refresh() {
9471        for value in ["compaction", "immediate"] {
9472            let err = RuntimeManifest::from_yaml_str(&format!(
9473                "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  \
9474                 command: claude\n  model: claude-haiku-4-5-20251001\n  tool_refresh: {value}\n"
9475            ))
9476            .unwrap_err();
9477            match err {
9478                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9479                    assert_eq!(field, "inference.tool_refresh");
9480                    assert_eq!(message, "is not valid with transport: process");
9481                }
9482                other => panic!("unexpected error for {value}: {other:?}"),
9483            }
9484        }
9485    }
9486
9487    #[test]
9488    fn inference_max_tokens_round_trips() {
9489        let manifest = RuntimeManifest::from_yaml_str(
9490            r#"
9491name: cap
9492version: 0.0.1
9493artifacts:
9494  - name: murmur-driver-anthropic
9495    version: 0.1.0
9496    runtime: driver
9497    gateway:
9498      endpoint: https://api.anthropic.com
9499      api_key: test-key
9500inference:
9501  transport: http
9502  model: claude-opus-4-5
9503  max_tokens: 4096
9504  driver:
9505    artifact: murmur-driver-anthropic
9506"#,
9507        )
9508        .unwrap();
9509
9510        let inference = manifest.inference.unwrap();
9511        assert_eq!(inference.max_tokens, Some(4096));
9512    }
9513
9514    #[test]
9515    fn inference_max_tokens_absent_is_none() {
9516        // Absent means "the manifest didn't set it" all the way down; the 8192 default is
9517        // applied once, by the runtime, not smuggled in here.
9518        let manifest = RuntimeManifest::from_yaml_str(
9519            r#"
9520name: cap
9521version: 0.0.1
9522artifacts:
9523  - name: murmur-driver-anthropic
9524    version: 0.1.0
9525    runtime: driver
9526    gateway:
9527      endpoint: https://api.anthropic.com
9528      api_key: test-key
9529inference:
9530  transport: http
9531  model: claude-opus-4-5
9532  driver:
9533    artifact: murmur-driver-anthropic
9534"#,
9535        )
9536        .unwrap();
9537
9538        assert_eq!(manifest.inference.unwrap().max_tokens, None);
9539    }
9540
9541    #[test]
9542    fn inference_max_tokens_zero_is_rejected() {
9543        let err = RuntimeManifest::from_yaml_str(
9544            r#"
9545name: cap
9546version: 0.0.1
9547artifacts:
9548  - name: murmur-driver-anthropic
9549    version: 0.1.0
9550    runtime: driver
9551    gateway:
9552      endpoint: https://api.anthropic.com
9553      api_key: test-key
9554inference:
9555  transport: http
9556  model: claude-opus-4-5
9557  max_tokens: 0
9558  driver:
9559    artifact: murmur-driver-anthropic
9560"#,
9561        )
9562        .unwrap_err();
9563
9564        let msg = err.to_string();
9565        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
9566        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
9567    }
9568
9569    #[test]
9570    fn inference_max_tokens_large_value_is_accepted() {
9571        // Manifest validation stays advisory: an over-large cap is the provider's to reject
9572        // at request time, not ours to clamp or block at load time.
9573        let manifest = RuntimeManifest::from_yaml_str(
9574            r#"
9575name: cap
9576version: 0.0.1
9577artifacts:
9578  - name: murmur-driver-anthropic
9579    version: 0.1.0
9580    runtime: driver
9581    gateway:
9582      endpoint: https://api.anthropic.com
9583      api_key: test-key
9584inference:
9585  transport: http
9586  model: claude-opus-4-5
9587  max_tokens: 999999
9588  driver:
9589    artifact: murmur-driver-anthropic
9590"#,
9591        )
9592        .unwrap();
9593
9594        assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
9595    }
9596
9597    #[test]
9598    fn inference_and_context_max_tokens_are_independent() {
9599        // Two unrelated concepts that happen to share a leaf name: per-turn output cap vs.
9600        // session compaction budget. Neither may leak into the other.
9601        let manifest = RuntimeManifest::from_yaml_str(
9602            r#"
9603name: cap
9604version: 0.0.1
9605artifacts:
9606  - name: murmur-driver-anthropic
9607    version: 0.1.0
9608    runtime: driver
9609    gateway:
9610      endpoint: https://api.anthropic.com
9611      api_key: test-key
9612inference:
9613  transport: http
9614  model: claude-opus-4-5
9615  max_tokens: 4096
9616  driver:
9617    artifact: murmur-driver-anthropic
9618context:
9619  max_tokens: 200000
9620"#,
9621        )
9622        .unwrap();
9623
9624        assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
9625        assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
9626    }
9627
9628    #[test]
9629    fn hook_config_defaults_when_fields_absent() {
9630        let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
9631        assert_eq!(config.binding, HookBinding::All);
9632        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
9633        assert_eq!(config.commit_policy, HookCommitPolicy::None);
9634    }
9635
9636    #[test]
9637    fn hook_config_parses_all_fields() {
9638        let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
9639        let config = parse_hook_config_from_yaml(yaml).unwrap();
9640        assert_eq!(config.binding, HookBinding::OnCompaction);
9641        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
9642        assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
9643    }
9644
9645    #[test]
9646    fn hook_config_parses_on_task_start() {
9647        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
9648        let config = parse_hook_config_from_yaml(yaml).unwrap();
9649        assert_eq!(config.binding, HookBinding::OnTaskStart);
9650    }
9651
9652    #[test]
9653    fn hook_config_parses_on_task_end() {
9654        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
9655        let config = parse_hook_config_from_yaml(yaml).unwrap();
9656        assert_eq!(config.binding, HookBinding::OnTaskEnd);
9657    }
9658
9659    #[test]
9660    fn hook_config_unknown_binding_lists_task_events() {
9661        let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
9662        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9663        assert!(err.contains("on-task-start"), "error was: {err}");
9664        assert!(err.contains("on-task-end"), "error was: {err}");
9665    }
9666
9667    #[test]
9668    fn hook_config_async_with_commit_is_rejected() {
9669        let yaml =
9670            "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
9671        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9672        assert!(err.contains("async-with-commit"), "error was: {err}");
9673    }
9674
9675    #[test]
9676    fn hook_config_on_stage_async_is_rejected() {
9677        let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
9678        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9679        assert!(err.contains("on-stage"), "error was: {err}");
9680        assert!(err.contains("blocking"), "error was: {err}");
9681    }
9682
9683    #[test]
9684    fn hook_config_async_none_is_valid() {
9685        let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
9686        let config = parse_hook_config_from_yaml(yaml).unwrap();
9687        assert_eq!(config.execution_mode, HookExecutionMode::Async);
9688        assert_eq!(config.commit_policy, HookCommitPolicy::None);
9689    }
9690
9691    #[test]
9692    fn hook_config_parses_reopen_task_commit_policy() {
9693        let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
9694        let config = parse_hook_config_from_yaml(yaml).unwrap();
9695        assert_eq!(config.binding, HookBinding::OnTaskEnd);
9696        assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
9697    }
9698
9699    #[test]
9700    fn hook_config_unknown_commit_policy_lists_reopen_task() {
9701        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
9702        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9703        assert!(err.contains("reopen-task"), "error was: {err}");
9704    }
9705
9706    #[test]
9707    fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
9708        for binding in ["on-shell", "on-tool-call"] {
9709            let yaml =
9710                format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
9711            let config = parse_hook_config_from_yaml(&yaml).unwrap();
9712            assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
9713        }
9714        assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
9715    }
9716
9717    /// `deny` is the one policy an omitted `binding:` cannot carry: a hook that does not name
9718    /// which of the two gated events it decides on would be asked at both.
9719    #[test]
9720    fn hook_config_deny_without_a_binding_is_rejected() {
9721        let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
9722        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9723        assert!(
9724            err.contains("requires an explicit binding"),
9725            "error was: {err}"
9726        );
9727        assert!(err.contains("on-shell"), "error was: {err}");
9728        assert!(err.contains("on-tool-call"), "error was: {err}");
9729    }
9730
9731    /// Every other binding refuses `deny` through the existing binding-mismatch check.
9732    #[test]
9733    fn hook_config_deny_on_an_ungated_binding_is_rejected() {
9734        let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
9735        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9736        assert!(err.contains("is not valid for binding"), "error was: {err}");
9737        assert!(err.contains("replace-context"), "error was: {err}");
9738    }
9739
9740    /// The unchanged async/commit check covers `deny` for free; this pins that it does.
9741    #[test]
9742    fn hook_config_async_deny_is_rejected() {
9743        let yaml =
9744            "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
9745        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9746        assert!(err.contains("async-with-commit"), "error was: {err}");
9747        assert!(err.contains("deny"), "error was: {err}");
9748    }
9749
9750    #[test]
9751    fn hook_config_unknown_commit_policy_lists_deny() {
9752        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
9753        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9754        assert!(err.contains("deny"), "error was: {err}");
9755    }
9756
9757    /// The existing, unmodified async/commit validation must also reject
9758    /// `commit_policy: reopen-task` combined with `execution_mode: async`.
9759    #[test]
9760    fn hook_config_async_reopen_task_is_rejected() {
9761        let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
9762        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9763        assert!(err.contains("async-with-commit"), "error was: {err}");
9764        assert!(err.contains("reopen-task"), "error was: {err}");
9765    }
9766
9767    // ── binding is the single source of truth for what a hook commits ─────────────
9768
9769    /// The two bindings whose events honor no `hook-output` arm at all cannot declare
9770    /// any non-`none` `commit_policy`. The error names the binding, the declared policy,
9771    /// and `none` as what the binding honors.
9772    #[test]
9773    fn hook_config_non_committing_binding_rejects_any_commit_policy() {
9774        for binding in ["on-session-start", "on-session-end"] {
9775            let yaml = format!(
9776                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
9777            );
9778            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
9779            assert!(err.contains(binding), "error was: {err}");
9780            assert!(err.contains("replace-context"), "error was: {err}");
9781            assert!(
9782                err.contains("honors commit_policy 'none'"),
9783                "error was: {err}"
9784            );
9785        }
9786    }
9787
9788    /// A binding that honors one arm rejects every *other* policy, naming the one it does
9789    /// honor so the fix is obvious from the message alone.
9790    #[test]
9791    fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
9792        for (binding, declared, honored) in [
9793            ("on-stage", "reopen-task", "write-manifests"),
9794            ("on-compaction", "write-manifests", "replace-context"),
9795            ("on-task-end", "replace-context", "reopen-task"),
9796            ("on-task-start", "replace-context", "seed-context"),
9797            ("on-shell", "replace-context", "deny"),
9798            ("on-tool-call", "replace-context", "deny"),
9799        ] {
9800            let yaml = format!(
9801                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
9802            );
9803            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
9804            assert!(err.contains(binding), "error was: {err}");
9805            assert!(
9806                err.contains(&format!("commit_policy '{declared}' is not valid")),
9807                "error was: {err}"
9808            );
9809            assert!(
9810                err.contains(&format!("honors commit_policy '{honored}'")),
9811                "error was: {err}"
9812            );
9813        }
9814    }
9815
9816    /// `on-inference` honors the `artifact` arm, which has no `commit_policy` spelling —
9817    /// so *no* non-`none` policy is ever valid for it, including `write-manifests`.
9818    #[test]
9819    fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
9820        for declared in ["replace-context", "write-manifests", "reopen-task"] {
9821            let yaml = format!(
9822                "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
9823            );
9824            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
9825            assert!(err.contains("on-inference"), "error was: {err}");
9826            assert!(err.contains(declared), "error was: {err}");
9827            assert!(
9828                err.contains("honors commit_policy 'none'"),
9829                "error was: {err}"
9830            );
9831            assert!(err.contains("artifact"), "error was: {err}");
9832        }
9833    }
9834
9835    /// A binding declaring exactly the policy it honors is accepted, for every
9836    /// representable pair.
9837    #[test]
9838    fn hook_config_matching_binding_and_commit_policy_is_valid() {
9839        for (binding, policy, expected) in [
9840            (
9841                "on-stage",
9842                "write-manifests",
9843                HookCommitPolicy::WriteManifests,
9844            ),
9845            (
9846                "on-compaction",
9847                "replace-context",
9848                HookCommitPolicy::ReplaceContext,
9849            ),
9850            ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
9851            ("on-shell", "deny", HookCommitPolicy::Deny),
9852            ("on-tool-call", "deny", HookCommitPolicy::Deny),
9853        ] {
9854            let yaml =
9855                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
9856            let config = parse_hook_config_from_yaml(&yaml).unwrap();
9857            assert_eq!(config.commit_policy, expected);
9858        }
9859    }
9860
9861    /// Every binding may declare `commit_policy: none` — including the ones that honor
9862    /// an arm, which simply means the hook opts out of committing.
9863    #[test]
9864    fn hook_config_commit_policy_none_is_valid_for_every_binding() {
9865        for binding in [
9866            "on-stage",
9867            "on-session-start",
9868            "on-task-start",
9869            "on-inference",
9870            "on-tool-call",
9871            "on-shell",
9872            "on-compaction",
9873            "on-task-end",
9874            "on-session-end",
9875        ] {
9876            let yaml =
9877                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
9878            let config = parse_hook_config_from_yaml(&yaml).unwrap();
9879            assert_eq!(config.commit_policy, HookCommitPolicy::None);
9880        }
9881    }
9882
9883    /// Resolved ambiguity: an omitted `binding:` is `HookBinding::All`, which is dispatched
9884    /// to every event — including every one that honors an arm — so every `commit_policy` is
9885    /// accepted for it. Deliberate: no narrowing for `All`. `deny` is the one exception,
9886    /// covered by [`hook_config_deny_without_a_binding_is_rejected`].
9887    #[test]
9888    fn hook_config_omitted_binding_accepts_every_commit_policy() {
9889        for (policy, expected) in [
9890            ("replace-context", HookCommitPolicy::ReplaceContext),
9891            ("write-manifests", HookCommitPolicy::WriteManifests),
9892            ("reopen-task", HookCommitPolicy::ReopenTask),
9893            ("none", HookCommitPolicy::None),
9894        ] {
9895            let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
9896            let config = parse_hook_config_from_yaml(&yaml).unwrap();
9897            assert_eq!(config.binding, HookBinding::All);
9898            assert_eq!(config.commit_policy, expected);
9899        }
9900    }
9901
9902    /// Every hook artifact shipped in `default-artifacts` today must keep parsing after the
9903    /// binding/commit_policy check exists — this change is non-breaking for all eight. The
9904    /// field combinations are reproduced literally rather than read from that repo, so this
9905    /// suite stays self-contained.
9906    #[test]
9907    fn hook_config_shipped_default_artifact_manifests_still_parse() {
9908        let shipped: [(&str, &str, HookConfig); 8] = [
9909            (
9910                "murmur-hook-compact",
9911                "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
9912                HookConfig {
9913                    binding: HookBinding::OnCompaction,
9914                    execution_mode: HookExecutionMode::Blocking,
9915                    commit_policy: HookCommitPolicy::ReplaceContext,
9916                },
9917            ),
9918            (
9919                "murmur-hook-shell-desc",
9920                "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
9921                HookConfig {
9922                    binding: HookBinding::OnStage,
9923                    execution_mode: HookExecutionMode::Blocking,
9924                    commit_policy: HookCommitPolicy::WriteManifests,
9925                },
9926            ),
9927            (
9928                // No `binding:` line in the shipped manifest — `All`, not `on-task-end`.
9929                "murmur-hook-regression-verifier",
9930                "execution_mode: blocking\ncommit_policy: reopen-task\n",
9931                HookConfig {
9932                    binding: HookBinding::All,
9933                    execution_mode: HookExecutionMode::Blocking,
9934                    commit_policy: HookCommitPolicy::ReopenTask,
9935                },
9936            ),
9937            (
9938                "murmur-hook-memory",
9939                "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
9940                HookConfig {
9941                    binding: HookBinding::OnTaskStart,
9942                    execution_mode: HookExecutionMode::Blocking,
9943                    commit_policy: HookCommitPolicy::SeedContext,
9944                },
9945            ),
9946            (
9947                "murmur-hook-debug",
9948                "execution_mode: async\ncommit_policy: none\n",
9949                HookConfig {
9950                    binding: HookBinding::All,
9951                    execution_mode: HookExecutionMode::Async,
9952                    commit_policy: HookCommitPolicy::None,
9953                },
9954            ),
9955            (
9956                "murmur-hook-diff-summary",
9957                "execution_mode: blocking\ncommit_policy: none\n",
9958                HookConfig {
9959                    binding: HookBinding::All,
9960                    execution_mode: HookExecutionMode::Blocking,
9961                    commit_policy: HookCommitPolicy::None,
9962                },
9963            ),
9964            ("murmur-hook-eval", "", HookConfig::default()),
9965            ("murmur-hook-grafana", "", HookConfig::default()),
9966        ];
9967
9968        for (name, fields, expected) in shipped {
9969            let yaml = format!("name: {name}\nruntime: hook\n{fields}");
9970            let config = parse_hook_config_from_yaml(&yaml)
9971                .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
9972            assert_eq!(config, expected, "shipped hook {name}");
9973        }
9974    }
9975
9976    /// The manifest-side honored-policy table agrees with the manifest spellings the
9977    /// parser accepts; `capsule-runtime` separately cross-checks it against its own
9978    /// `HONORED_OUTPUT_ARM` dispatch table.
9979    #[test]
9980    fn commit_policy_for_binding_matches_the_declarable_pairs() {
9981        assert_eq!(
9982            commit_policy_for_binding(&HookBinding::OnStage),
9983            Some(HookCommitPolicy::WriteManifests)
9984        );
9985        assert_eq!(
9986            commit_policy_for_binding(&HookBinding::OnCompaction),
9987            Some(HookCommitPolicy::ReplaceContext)
9988        );
9989        assert_eq!(
9990            commit_policy_for_binding(&HookBinding::OnTaskEnd),
9991            Some(HookCommitPolicy::ReopenTask)
9992        );
9993        assert_eq!(
9994            commit_policy_for_binding(&HookBinding::OnTaskStart),
9995            Some(HookCommitPolicy::SeedContext)
9996        );
9997        assert_eq!(
9998            commit_policy_for_binding(&HookBinding::OnToolCall),
9999            Some(HookCommitPolicy::Deny)
10000        );
10001        assert_eq!(
10002            commit_policy_for_binding(&HookBinding::OnShell),
10003            Some(HookCommitPolicy::Deny)
10004        );
10005        // `on-inference` honors `artifact`, which has no `commit_policy` spelling; the
10006        // other two honor nothing. `All` is unconstrained by design, apart from the `deny`
10007        // carve-out the parser applies.
10008        for binding in [
10009            HookBinding::OnSessionStart,
10010            HookBinding::OnInference,
10011            HookBinding::OnSessionEnd,
10012            HookBinding::All,
10013        ] {
10014            assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
10015        }
10016    }
10017
10018    /// The `on-task-start` pairing is declarable end to end: the parser accepts it, and
10019    /// refuses the same policy on any other binding by name.
10020    #[test]
10021    fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
10022        let config = parse_hook_config_from_yaml(
10023            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
10024             commit_policy: seed-context\n",
10025        )
10026        .expect("on-task-start + seed-context is the declarable pairing");
10027        assert_eq!(config.binding, HookBinding::OnTaskStart);
10028        assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
10029
10030        let error = parse_hook_config_from_yaml(
10031            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
10032             commit_policy: seed-context\n",
10033        )
10034        .expect_err("on-compaction cannot commit a seed");
10035        assert!(error.contains("on-compaction"), "{error}");
10036        assert!(error.contains("seed-context"), "{error}");
10037    }
10038
10039    /// The unknown-value diagnostic lists every accepted spelling, so an operator who
10040    /// mistypes one reads the whole set rather than guessing.
10041    #[test]
10042    fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
10043        let error = parse_hook_config_from_yaml(
10044            "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
10045        )
10046        .expect_err("a misspelled policy is rejected");
10047        assert!(error.contains("seed-context"), "{error}");
10048    }
10049
10050    // ── context.seed_budget / context.seed_overflow_margin ───────────────────
10051
10052    fn context_of(yaml_block: &str) -> Option<ContextConfig> {
10053        RuntimeManifest::from_yaml_str(&format!(
10054            "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
10055        ))
10056        .expect("the manifest under test parses")
10057        .context
10058    }
10059
10060    #[test]
10061    fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
10062        let context = context_of(
10063            "context:\n  max_tokens: 200000\n  seed_budget: 0.10\n  seed_overflow_margin: 0.10\n",
10064        )
10065        .expect("a declared context block parses to Some");
10066        assert_eq!(context.max_tokens, Some(200_000));
10067        assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
10068        assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
10069    }
10070
10071    #[test]
10072    fn seed_budget_and_overflow_margin_default_when_omitted() {
10073        let context = context_of("context:\n  max_tokens: 200000\n")
10074            .expect("a declared context block parses to Some");
10075        assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
10076        assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
10077    }
10078
10079    /// No `context:` block at all stays `None` — the two fractions default *within* a
10080    /// declared block, and never conjure one that the operator did not write.
10081    #[test]
10082    fn seed_budget_defaults_do_not_create_a_context_block() {
10083        assert_eq!(context_of(""), None);
10084    }
10085
10086    #[test]
10087    fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
10088        for (block, field) in [
10089            ("context:\n  seed_budget: 1.5\n", "context.seed_budget"),
10090            ("context:\n  seed_budget: -0.1\n", "context.seed_budget"),
10091            (
10092                "context:\n  seed_overflow_margin: 2.0\n",
10093                "context.seed_overflow_margin",
10094            ),
10095        ] {
10096            let error = RuntimeManifest::from_yaml_str(&format!(
10097                "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
10098            ))
10099            .expect_err("an out-of-range fraction is rejected");
10100            match error {
10101                RuntimeManifestError::InvalidInferenceConfig {
10102                    field: got,
10103                    ref message,
10104                } => {
10105                    assert_eq!(got, field);
10106                    assert!(message.contains("0.0 and 1.0"), "{message}");
10107                }
10108                other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
10109            }
10110        }
10111    }
10112
10113    #[test]
10114    fn parse_tool_implementation_defaults_to_wasm() {
10115        let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
10116        assert_eq!(impl_, ArtifactImplementation::Wasm);
10117    }
10118
10119    #[test]
10120    fn parse_tool_implementation_native() {
10121        let impl_ = parse_tool_implementation_from_yaml(
10122            "name: my-tool\nruntime: tool\nimplementation: native\n",
10123        );
10124        assert_eq!(impl_, ArtifactImplementation::Native);
10125    }
10126
10127    #[test]
10128    fn mur_version_parses_correctly() {
10129        let manifest = RuntimeManifest::from_yaml_str(
10130            "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"0.4.5\"\n",
10131        )
10132        .unwrap();
10133        assert_eq!(manifest.mur_version, Some("0.4.5".to_string()));
10134    }
10135
10136    #[test]
10137    fn mur_version_absent_is_none() {
10138        let manifest =
10139            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
10140        assert_eq!(manifest.mur_version, None);
10141    }
10142
10143    #[test]
10144    fn mur_version_empty_string_is_none() {
10145        let manifest = RuntimeManifest::from_yaml_str(
10146            "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"\"\n",
10147        )
10148        .unwrap();
10149        assert_eq!(manifest.mur_version, None);
10150    }
10151
10152    #[test]
10153    fn inference_max_turns_defaults_to_10() {
10154        let manifest = RuntimeManifest::from_yaml_str(
10155            "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",
10156        ).unwrap();
10157        assert_eq!(manifest.inference.unwrap().max_turns, 10);
10158    }
10159
10160    #[test]
10161    fn inference_max_turns_explicit_value() {
10162        let manifest = RuntimeManifest::from_yaml_str(
10163            "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",
10164        ).unwrap();
10165        assert_eq!(manifest.inference.unwrap().max_turns, 20);
10166    }
10167
10168    #[test]
10169    fn inference_max_turns_zero_is_rejected() {
10170        let err = RuntimeManifest::from_yaml_str(
10171            "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",
10172        ).unwrap_err();
10173        let msg = err.to_string();
10174        assert!(msg.contains("inference.max_turns"), "error was: {msg}");
10175        assert!(msg.contains("greater than 0"), "error was: {msg}");
10176    }
10177
10178    /// A `lifecycle:` block that says nothing about the budget still permits one reopen.
10179    #[test]
10180    fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
10181        let manifest = RuntimeManifest::from_yaml_str(
10182            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 45\n",
10183        )
10184        .unwrap();
10185        let lifecycle = manifest.lifecycle.unwrap();
10186        assert_eq!(lifecycle.shell_grace_secs, 45);
10187        // Every other lifecycle field is untouched by declaring this one.
10188        assert_eq!(
10189            lifecycle.task_acceptance,
10190            LifecycleConfig::default().task_acceptance
10191        );
10192        assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
10193        assert_eq!(
10194            lifecycle.queue_depth,
10195            LifecycleConfig::default().queue_depth
10196        );
10197        assert_eq!(lifecycle.input_timeout_secs, None);
10198        assert_eq!(
10199            lifecycle.conversation_mode,
10200            LifecycleConfig::default().conversation_mode
10201        );
10202        assert_eq!(
10203            lifecycle.max_task_reopens,
10204            LifecycleConfig::default().max_task_reopens
10205        );
10206    }
10207
10208    #[test]
10209    fn lifecycle_shell_grace_secs_defaults_to_10() {
10210        let manifest = RuntimeManifest::from_yaml_str(
10211            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
10212        )
10213        .unwrap();
10214        let lifecycle = manifest.lifecycle.unwrap();
10215        assert_eq!(lifecycle.shell_grace_secs, 10);
10216        assert_eq!(lifecycle.queue_depth, 3);
10217
10218        let without_block =
10219            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
10220        assert_eq!(without_block.effective_lifecycle().shell_grace_secs, 10);
10221    }
10222
10223    /// The bound a capsule delegates under is declared, and a capsule that declares nothing is
10224    /// still bounded: absent means a ceiling, never "wait forever".
10225    #[test]
10226    fn lifecycle_delegation_deadline_secs_defaults_to_600() {
10227        let without_block =
10228            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
10229        assert_eq!(
10230            without_block.effective_lifecycle().delegation_deadline_secs,
10231            600
10232        );
10233
10234        let without_key = RuntimeManifest::from_yaml_str(
10235            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
10236        )
10237        .unwrap();
10238        let lifecycle = without_key.lifecycle.unwrap();
10239        assert_eq!(lifecycle.delegation_deadline_secs, 600);
10240        assert_eq!(lifecycle.queue_depth, 3);
10241    }
10242
10243    #[test]
10244    fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
10245        let manifest = RuntimeManifest::from_yaml_str(
10246            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
10247             delegation_deadline_secs: 30\n",
10248        )
10249        .unwrap();
10250        let lifecycle = manifest.lifecycle.unwrap();
10251        assert_eq!(lifecycle.delegation_deadline_secs, 30);
10252        // Every other lifecycle field is untouched by declaring this one.
10253        assert_eq!(
10254            lifecycle.shell_grace_secs,
10255            LifecycleConfig::default().shell_grace_secs
10256        );
10257        assert_eq!(
10258            lifecycle.max_task_reopens,
10259            LifecycleConfig::default().max_task_reopens
10260        );
10261    }
10262
10263    /// Declaring the key produces no unknown-key report, and a misspelling of it still does.
10264    #[test]
10265    fn lifecycle_delegation_deadline_secs_is_a_known_key() {
10266        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
10267        let manifest = RuntimeManifest::from_yaml_str(
10268            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
10269             delegation_deadline_secs: 30\n  delegation_deadline: 30\n",
10270        )
10271        .unwrap();
10272        let reported: Vec<(String, String)> = manifest
10273            .unknown_keys
10274            .iter()
10275            .map(|key| (key.key.clone(), key.block_path.clone()))
10276            .collect();
10277        assert_eq!(
10278            reported,
10279            vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
10280            "the declared key is known and only the misspelt neighbour is reported"
10281        );
10282        assert_eq!(
10283            manifest.lifecycle.unwrap().delegation_deadline_secs,
10284            30,
10285            "the misspelt neighbour does not disturb the declared value"
10286        );
10287    }
10288
10289    /// `0` is a valid explicit value, not an absent one: it demotes at the first poll after the
10290    /// spawn, so effectively every command detaches.
10291    #[test]
10292    fn lifecycle_shell_grace_secs_zero_is_accepted() {
10293        let manifest = RuntimeManifest::from_yaml_str(
10294            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 0\n",
10295        )
10296        .unwrap();
10297        let lifecycle = manifest.lifecycle.unwrap();
10298        assert_eq!(lifecycle.shell_grace_secs, 0);
10299        assert_eq!(
10300            lifecycle.max_task_reopens,
10301            LifecycleConfig::default().max_task_reopens
10302        );
10303    }
10304
10305    #[test]
10306    fn lifecycle_max_task_reopens_defaults_to_1() {
10307        let manifest = RuntimeManifest::from_yaml_str(
10308            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  after_task: sleep\n",
10309        )
10310        .unwrap();
10311        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
10312    }
10313
10314    /// No `lifecycle:` block at all lands on the same default through `effective_lifecycle`.
10315    #[test]
10316    fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
10317        let manifest =
10318            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
10319        assert!(manifest.lifecycle.is_none());
10320        assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
10321    }
10322
10323    #[test]
10324    fn lifecycle_max_task_reopens_explicit_value() {
10325        let manifest = RuntimeManifest::from_yaml_str(
10326            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 3\n",
10327        )
10328        .unwrap();
10329        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
10330    }
10331
10332    /// Unlike `inference.max_turns`, `0` is a valid explicit value — it disables reopening.
10333    #[test]
10334    fn lifecycle_max_task_reopens_zero_is_accepted() {
10335        let manifest = RuntimeManifest::from_yaml_str(
10336            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 0\n",
10337        )
10338        .unwrap();
10339        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
10340    }
10341
10342    /// The budget is independent of the inference transport in play.
10343    #[test]
10344    fn lifecycle_max_task_reopens_with_process_transport() {
10345        let manifest = RuntimeManifest::from_yaml_str(
10346            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: fixture-process-driver\n    version: 0.1.0\n    runtime: driver\ninference:\n  transport: process\n  driver:\n    artifact: fixture-process-driver\nlifecycle:\n  max_task_reopens: 2\n",
10347        ).unwrap();
10348        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
10349    }
10350
10351    #[test]
10352    fn inference_max_task_reopens_is_rejected_under_http_transport() {
10353        let err = RuntimeManifest::from_yaml_str(
10354            "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",
10355        ).unwrap_err();
10356        assert!(
10357            matches!(
10358                &err,
10359                RuntimeManifestError::InvalidInferenceConfig { field, .. }
10360                    if field == "inference.max_task_reopens"
10361            ),
10362            "error was: {err:?}"
10363        );
10364        let msg = err.to_string();
10365        assert!(
10366            msg.contains("lifecycle.max_task_reopens"),
10367            "error was: {msg}"
10368        );
10369    }
10370
10371    #[test]
10372    fn inference_max_task_reopens_is_rejected_under_process_transport() {
10373        let err = RuntimeManifest::from_yaml_str(
10374            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  max_task_reopens: 2\n",
10375        ).unwrap_err();
10376        assert!(
10377            matches!(
10378                &err,
10379                RuntimeManifestError::InvalidInferenceConfig { field, .. }
10380                    if field == "inference.max_task_reopens"
10381            ),
10382            "error was: {err:?}"
10383        );
10384        let msg = err.to_string();
10385        assert!(
10386            msg.contains("lifecycle.max_task_reopens"),
10387            "error was: {msg}"
10388        );
10389    }
10390
10391    /// The rejection is unconditional: the new key being present alongside it changes nothing.
10392    #[test]
10393    fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
10394        let err = RuntimeManifest::from_yaml_str(
10395            "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",
10396        ).unwrap_err();
10397        assert!(
10398            matches!(
10399                &err,
10400                RuntimeManifestError::InvalidInferenceConfig { field, .. }
10401                    if field == "inference.max_task_reopens"
10402            ),
10403            "error was: {err:?}"
10404        );
10405    }
10406
10407    #[test]
10408    fn skill_is_llm_visible() {
10409        assert!(ArtifactRuntime::Skill.is_llm_visible());
10410    }
10411
10412    #[test]
10413    fn driver_and_hook_are_not_llm_visible() {
10414        assert!(!ArtifactRuntime::Driver.is_llm_visible());
10415        assert!(!ArtifactRuntime::Hook.is_llm_visible());
10416    }
10417
10418    #[test]
10419    fn parses_system_prompt_artifact() {
10420        let manifest = RuntimeManifest::from_yaml_str(
10421            r#"
10422name: cap
10423version: 0.0.1
10424artifacts:
10425  - name: my-skill
10426    version: 1.0.0
10427    runtime: skill
10428  - name: murmur-driver-anthropic
10429    version: 0.1.0
10430    runtime: driver
10431    gateway:
10432      endpoint: http://127.0.0.1:8080
10433      keyless: true
10434inference:
10435  model: test-model
10436  system_prompt_artifact: my-skill
10437  driver:
10438    artifact: murmur-driver-anthropic
10439"#,
10440        )
10441        .unwrap();
10442
10443        let inference = manifest.inference.unwrap();
10444        assert_eq!(
10445            inference.system_prompt_artifact,
10446            Some("my-skill".to_string())
10447        );
10448        assert!(inference.system_prompt.is_none());
10449        assert!(inference.system_prompt_file.is_none());
10450    }
10451
10452    #[test]
10453    fn rejects_system_prompt_artifact_with_inline_prompt() {
10454        let err = RuntimeManifest::from_yaml_str(
10455            r#"
10456name: cap
10457version: 0.0.1
10458artifacts:
10459  - name: my-skill
10460    version: 1.0.0
10461    runtime: skill
10462  - name: murmur-driver-anthropic
10463    version: 0.1.0
10464    runtime: driver
10465    gateway:
10466      endpoint: http://127.0.0.1:8080
10467      keyless: true
10468inference:
10469  model: test-model
10470  system_prompt: "inline"
10471  system_prompt_artifact: my-skill
10472  driver:
10473    artifact: murmur-driver-anthropic
10474"#,
10475        )
10476        .unwrap_err();
10477
10478        let msg = err.to_string();
10479        assert!(msg.contains("at most one"), "error was: {msg}");
10480    }
10481
10482    #[test]
10483    fn rejects_system_prompt_artifact_naming_non_skill() {
10484        let err = RuntimeManifest::from_yaml_str(
10485            r#"
10486name: cap
10487version: 0.0.1
10488artifacts:
10489  - name: my-tool
10490    version: 1.0.0
10491    runtime: tool
10492  - name: murmur-driver-anthropic
10493    version: 0.1.0
10494    runtime: driver
10495    gateway:
10496      endpoint: http://127.0.0.1:8080
10497      keyless: true
10498inference:
10499  model: test-model
10500  system_prompt_artifact: my-tool
10501  driver:
10502    artifact: murmur-driver-anthropic
10503"#,
10504        )
10505        .unwrap_err();
10506
10507        let msg = err.to_string();
10508        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
10509        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
10510    }
10511
10512    #[test]
10513    fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
10514        let err = RuntimeManifest::from_yaml_str(
10515            r#"
10516name: cap
10517version: 0.0.1
10518artifacts:
10519  - name: murmur-driver-anthropic
10520    version: 0.1.0
10521    runtime: driver
10522    gateway:
10523      endpoint: http://127.0.0.1:8080
10524      keyless: true
10525inference:
10526  model: test-model
10527  system_prompt_artifact: missing-skill
10528  driver:
10529    artifact: murmur-driver-anthropic
10530"#,
10531        )
10532        .unwrap_err();
10533
10534        let msg = err.to_string();
10535        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
10536        assert!(msg.contains("not declared"), "error was: {msg}");
10537    }
10538
10539    #[test]
10540    fn rejects_all_three_system_prompt_sources() {
10541        let err = RuntimeManifest::from_yaml_str(
10542            r#"
10543name: cap
10544version: 0.0.1
10545artifacts:
10546  - name: my-skill
10547    version: 1.0.0
10548    runtime: skill
10549  - name: murmur-driver-anthropic
10550    version: 0.1.0
10551    runtime: driver
10552    gateway:
10553      endpoint: http://127.0.0.1:8080
10554      keyless: true
10555inference:
10556  model: test-model
10557  system_prompt: "inline"
10558  system_prompt_file: "conventions.md"
10559  system_prompt_artifact: my-skill
10560  driver:
10561    artifact: murmur-driver-anthropic
10562"#,
10563        )
10564        .unwrap_err();
10565
10566        let msg = err.to_string();
10567        assert!(msg.contains("at most one"), "error was: {msg}");
10568    }
10569
10570    // ── Per-artifact capability grants ───────────────────────────────────────
10571
10572    #[test]
10573    fn hook_artifact_capabilities_parse_network_and_filesystem() {
10574        let manifest = RuntimeManifest::from_yaml_str(
10575            r#"
10576name: cap
10577version: 0.0.1
10578artifacts:
10579  - name: telemetry-hook
10580    version: 1.0.0
10581    runtime: hook
10582    capabilities:
10583      network:
10584        allow:
10585          - https://telemetry.example.com
10586      filesystem:
10587        scope: hook-state
10588"#,
10589        )
10590        .unwrap();
10591
10592        let caps = manifest.artifacts[0]
10593            .capabilities
10594            .as_ref()
10595            .expect("hook entry carries a capability grant");
10596        assert_eq!(
10597            caps.network.as_ref().unwrap().allow,
10598            vec!["https://telemetry.example.com".to_string()]
10599        );
10600        assert_eq!(
10601            caps.filesystem.as_ref().unwrap().scope,
10602            Some("hook-state".to_string())
10603        );
10604    }
10605
10606    /// Default-deny: a hook entry with no `capabilities:` key parses to `None`, which the
10607    /// runtime lowers to zero network and zero preopened directories.
10608    #[test]
10609    fn hook_artifact_without_capabilities_is_none() {
10610        let manifest = RuntimeManifest::from_yaml_str(
10611            r#"
10612name: cap
10613version: 0.0.1
10614artifacts:
10615  - name: quiet-hook
10616    version: 1.0.0
10617    runtime: hook
10618"#,
10619        )
10620        .unwrap();
10621
10622        assert!(manifest.artifacts[0].capabilities.is_none());
10623    }
10624
10625    /// Tools and drivers execute, so they may carry a grant — the runtime reads it as a
10626    /// narrowing of the capsule-wide ceiling rather than as a default-deny baseline.
10627    #[test]
10628    fn tool_and_driver_artifact_capabilities_parse() {
10629        for role in ["tool", "driver"] {
10630            let yaml = format!(
10631                r#"
10632name: cap
10633version: 0.0.1
10634artifacts:
10635  - name: scoped
10636    version: 1.0.0
10637    runtime: {role}
10638    capabilities:
10639      network:
10640        allow:
10641          - https://api.example.com
10642      filesystem:
10643        scope: cache
10644"#
10645            );
10646            let manifest = RuntimeManifest::from_yaml_str(&yaml)
10647                .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
10648
10649            let caps = manifest.artifacts[0]
10650                .capabilities
10651                .as_ref()
10652                .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
10653            assert_eq!(
10654                caps.network.as_ref().unwrap().allow,
10655                vec!["https://api.example.com".to_string()]
10656            );
10657            assert_eq!(
10658                caps.filesystem.as_ref().unwrap().scope,
10659                Some("cache".to_string())
10660            );
10661        }
10662    }
10663
10664    /// A tool/driver entry with no `capabilities:` key parses to `None`, which the runtime
10665    /// lowers to the unchanged capsule ceiling.
10666    #[test]
10667    fn tool_artifact_without_capabilities_is_none() {
10668        let manifest = RuntimeManifest::from_yaml_str(
10669            r#"
10670name: cap
10671version: 0.0.1
10672artifacts:
10673  - name: plain-tool
10674    version: 1.0.0
10675    runtime: tool
10676"#,
10677        )
10678        .unwrap();
10679
10680        assert!(manifest.artifacts[0].capabilities.is_none());
10681    }
10682
10683    /// A skill has no execution surface, so a grant on one would be silently unenforced —
10684    /// rejected at parse time, with a message naming the roles that do work.
10685    #[test]
10686    fn skill_artifact_capabilities_are_rejected() {
10687        let yaml = r#"
10688name: cap
10689version: 0.0.1
10690artifacts:
10691  - name: sneaky
10692    version: 1.0.0
10693    runtime: skill
10694    capabilities:
10695      network:
10696        allow:
10697          - https://evil.example.com
10698"#;
10699        let err = match RuntimeManifest::from_yaml_str(yaml) {
10700            Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
10701            Err(err) => err,
10702        };
10703        let msg = err.to_string();
10704        assert!(msg.contains("sneaky"), "error was: {msg}");
10705        assert!(
10706            msg.contains(
10707                "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
10708                 entries"
10709            ),
10710            "error was: {msg}"
10711        );
10712        assert!(msg.contains("runtime: skill"), "error was: {msg}");
10713    }
10714
10715    // ── Per-artifact `config:` (operator-authored artifact configuration) ────
10716
10717    /// The block is carried verbatim as YAML. This crate accepts any mapping; the runtime is
10718    /// what lowers it to JSON and refuses a shape that cannot travel that way.
10719    #[test]
10720    fn artifact_config_is_carried_verbatim() {
10721        let manifest = RuntimeManifest::from_yaml_str(
10722            r#"
10723name: cap
10724version: 0.0.1
10725artifacts:
10726  - name: murmur-tool-corpus
10727    version: 0.1.0
10728    runtime: tool
10729    config:
10730      read_recent: { default: 20, max: 100 }
10731"#,
10732        )
10733        .unwrap();
10734
10735        let config = manifest.artifacts[0]
10736            .config
10737            .as_ref()
10738            .expect("a declared block parses");
10739        assert_eq!(
10740            config["read_recent"]["max"],
10741            serde_yaml::Value::Number(100.into())
10742        );
10743    }
10744
10745    /// Absent means absent: no key, no variable, and nothing for the runtime to lower.
10746    #[test]
10747    fn an_undeclared_config_is_none() {
10748        let manifest = RuntimeManifest::from_yaml_str(
10749            r#"
10750name: cap
10751version: 0.0.1
10752artifacts:
10753  - name: git
10754    version: 1.0.0
10755    runtime: tool
10756"#,
10757        )
10758        .unwrap();
10759
10760        assert!(manifest.artifacts[0].config.is_none());
10761    }
10762
10763    /// `config:` with nothing under it is a written declaration that carries nothing, and must
10764    /// stay distinguishable from the key being absent — the runtime refuses it, and can only do
10765    /// so if the parser does not collapse YAML null into `None`.
10766    #[test]
10767    fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
10768        let manifest = RuntimeManifest::from_yaml_str(
10769            r#"
10770name: cap
10771version: 0.0.1
10772artifacts:
10773  - name: git
10774    version: 1.0.0
10775    runtime: tool
10776    config:
10777"#,
10778        )
10779        .unwrap();
10780
10781        assert_eq!(
10782            manifest.artifacts[0].config,
10783            Some(serde_yaml::Value::Null),
10784            "an empty block must survive parsing as a declaration, not vanish into None"
10785        );
10786    }
10787
10788    /// A skill runs no component and holds no grant, so nothing would ever deliver a config
10789    /// block to one — rejected at parse time, with a message naming the roles that do.
10790    #[test]
10791    fn skill_artifact_config_is_rejected() {
10792        let yaml = r#"
10793name: cap
10794version: 0.0.1
10795artifacts:
10796  - name: sneaky
10797    version: 1.0.0
10798    runtime: skill
10799    config:
10800      who: me
10801"#;
10802        let err = match RuntimeManifest::from_yaml_str(yaml) {
10803            Ok(_) => panic!("config: must be rejected for runtime: skill"),
10804            Err(err) => err,
10805        };
10806        assert!(matches!(
10807            err,
10808            RuntimeManifestError::InvalidArtifact { index: 0, .. }
10809        ));
10810        let msg = err.to_string();
10811        assert!(msg.contains("sneaky"), "error was: {msg}");
10812        assert!(msg.contains("runtime: skill"), "error was: {msg}");
10813        assert!(
10814            msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
10815            "error was: {msg}"
10816        );
10817    }
10818
10819    /// Config is delivered on one artifact's own grant, so a capsule-wide block reaches nothing.
10820    /// Refused rather than silently ignored, and the message points at the artifact entry.
10821    #[test]
10822    fn a_capsule_wide_config_block_is_rejected() {
10823        let yaml = r#"
10824name: cap
10825version: 0.0.1
10826config:
10827  who: capsule
10828artifacts:
10829  - name: git
10830    version: 1.0.0
10831    runtime: tool
10832"#;
10833        let err = match RuntimeManifest::from_yaml_str(yaml) {
10834            Ok(_) => panic!("a capsule-wide config block must be rejected"),
10835            Err(err) => err,
10836        };
10837        let msg = err.to_string();
10838        assert!(msg.contains("config"), "error was: {msg}");
10839        assert!(msg.contains("declared per artifact"), "error was: {msg}");
10840    }
10841
10842    // ── Per-artifact `on_overflow:` (async hook queue policy) ────────────────
10843
10844    /// `on_overflow: block` on a hook entry parses to the blocking policy.
10845    #[test]
10846    fn hook_on_overflow_block_parses() {
10847        let manifest = RuntimeManifest::from_yaml_str(
10848            r#"
10849name: cap
10850version: 0.0.1
10851artifacts:
10852  - name: murmur-hook-grafana
10853    version: 1.0.0
10854    runtime: hook
10855    on_overflow: block
10856"#,
10857        )
10858        .unwrap();
10859
10860        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
10861    }
10862
10863    /// Omitting the key means `drop`: an operator who says nothing gets the loop kept moving,
10864    /// not backpressure they never asked for.
10865    #[test]
10866    fn hook_on_overflow_defaults_to_drop() {
10867        let manifest = RuntimeManifest::from_yaml_str(
10868            r#"
10869name: cap
10870version: 0.0.1
10871artifacts:
10872  - name: murmur-hook-grafana
10873    version: 1.0.0
10874    runtime: hook
10875"#,
10876        )
10877        .unwrap();
10878
10879        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
10880        assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
10881    }
10882
10883    /// Only a hook has a job queue, so the key is rejected on every other role rather than
10884    /// silently ignored — the same rule per-artifact `capabilities:` follows on a skill.
10885    #[test]
10886    fn on_overflow_on_a_non_hook_artifact_is_rejected() {
10887        let yaml = r#"
10888name: cap
10889version: 0.0.1
10890artifacts:
10891  - name: git
10892    version: 1.0.0
10893    runtime: tool
10894    on_overflow: block
10895"#;
10896        let err = match RuntimeManifest::from_yaml_str(yaml) {
10897            Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
10898            Err(err) => err,
10899        };
10900        assert!(matches!(
10901            err,
10902            RuntimeManifestError::InvalidArtifact { index: 0, .. }
10903        ));
10904        let msg = err.to_string();
10905        assert!(msg.contains("git"), "error was: {msg}");
10906        assert!(
10907            msg.contains("only recognized on 'runtime: hook' entries"),
10908            "error was: {msg}"
10909        );
10910    }
10911
10912    /// An unknown value is a typo, not a policy — rejected with both spellings named.
10913    #[test]
10914    fn unknown_on_overflow_value_is_rejected() {
10915        let yaml = r#"
10916name: cap
10917version: 0.0.1
10918artifacts:
10919  - name: murmur-hook-grafana
10920    version: 1.0.0
10921    runtime: hook
10922    on_overflow: spill
10923"#;
10924        let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
10925        let msg = err.to_string();
10926        assert!(msg.contains("spill"), "error was: {msg}");
10927        assert!(msg.contains("expected: drop, block"), "error was: {msg}");
10928    }
10929
10930    /// The key is legal on a *blocking* hook entry and simply inert: the operator manifest
10931    /// cannot know a hook's execution mode, which lives in the artifact's own manifest.
10932    #[test]
10933    fn on_overflow_is_accepted_on_any_hook_entry() {
10934        let manifest = RuntimeManifest::from_yaml_str(
10935            r#"
10936name: cap
10937version: 0.0.1
10938artifacts:
10939  - name: murmur-hook-eval
10940    version: 1.0.0
10941    runtime: hook
10942    on_overflow: drop
10943    capabilities:
10944      network:
10945        allow:
10946          - https://telemetry.example.com
10947"#,
10948        )
10949        .unwrap();
10950
10951        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
10952        assert!(manifest.artifacts[0].capabilities.is_some());
10953    }
10954
10955    /// The capsule-wide top-level block is untouched by the per-artifact field: both may be
10956    /// present in one manifest and neither shadows the other.
10957    #[test]
10958    fn capsule_wide_and_hook_capabilities_coexist() {
10959        let manifest = RuntimeManifest::from_yaml_str(
10960            r#"
10961name: cap
10962version: 0.0.1
10963artifacts:
10964  - name: telemetry-hook
10965    version: 1.0.0
10966    runtime: hook
10967    capabilities:
10968      network:
10969        allow:
10970          - https://telemetry.example.com
10971capabilities:
10972  network:
10973    allow:
10974      - https://api.anthropic.com
10975"#,
10976        )
10977        .unwrap();
10978
10979        assert_eq!(
10980            manifest.capabilities.unwrap().network.unwrap().allow,
10981            vec!["https://api.anthropic.com".to_string()]
10982        );
10983        assert_eq!(
10984            manifest.artifacts[0]
10985                .capabilities
10986                .as_ref()
10987                .unwrap()
10988                .network
10989                .as_ref()
10990                .unwrap()
10991                .allow,
10992            vec!["https://telemetry.example.com".to_string()]
10993        );
10994    }
10995
10996    /// No self-escalation: the hook artifact's *own* bundled murmur.yaml is parsed only by
10997    /// `parse_hook_config_from_yaml`, which has no capabilities field and no code path that
10998    /// reads one. A self-declared `capabilities:` block there is inert.
10999    #[test]
11000    fn hook_own_manifest_capabilities_are_not_read_into_config() {
11001        let config = parse_hook_config_from_yaml(
11002            r#"
11003name: telemetry-hook
11004version: 1.0.0
11005binding: on-inference
11006execution_mode: blocking
11007capabilities:
11008  network:
11009    allow:
11010      - https://evil.example.com
11011  filesystem:
11012    scope: /
11013"#,
11014        )
11015        .expect("a self-declared capabilities block is ignored, not an error");
11016
11017        assert_eq!(
11018            config,
11019            HookConfig {
11020                binding: HookBinding::OnInference,
11021                execution_mode: HookExecutionMode::Blocking,
11022                commit_policy: HookCommitPolicy::None,
11023            }
11024        );
11025    }
11026
11027    // ── Containment class ────────────────────────────────────────────────────
11028
11029    #[test]
11030    fn containment_classes_order_weakest_to_strongest() {
11031        assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
11032        assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
11033        assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
11034        assert_eq!(
11035            ContainmentClass::ALL.iter().copied().max(),
11036            Some(ContainmentClass::Sealed)
11037        );
11038    }
11039
11040    #[test]
11041    fn containment_class_defaults_to_advisory() {
11042        assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
11043    }
11044
11045    #[test]
11046    fn containment_class_round_trips_through_string() {
11047        for class in ContainmentClass::ALL {
11048            assert_eq!(
11049                class.to_string().parse::<ContainmentClass>(),
11050                Ok(class),
11051                "{class} must survive Display -> FromStr"
11052            );
11053        }
11054        assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
11055        assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
11056        assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
11057    }
11058
11059    #[test]
11060    fn containment_class_rejects_unknown_names() {
11061        let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
11062        assert_eq!(err.value, "paranoid");
11063        assert_eq!(
11064            err.to_string(),
11065            "must be one of: advisory, scoped, sealed; got 'paranoid'"
11066        );
11067        // Case-sensitive by design: the wire form is lowercase everywhere.
11068        assert!("Sealed".parse::<ContainmentClass>().is_err());
11069    }
11070
11071    #[test]
11072    fn containment_class_serializes_as_its_wire_name() {
11073        assert_eq!(
11074            serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
11075            "\"scoped\""
11076        );
11077        assert_eq!(
11078            serde_yaml::from_str::<ContainmentClass>("sealed").unwrap(),
11079            ContainmentClass::Sealed
11080        );
11081    }
11082
11083    #[test]
11084    fn effective_floor_is_advisory_when_nothing_is_declared() {
11085        assert_eq!(
11086            effective_containment_floor(None, None, None),
11087            ContainmentClass::Advisory
11088        );
11089    }
11090
11091    #[test]
11092    fn effective_floor_takes_the_strongest_across_every_presence_combination() {
11093        use ContainmentClass::{Advisory, Scoped, Sealed};
11094
11095        // Exactly one source present: that source wins outright.
11096        assert_eq!(
11097            effective_containment_floor(Some(Scoped), None, None),
11098            Scoped
11099        );
11100        assert_eq!(
11101            effective_containment_floor(None, Some(Scoped), None),
11102            Scoped
11103        );
11104        assert_eq!(
11105            effective_containment_floor(None, None, Some(Scoped)),
11106            Scoped
11107        );
11108        assert_eq!(
11109            effective_containment_floor(Some(Sealed), None, None),
11110            Sealed
11111        );
11112        assert_eq!(
11113            effective_containment_floor(None, Some(Sealed), None),
11114            Sealed
11115        );
11116        assert_eq!(
11117            effective_containment_floor(None, None, Some(Sealed)),
11118            Sealed
11119        );
11120
11121        // Two sources present: the stronger wins regardless of which slot holds it, and a
11122        // weaker source can never pull the stronger one down.
11123        assert_eq!(
11124            effective_containment_floor(Some(Advisory), Some(Sealed), None),
11125            Sealed
11126        );
11127        assert_eq!(
11128            effective_containment_floor(Some(Sealed), Some(Advisory), None),
11129            Sealed
11130        );
11131        assert_eq!(
11132            effective_containment_floor(Some(Advisory), None, Some(Scoped)),
11133            Scoped
11134        );
11135        assert_eq!(
11136            effective_containment_floor(Some(Scoped), None, Some(Advisory)),
11137            Scoped
11138        );
11139        assert_eq!(
11140            effective_containment_floor(None, Some(Advisory), Some(Scoped)),
11141            Scoped
11142        );
11143        assert_eq!(
11144            effective_containment_floor(None, Some(Scoped), Some(Advisory)),
11145            Scoped
11146        );
11147
11148        // All three present: max wins from every slot, and all-equal is a fixed point.
11149        assert_eq!(
11150            effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
11151            Sealed
11152        );
11153        assert_eq!(
11154            effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
11155            Sealed
11156        );
11157        assert_eq!(
11158            effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
11159            Sealed
11160        );
11161        assert_eq!(
11162            effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
11163            Scoped
11164        );
11165        assert_eq!(
11166            effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
11167            Advisory
11168        );
11169    }
11170
11171    #[test]
11172    fn manifest_parses_each_declared_containment_class() {
11173        for class in ContainmentClass::ALL {
11174            let manifest = RuntimeManifest::from_yaml_str(&format!(
11175                r#"
11176name: cap
11177version: 0.0.1
11178capabilities:
11179  containment: {class}
11180"#
11181            ))
11182            .expect("a valid containment class parses");
11183
11184            assert_eq!(
11185                manifest.capabilities.unwrap().containment,
11186                Some(class),
11187                "capabilities.containment: {class}"
11188            );
11189        }
11190    }
11191
11192    #[test]
11193    fn manifest_without_containment_key_declares_nothing() {
11194        // Absent `capabilities:` block entirely.
11195        let manifest = RuntimeManifest::from_yaml_str(
11196            r#"
11197name: cap
11198version: 0.0.1
11199"#,
11200        )
11201        .unwrap();
11202        assert!(manifest.capabilities.is_none());
11203
11204        // Present `capabilities:` block that simply omits the key: still None, never a
11205        // silently-defaulted Advisory floor stored as if the operator had written it.
11206        let manifest = RuntimeManifest::from_yaml_str(
11207            r#"
11208name: cap
11209version: 0.0.1
11210capabilities:
11211  network:
11212    allow:
11213      - https://api.example.com
11214"#,
11215        )
11216        .unwrap();
11217        assert_eq!(manifest.capabilities.unwrap().containment, None);
11218    }
11219
11220    #[test]
11221    fn manifest_rejects_an_unknown_containment_class() {
11222        let err = RuntimeManifest::from_yaml_str(
11223            r#"
11224name: cap
11225version: 0.0.1
11226capabilities:
11227  containment: paranoid
11228"#,
11229        )
11230        .expect_err("an unknown containment class is rejected at parse time");
11231
11232        match err {
11233            RuntimeManifestError::InvalidCapabilities { field, message } => {
11234                assert_eq!(field, "capabilities.containment");
11235                assert_eq!(
11236                    message,
11237                    "must be one of: advisory, scoped, sealed; got 'paranoid'"
11238                );
11239            }
11240            other => panic!("expected InvalidCapabilities, got {other:?}"),
11241        }
11242    }
11243
11244    // ── exports.files ────────────────────────────────────────────────────────
11245
11246    fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
11247        RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
11248    }
11249
11250    fn export_error(block: &str) -> (String, String) {
11251        match exports_manifest(block).expect_err("this exports block must not parse") {
11252            RuntimeManifestError::InvalidExports { field, message } => (field, message),
11253            other => panic!("expected InvalidExports, got {other:?}"),
11254        }
11255    }
11256
11257    fn peer_fetch_error(block: &str) -> (String, String) {
11258        match exports_manifest(block).expect_err("this capabilities block must not parse") {
11259            RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
11260            other => panic!("expected InvalidCapabilities, got {other:?}"),
11261        }
11262    }
11263
11264    // ── exports.peer_files ───────────────────────────────────────────────────
11265
11266    #[test]
11267    fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
11268        assert_eq!(parse_duration_secs("90").unwrap(), 90);
11269        assert_eq!(parse_duration_secs("30s").unwrap(), 30);
11270        assert_eq!(parse_duration_secs("15m").unwrap(), 900);
11271        assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
11272        assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
11273        assert_eq!(parse_duration_secs("  2h  ").unwrap(), 7200);
11274        assert_eq!(parse_duration_secs("0").unwrap(), 0);
11275    }
11276
11277    /// Every spelling that is not the one accepted form, refused rather than guessed at: a
11278    /// manifest meaning 5 seconds and one meaning 5 minutes must not both parse.
11279    #[test]
11280    fn parse_duration_secs_rejects_every_other_spelling() {
11281        for input in [
11282            "",
11283            "  ",
11284            "5 minutes",
11285            "30S",
11286            "1H",
11287            "5min",
11288            "1.5h",
11289            "-1",
11290            "m",
11291            "h",
11292            "1h30m",
11293            "0x10",
11294            "30 s",
11295        ] {
11296            let error = parse_duration_secs(input)
11297                .expect_err(&format!("'{input}' must not parse as a duration"));
11298            assert!(
11299                error.contains(DURATION_ACCEPTED_FORM),
11300                "'{input}' must be refused with the accepted form; got: {error}"
11301            );
11302        }
11303    }
11304
11305    #[test]
11306    fn parse_duration_secs_refuses_to_overflow() {
11307        assert!(parse_duration_secs("18446744073709551615h")
11308            .unwrap_err()
11309            .contains("overflows"));
11310    }
11311
11312    #[test]
11313    fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
11314        let manifest = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
11315            .expect("a minimal peer_files block parses");
11316        let peer = manifest.exports.unwrap().peer_files.unwrap();
11317        assert_eq!(peer.root, "out/");
11318        assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
11319        assert_eq!(peer.max_bytes, 10_485_760);
11320        // Undeclared, not defaulted: the two ephemerality cases answer an absent value
11321        // differently, and substituting here would erase the difference.
11322        assert_eq!(peer.max_ttl_secs, None);
11323        assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
11324        assert_eq!(peer.effective_max_ttl_secs(), 3600);
11325    }
11326
11327    #[test]
11328    fn peer_files_accepts_every_duration_spelling() {
11329        for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
11330            let manifest = exports_manifest(&format!(
11331                "exports:\n  peer_files:\n    root: out/\n    max_ttl: {declared}\n"
11332            ))
11333            .expect("a declared max_ttl parses");
11334            let peer = manifest.exports.unwrap().peer_files.unwrap();
11335            assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
11336            assert_eq!(peer.effective_max_ttl_secs(), expected);
11337        }
11338    }
11339
11340    /// The two export blocks are separate authorisers: declaring one says nothing about the
11341    /// other, and both may be declared side by side over different subtrees.
11342    #[test]
11343    fn the_two_export_blocks_are_independent() {
11344        let both = exports_manifest(
11345            "exports:\n  files:\n    root: out/\n    mode: read-only\n  \
11346             peer_files:\n    root: out/handoff/\n",
11347        )
11348        .expect("both blocks parse together")
11349        .exports
11350        .unwrap();
11351        assert_eq!(both.files.unwrap().root, "out/");
11352        assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
11353
11354        let only_files =
11355            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
11356                .unwrap()
11357                .exports
11358                .unwrap();
11359        assert!(only_files.peer_files.is_none());
11360
11361        let only_peer = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
11362            .unwrap()
11363            .exports
11364            .unwrap();
11365        assert!(only_peer.files.is_none());
11366
11367        let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
11368        assert!(neither.files.is_none() && neither.peer_files.is_none());
11369    }
11370
11371    #[test]
11372    fn peer_files_root_must_be_relative_and_inside_the_workdir() {
11373        for root in ["../out", "/etc", "out/../../etc"] {
11374            let (field, message) =
11375                export_error(&format!("exports:\n  peer_files:\n    root: {root}\n"));
11376            assert_eq!(field, "exports.peer_files.root", "root: {root}");
11377            assert!(
11378                message.contains(EXPORT_ROOT_ACCEPTED_FORM),
11379                "root: {root}; got: {message}"
11380            );
11381        }
11382    }
11383
11384    #[test]
11385    fn peer_files_root_is_required() {
11386        let (field, message) = export_error("exports:\n  peer_files: {}\n");
11387        assert_eq!(field, "exports.peer_files.root");
11388        assert!(message.contains("is required"));
11389        assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
11390    }
11391
11392    #[test]
11393    fn peer_files_max_ttl_states_its_accepted_form() {
11394        for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
11395            let (field, message) = export_error(&format!(
11396                "exports:\n  peer_files:\n    root: out/\n    max_ttl: \"{declared}\"\n"
11397            ));
11398            assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
11399            assert!(
11400                message.contains(DURATION_ACCEPTED_FORM),
11401                "max_ttl: {declared}; got: {message}"
11402            );
11403        }
11404    }
11405
11406    /// A zero ceiling reads like a declared surface and refuses every mint. Better discovered at
11407    /// the line that wrote it than at the first `share-file` call.
11408    #[test]
11409    fn peer_files_max_ttl_rejects_zero() {
11410        let (field, message) =
11411            export_error("exports:\n  peer_files:\n    root: out/\n    max_ttl: 0\n");
11412        assert_eq!(field, "exports.peer_files.max_ttl");
11413        assert!(message.contains("greater than zero"));
11414    }
11415
11416    #[test]
11417    fn peer_files_max_bytes_states_its_accepted_form() {
11418        let (field, message) =
11419            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 10MB\n");
11420        assert_eq!(field, "exports.peer_files.max_bytes");
11421        assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
11422    }
11423
11424    #[test]
11425    fn peer_files_max_bytes_rejects_zero() {
11426        let (field, message) =
11427            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 0\n");
11428        assert_eq!(field, "exports.peer_files.max_bytes");
11429        assert!(message.contains("greater than zero"));
11430    }
11431
11432    // ── exports.peer_tasks ───────────────────────────────────────────────────
11433
11434    /// Absent and `accept: false` are the same posture; only an explicit `true` consents.
11435    #[test]
11436    fn peer_tasks_consents_only_on_an_explicit_true() {
11437        let absent = exports_manifest("artifacts: []\n").expect("no exports block parses");
11438        assert!(absent.exports.is_none());
11439        assert!(!absent.accepts_peer_tasks());
11440
11441        let refused = exports_manifest("exports:\n  peer_tasks:\n    accept: false\n")
11442            .expect("an explicit refusal parses");
11443        assert_eq!(
11444            refused.exports.as_ref().unwrap().peer_tasks,
11445            Some(PeerTasksExport { accept: false })
11446        );
11447        assert!(!refused.accepts_peer_tasks());
11448        assert!(!refused.exports.as_ref().unwrap().accepts_peer_tasks());
11449
11450        let accepted = exports_manifest("exports:\n  peer_tasks:\n    accept: true\n")
11451            .expect("consent parses");
11452        assert_eq!(
11453            accepted.exports.as_ref().unwrap().peer_tasks,
11454            Some(PeerTasksExport { accept: true })
11455        );
11456        assert!(accepted.accepts_peer_tasks());
11457        assert!(accepted.exports.as_ref().unwrap().accepts_peer_tasks());
11458    }
11459
11460    /// An `exports:` block declaring only `peer_tasks` opens neither file plane.
11461    #[test]
11462    fn peer_tasks_alone_declares_no_file_plane() {
11463        let manifest = exports_manifest("exports:\n  peer_tasks:\n    accept: true\n")
11464            .expect("a peer_tasks-only exports block parses");
11465        let exports = manifest.exports.unwrap();
11466        assert_eq!(exports.files, None);
11467        assert_eq!(exports.peer_files, None);
11468    }
11469
11470    /// A file export says nothing about peer tasks.
11471    #[test]
11472    fn a_file_export_does_not_consent_to_peer_tasks() {
11473        let manifest =
11474            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
11475                .expect("a files export parses");
11476        assert_eq!(manifest.exports.as_ref().unwrap().peer_tasks, None);
11477        assert!(!manifest.accepts_peer_tasks());
11478    }
11479
11480    /// Consent is never inferred: a `peer_tasks:` block without `accept:` is refused naming the
11481    /// key, as `capabilities.plan` without `submit:` is.
11482    #[test]
11483    fn peer_tasks_without_accept_is_refused() {
11484        let (field, message) = export_error("exports:\n  peer_tasks: {}\n");
11485        assert_eq!(field, "exports.peer_tasks.accept");
11486        assert_eq!(
11487            message,
11488            "must be set explicitly to true or false — consent to peer tasks is never inferred"
11489        );
11490    }
11491
11492    /// A value of the wrong type is refused at parse, and the refusal names where it is.
11493    #[test]
11494    fn peer_tasks_of_the_wrong_type_is_refused_naming_the_field() {
11495        for (block, field) in [
11496            (
11497                "exports:\n  peer_tasks:\n    accept: \"yes\"\n",
11498                "exports.peer_tasks.accept",
11499            ),
11500            (
11501                "exports:\n  peer_tasks:\n    accept: 1\n",
11502                "exports.peer_tasks.accept",
11503            ),
11504            ("exports:\n  peer_tasks: true\n", "exports.peer_tasks"),
11505            ("exports:\n  peer_tasks: [peer-a]\n", "exports.peer_tasks"),
11506        ] {
11507            let message = exports_manifest(block)
11508                .expect_err(&format!("{block:?} must not parse"))
11509                .to_string();
11510            assert!(message.contains(field), "{block:?}: {message}");
11511        }
11512    }
11513
11514    /// An unknown key under the block is a warning reported by path, not a refusal — and naming
11515    /// a peer there grants nothing.
11516    #[test]
11517    fn an_unknown_peer_tasks_key_is_reported_by_path() {
11518        let manifest =
11519            exports_manifest("exports:\n  peer_tasks:\n    accept: false\n    allow: [peer-a]\n")
11520                .expect("an unknown key is a warning, not a parse error");
11521        assert!(
11522            manifest
11523                .unknown_keys
11524                .iter()
11525                .any(|key| key.block_path == "exports.peer_tasks" && key.key == "allow"),
11526            "{:?}",
11527            manifest.unknown_keys
11528        );
11529        assert!(!manifest.accepts_peer_tasks());
11530    }
11531
11532    // ── capabilities.peer_fetch ──────────────────────────────────────────────
11533
11534    #[test]
11535    fn peer_fetch_allow_carries_its_declared_destinations() {
11536        let manifest = exports_manifest(
11537            "capabilities:\n  peer_fetch:\n    allow:\n      - localhost:41234\n      \
11538             - reporting.internal\n      - https://gateway.example.com\n",
11539        )
11540        .expect("a peer_fetch block parses");
11541        assert_eq!(
11542            manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
11543            vec![
11544                "localhost:41234".to_string(),
11545                "reporting.internal".to_string(),
11546                "https://gateway.example.com".to_string(),
11547            ]
11548        );
11549    }
11550
11551    /// An empty list is a parse error rather than a silent deny: `allow: []` reads as a declared
11552    /// grant and would otherwise refuse every fetch with no line to point at.
11553    #[test]
11554    fn peer_fetch_allow_must_not_be_empty() {
11555        let (field, message) = peer_fetch_error("capabilities:\n  peer_fetch:\n    allow: []\n");
11556        assert_eq!(field, "capabilities.peer_fetch.allow");
11557        assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
11558    }
11559
11560    #[test]
11561    fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
11562        for entry in [
11563            "http://[not a host",
11564            "\"\"",
11565            "localhost:notaport",
11566            "http://example.com/path",
11567            "ftp://example.com",
11568        ] {
11569            let (field, message) = peer_fetch_error(&format!(
11570                "capabilities:\n  peer_fetch:\n    allow:\n      - {entry}\n"
11571            ));
11572            assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
11573            assert!(
11574                message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
11575                "entry: {entry}; got: {message}"
11576            );
11577        }
11578    }
11579
11580    /// `peer_fetch` is a separate authoriser and never an alias: declaring it leaves
11581    /// `network.allow` exactly as the manifest wrote it, and vice versa.
11582    #[test]
11583    fn peer_fetch_does_not_widen_network_allow() {
11584        let capabilities = exports_manifest(
11585            "capabilities:\n  network:\n    allow:\n      - api.example.com\n  \
11586             peer_fetch:\n    allow:\n      - localhost:41234\n",
11587        )
11588        .unwrap()
11589        .capabilities
11590        .unwrap();
11591        assert_eq!(
11592            capabilities.network.unwrap().allow,
11593            vec!["api.example.com".to_string()]
11594        );
11595        assert_eq!(
11596            capabilities.peer_fetch.unwrap().allow,
11597            vec!["localhost:41234".to_string()]
11598        );
11599    }
11600
11601    #[test]
11602    fn an_absent_peer_fetch_block_is_absent() {
11603        assert!(exports_manifest("capabilities: {}\n")
11604            .unwrap()
11605            .capabilities
11606            .unwrap()
11607            .peer_fetch
11608            .is_none());
11609    }
11610
11611    #[test]
11612    fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
11613        assert_eq!(parse_byte_size("0").unwrap(), 0);
11614        assert_eq!(parse_byte_size("4096").unwrap(), 4096);
11615        assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
11616        assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
11617        assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
11618        assert_eq!(parse_byte_size("  10Mi  ").unwrap(), 10 * 1024 * 1024);
11619    }
11620
11621    /// Decimal suffixes are refused rather than guessed at: a manifest meaning 10 000 000 and one
11622    /// meaning 10 485 760 must not both parse.
11623    #[test]
11624    fn parse_byte_size_rejects_every_other_spelling() {
11625        for input in [
11626            "", "  ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
11627            "1Ki1",
11628        ] {
11629            let error = parse_byte_size(input)
11630                .expect_err(&format!("'{input}' must not parse as a byte size"));
11631            assert!(
11632                error.contains(BYTE_SIZE_ACCEPTED_FORM),
11633                "'{input}' must be refused with the accepted form; got: {error}"
11634            );
11635        }
11636    }
11637
11638    #[test]
11639    fn parse_byte_size_refuses_to_overflow() {
11640        assert!(parse_byte_size("18446744073709551615Gi")
11641            .unwrap_err()
11642            .contains("overflows"));
11643    }
11644
11645    #[test]
11646    fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
11647        let manifest =
11648            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
11649                .expect("a minimal exports block parses");
11650        let files = manifest.exports.unwrap().files.unwrap();
11651        assert_eq!(files.root, "out/");
11652        assert_eq!(files.mode, ExportMode::ReadOnly);
11653        assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
11654        assert_eq!(files.max_bytes, 10_485_760);
11655    }
11656
11657    #[test]
11658    fn exports_files_accepts_both_byte_spellings() {
11659        for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
11660            let manifest = exports_manifest(&format!(
11661                "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: {declared}\n"
11662            ))
11663            .expect("a declared max_bytes parses");
11664            assert_eq!(
11665                manifest.exports.unwrap().files.unwrap().max_bytes,
11666                expected,
11667                "max_bytes: {declared}"
11668            );
11669        }
11670    }
11671
11672    /// An `exports:` block with no `files:` is not a resource plane. It parses, and the plane
11673    /// stays undeclared — which is the deny case, not a defaulted-open one.
11674    #[test]
11675    fn an_exports_block_without_files_declares_no_plane() {
11676        let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
11677        assert!(manifest.exports.unwrap().files.is_none());
11678    }
11679
11680    #[test]
11681    fn an_absent_exports_block_is_absent() {
11682        let manifest = exports_manifest("").expect("a manifest without exports parses");
11683        assert!(manifest.exports.is_none());
11684    }
11685
11686    #[test]
11687    fn exports_files_root_must_be_relative_and_inside_the_workdir() {
11688        for root in ["../out", "/etc", "out/../../etc", "''"] {
11689            let (field, message) = export_error(&format!(
11690                "exports:\n  files:\n    root: {root}\n    mode: read-only\n"
11691            ));
11692            assert_eq!(field, "exports.files.root", "root: {root}");
11693            assert!(
11694                message.contains("must be a relative path inside the workdir"),
11695                "root: {root}; got: {message}"
11696            );
11697        }
11698    }
11699
11700    #[test]
11701    fn exports_files_root_is_required() {
11702        let (field, message) = export_error("exports:\n  files:\n    mode: read-only\n");
11703        assert_eq!(field, "exports.files.root");
11704        assert_eq!(
11705            message,
11706            "is required and must be a relative path inside the workdir"
11707        );
11708    }
11709
11710    #[test]
11711    fn exports_files_mode_is_required_and_read_only() {
11712        let (field, message) = export_error("exports:\n  files:\n    root: out/\n");
11713        assert_eq!(field, "exports.files.mode");
11714        assert_eq!(message, "is required and must be 'read-only'");
11715
11716        let (field, message) =
11717            export_error("exports:\n  files:\n    root: out/\n    mode: read-write\n");
11718        assert_eq!(field, "exports.files.mode");
11719        assert_eq!(message, "'read-write' must be 'read-only'");
11720    }
11721
11722    #[test]
11723    fn exports_files_max_bytes_states_its_accepted_form() {
11724        let (field, message) = export_error(
11725            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 10MB\n",
11726        );
11727        assert_eq!(field, "exports.files.max_bytes");
11728        assert_eq!(
11729            message,
11730            "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
11731        );
11732    }
11733
11734    /// A zero ceiling would refuse every read while reading like a declared export, so it is
11735    /// refused where it is written rather than at the first request.
11736    #[test]
11737    fn exports_files_max_bytes_rejects_zero() {
11738        let (field, message) = export_error(
11739            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 0\n",
11740        );
11741        assert_eq!(field, "exports.files.max_bytes");
11742        assert!(message.contains("must be greater than zero"), "{message}");
11743    }
11744
11745    /// A leading `./` is a relative path like any other and is not an escape.
11746    #[test]
11747    fn exports_files_root_accepts_a_current_dir_prefix() {
11748        let manifest =
11749            exports_manifest("exports:\n  files:\n    root: ./out\n    mode: read-only\n")
11750                .expect("./out is a legal root");
11751        assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
11752    }
11753
11754    // ── Unrecognized manifest keys (W-SEC-019) ────────────────────────────────
11755
11756    fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
11757        RuntimeManifest::from_yaml_str(yaml)
11758            .expect("fixture must parse")
11759            .unknown_keys
11760            .into_iter()
11761            .map(|key| (key.key, key.block_path, key.nearest_known))
11762            .collect()
11763    }
11764
11765    /// The typo the whole warning exists for: a hyphen where the key takes an underscore, named
11766    /// with the block that held it and with the key it should have been.
11767    #[test]
11768    fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
11769        assert_eq!(
11770            unknown_keys_of(
11771                "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    read-only:\n      - tests\n"
11772            ),
11773            vec![(
11774                "read-only".to_string(),
11775                "capabilities.filesystem".to_string(),
11776                Some("read_only".to_string())
11777            )]
11778        );
11779    }
11780
11781    /// A manifest exercising every layer the walk descends through — capsule-wide capabilities,
11782    /// a per-artifact block, shell, network and an artifact entry — reports nothing. A warning
11783    /// that fires on correct input is noise, and noise is ignored.
11784    #[test]
11785    fn a_manifest_of_recognized_keys_reports_nothing() {
11786        assert!(unknown_keys_of(
11787            r#"
11788name: cap
11789version: 0.1.0
11790mur_version: "0.2.0"
11791artifacts:
11792  - name: notes-tool
11793    version: 0.1.0
11794    runtime: tool
11795    capabilities:
11796      filesystem:
11797        scope: notes
11798      network:
11799        allow:
11800          - example.com:443
11801capabilities:
11802  filesystem:
11803    read_only:
11804      - tests
11805      - bench/fixtures
11806    workdir_exec: false
11807  shell:
11808    allow:
11809      - git
11810      - python3
11811  network:
11812    allow:
11813      - example.com:443
11814    unix_sockets: false
11815"#
11816        )
11817        .is_empty());
11818    }
11819
11820    /// A per-artifact capability block reports under its artifact's index, so an operator is not
11821    /// sent to the capsule-wide block for a key they wrote on one entry.
11822    #[test]
11823    fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
11824        assert_eq!(
11825            unknown_keys_of(
11826                "name: cap\nversion: 0.1.0\nartifacts:\n  - name: t\n    version: 0.1.0\n    \
11827                 runtime: tool\n    capabilities:\n      shell:\n        allow:\n          - git\n        \
11828                 allwo:\n          - git\n"
11829            ),
11830            vec![(
11831                "allwo".to_string(),
11832                "artifacts[0].capabilities.shell".to_string(),
11833                Some("allow".to_string())
11834            )]
11835        );
11836    }
11837
11838    /// A top-level key with no near neighbour reports an empty path and no suggestion — the two
11839    /// facts the emitter needs to word it as a possibly-newer key rather than a misspelling.
11840    #[test]
11841    fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
11842        assert_eq!(
11843            unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
11844            vec![("quantum_teleport".to_string(), String::new(), None)]
11845        );
11846    }
11847
11848    /// The minimum publishable delegated sub-capsule — `runtime:` plus `execution:` — reports
11849    /// nothing. Every registrant of a delegating capsule must be published, so every one of them
11850    /// carries these keys; warning on them would make the diagnostic unreadable exactly where it
11851    /// is needed most.
11852    #[test]
11853    fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
11854        assert!(unknown_keys_of(
11855            "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
11856        )
11857        .is_empty());
11858    }
11859
11860    /// All four keys `mur build` and `mur publish` read beyond `name:` and `version:`, together.
11861    #[test]
11862    fn every_build_side_key_is_recognized() {
11863        assert!(unknown_keys_of(
11864            "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
11865             wasm\nrequires_files:\n  - tool.wasm\n  - assets/logo.png\n"
11866        )
11867        .is_empty());
11868    }
11869
11870    /// `W-SEC-019` still covers every key no command reads: one sitting beside the four
11871    /// build-side keys reports, and reports alone.
11872    #[test]
11873    fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
11874        assert_eq!(
11875            unknown_keys_of(
11876                "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
11877                 static\nrequires_files:\n  - capsule.wasm\nquantum_teleport: true\n"
11878            ),
11879            vec![("quantum_teleport".to_string(), String::new(), None)]
11880        );
11881    }
11882
11883    /// The four keys are recognized and then dropped: they add no refusal and no run-time meaning,
11884    /// so a manifest carrying them parses to what it parses to without them. A wrong-shaped value
11885    /// is build's business to reject, not the run path's.
11886    #[test]
11887    fn the_build_side_keys_change_nothing_the_run_path_reads() {
11888        let body = "artifacts:\n  - name: notes-tool\n    version: 0.1.0\n    runtime: tool\n\
11889                    \x20 - name: murmur-driver-anthropic\n    version: 0.1.0\n    runtime: \
11890                    driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\n\
11891                    capabilities:\n  filesystem:\n    read_only:\n      - tests\n\
11892                    inference:\n  transport: http\n  model: test-model\n  driver:\n    \
11893                    artifact: murmur-driver-anthropic\n";
11894        let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
11895            .expect("fixture must parse");
11896        let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
11897            "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n  \
11898             any: shape\nrequires_files: not-a-list\n{body}"
11899        ))
11900        .expect("a build-side key of any shape never refuses a manifest");
11901
11902        assert!(with_build_keys.unknown_keys.is_empty());
11903        assert!(
11904            !plain.artifacts.is_empty()
11905                && plain.capabilities.is_some()
11906                && plain.inference.is_some(),
11907            "the fixture must populate the blocks the comparison below rests on, or it \
11908             compares absence against absence"
11909        );
11910        assert_eq!(with_build_keys.name, plain.name);
11911        assert_eq!(with_build_keys.version, plain.version);
11912        assert_eq!(with_build_keys.artifacts, plain.artifacts);
11913        assert_eq!(with_build_keys.capabilities, plain.capabilities);
11914        assert_eq!(with_build_keys.inference, plain.inference);
11915    }
11916
11917    /// The overflow map captures the key without consuming the block: the rest of it still parses
11918    /// and still applies. A capsule with an unrecognized key launches.
11919    #[test]
11920    fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
11921        let manifest = RuntimeManifest::from_yaml_str(
11922            "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    scope: notes\n    \
11923             read-only:\n      - tests\n",
11924        )
11925        .expect("an unrecognized key never refuses a manifest");
11926        assert_eq!(
11927            manifest
11928                .capabilities
11929                .as_ref()
11930                .and_then(|caps| caps.filesystem.as_ref())
11931                .and_then(|fs| fs.scope.as_deref()),
11932            Some("notes")
11933        );
11934        assert_eq!(manifest.unknown_keys.len(), 1);
11935    }
11936
11937    /// Every block on the way down is walked, not just the capability sub-blocks.
11938    #[test]
11939    fn nested_blocks_outside_capabilities_are_walked_too() {
11940        let keys = unknown_keys_of(
11941            "name: cap\nversion: 0.1.0\ncontext:\n  max_tokns: 100\nexports:\n  files:\n    \
11942             root: out\n    mode: read-only\n    max_byts: 10\n",
11943        );
11944        assert_eq!(
11945            keys,
11946            vec![
11947                (
11948                    "max_tokns".to_string(),
11949                    "context".to_string(),
11950                    Some("max_tokens".to_string())
11951                ),
11952                (
11953                    "max_byts".to_string(),
11954                    "exports.files".to_string(),
11955                    Some("max_bytes".to_string())
11956                ),
11957            ]
11958        );
11959    }
11960
11961    /// Source-derived, so a `Raw*` struct added later cannot skip the overflow map, and a field
11962    /// added to one cannot skip its `KNOWN_KEYS`: the suggester would then never propose it.
11963    ///
11964    /// Reads this file's own text rather than a hand-written list for the same reason the
11965    /// `CapabilityPolicy` coverage test in `capsule-runtime` does — a list maintained by hand
11966    /// records what someone remembered, not what the code says.
11967    #[test]
11968    fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
11969        let source = include_str!("runtime_manifest.rs");
11970        let lines: Vec<&str> = source.lines().collect();
11971
11972        let mut checked = 0usize;
11973        for (start, line) in lines.iter().enumerate() {
11974            let Some(name) = line
11975                .strip_prefix("struct Raw")
11976                .and_then(|rest| rest.strip_suffix(" {"))
11977            else {
11978                continue;
11979            };
11980            let name = format!("Raw{name}");
11981            let end = (start + 1..lines.len())
11982                .find(|index| lines[*index] == "}")
11983                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
11984            let body = &lines[start + 1..end];
11985
11986            assert!(
11987                body.contains(&"    #[serde(flatten)]")
11988                    && body.contains(&"    unknown: UnknownKeys,"),
11989                "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
11990                 so a key it does not recognize would be dropped instead of reported"
11991            );
11992
11993            let declared = serde_field_names(body);
11994            let known = known_keys_of(source, &name);
11995            assert_eq!(
11996                declared, known,
11997                "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
11998                 suggester would propose the wrong set for that block"
11999            );
12000            checked += 1;
12001        }
12002        assert!(
12003            checked >= 30,
12004            "expected every Raw* struct to be scanned, found only {checked}"
12005        );
12006    }
12007
12008    /// Source-derived for the same reason its sibling above is: a `Raw*`-typed field added to a
12009    /// block and left unwalked reports none of the keys inside that block, and reports nothing
12010    /// about having done so. The scan is keyed on the *parent's field*, not on the child's type,
12011    /// because one type can sit under several parents — `RawInferenceDriver` is both
12012    /// `inference.driver` and each alternate's `driver` — and a check keyed on the type would call
12013    /// one covered because the other is walked.
12014    #[test]
12015    fn every_raw_struct_descends_into_the_blocks_it_owns() {
12016        let source = include_str!("runtime_manifest.rs");
12017        let lines: Vec<&str> = source.lines().collect();
12018
12019        let mut structs = 0usize;
12020        let mut children = 0usize;
12021        for (start, line) in lines.iter().enumerate() {
12022            let Some(name) = line
12023                .strip_prefix("struct Raw")
12024                .and_then(|rest| rest.strip_suffix(" {"))
12025            else {
12026                continue;
12027            };
12028            let name = format!("Raw{name}");
12029            let end = (start + 1..lines.len())
12030                .find(|index| lines[*index] == "}")
12031                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
12032            let owned = block_typed_fields(&lines[start + 1..end]);
12033            structs += 1;
12034
12035            if owned.is_empty() {
12036                continue;
12037            }
12038            let block = raw_block_impl_of(source, &name);
12039            assert!(
12040                block.contains("fn walk_children"),
12041                "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
12042                 `walk_children`, so every key written inside {} would go unreported",
12043                if owned.len() == 1 { "" } else { "s" },
12044                owned.join(", "),
12045                if owned.len() == 1 { "it" } else { "them" }
12046            );
12047            for field in &owned {
12048                assert!(
12049                    mentions_field(&block, field),
12050                    "{name}'s `walk_children` never touches `self.{field}`, so every key written \
12051                     inside that block would go unreported — descend into it with \
12052                     `collect_block(.., &child_path(path, \"{field}\"), out)`"
12053                );
12054                children += 1;
12055            }
12056        }
12057        assert!(
12058            structs >= 30,
12059            "expected every Raw* struct to be scanned, found only {structs}"
12060        );
12061        assert!(
12062            children >= 31,
12063            "expected every nested block field to be scanned, found only {children}"
12064        );
12065    }
12066
12067    /// Whether `impl` reads `self.<field>` as that whole field, not as the head of a longer one:
12068    /// a plain substring test lets a struct owning both `foo` and `foo_bar` walk only `foo_bar`
12069    /// and still be called covered, which is the silent pass this scan exists to deny.
12070    fn mentions_field(impl_body: &str, field: &str) -> bool {
12071        let needle = format!("self.{field}");
12072        impl_body.match_indices(&needle).any(|(at, _)| {
12073            impl_body[at + needle.len()..]
12074                .chars()
12075                .next()
12076                .is_none_or(|next| !next.is_alphanumeric() && next != '_')
12077        })
12078    }
12079
12080    /// The fields of one struct body whose declared type names another `Raw*` type, in
12081    /// declaration order. Comment and attribute lines are skipped, so a doc comment that mentions
12082    /// a `Raw*` type by name is not read as a field.
12083    fn block_typed_fields(body: &[&str]) -> Vec<String> {
12084        let mut fields = Vec::new();
12085        for line in body {
12086            let trimmed = line.trim();
12087            if trimmed.starts_with("//") || trimmed.starts_with("#[") {
12088                continue;
12089            }
12090            let Some((field, declared)) = line
12091                .strip_prefix("    ")
12092                .and_then(|rest| rest.split_once(':'))
12093            else {
12094                continue;
12095            };
12096            if field.is_empty()
12097                || !field
12098                    .chars()
12099                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
12100            {
12101                continue;
12102            }
12103            if declared.contains("Raw") {
12104                fields.push(field.to_string());
12105            }
12106        }
12107        fields
12108    }
12109
12110    /// The text of `impl RawBlock for <name>`, cut at the next line that is exactly `}` at column
12111    /// 0 so one impl's body cannot be read as the next one's.
12112    fn raw_block_impl_of(source: &str, name: &str) -> String {
12113        let header = format!("impl RawBlock for {name} {{");
12114        let body = source
12115            .split_once(&header)
12116            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
12117            .1;
12118        body.split_once("\n}\n")
12119            .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
12120            .0
12121            .to_string()
12122    }
12123
12124    /// The walk's own result, taken straight off a deserialized `RawRuntimeManifest`.
12125    ///
12126    /// Deliberately not routed through [`RuntimeManifest::from_yaml_str`]: that validates, and a
12127    /// fixture that names every block at once would have to satisfy every semantic rule as well
12128    /// to reach the walk. What is under test here is which blocks are descended into, which the
12129    /// validator has no part in.
12130    fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
12131        let raw: RawRuntimeManifest = serde_yaml::from_str(yaml).expect("fixture must parse");
12132        collect_unknown_keys(&raw)
12133            .into_iter()
12134            .map(|key| (key.key, key.block_path))
12135            .collect()
12136    }
12137
12138    /// Every block the manifest type owns, each carrying one key no field of it claims, reported
12139    /// once and at its own dotted path. A block added after this one is held to the same rule by
12140    /// `every_raw_struct_descends_into_the_blocks_it_owns`; this is what "descended into" means.
12141    #[test]
12142    fn every_block_reports_its_own_unknown_key_at_its_own_path() {
12143        let keys = raw_unknown_keys(
12144            r#"
12145name: every-block
12146version: 0.1.0
12147probe_root: 1
12148artifacts:
12149  - name: t
12150    version: 0.1.0
12151    runtime: tool
12152    probe_artifact: 1
12153    capabilities:
12154      probe_artifact_capabilities: 1
12155      shell:
12156        probe_artifact_shell: 1
12157capabilities:
12158  probe_capabilities: 1
12159  network:
12160    probe_network: 1
12161  peer_fetch:
12162    probe_peer_fetch: 1
12163  filesystem:
12164    probe_filesystem: 1
12165  shell:
12166    probe_shell: 1
12167    interpreter_runtime:
12168      - binary: python3
12169        probe_interpreter_runtime: 1
12170        dirs:
12171          - path: /usr/lib/python3
12172            probe_interpreter_runtime_dir: 1
12173    staged_runtime:
12174      - binary: rg
12175        probe_staged_runtime: 1
12176  spawn:
12177    probe_spawn: 1
12178  env:
12179    probe_env: 1
12180  limits:
12181    probe_limits: 1
12182  resources:
12183    probe_resources: 1
12184  state:
12185    probe_state: 1
12186  task_io:
12187    probe_task_io: 1
12188  conversation:
12189    probe_conversation: 1
12190  install:
12191    probe_install: 1
12192inference:
12193  probe_inference: 1
12194  driver:
12195    probe_driver: 1
12196  compaction:
12197    probe_compaction: 1
12198context:
12199  probe_context: 1
12200observability:
12201  probe_observability: 1
12202  eval:
12203    probe_eval: 1
12204    scorers:
12205      - type: exact_match
12206        probe_scorer: 1
12207trace:
12208  probe_trace: 1
12209network:
12210  probe_network_config: 1
12211lifecycle:
12212  probe_lifecycle: 1
12213exports:
12214  probe_exports: 1
12215  files:
12216    probe_files: 1
12217  peer_files:
12218    probe_peer_files: 1
12219control:
12220  probe_control: 1
12221"#,
12222        );
12223
12224        let expected: Vec<(String, String)> = [
12225            ("probe_root", ""),
12226            ("probe_artifact", "artifacts[0]"),
12227            ("probe_artifact_capabilities", "artifacts[0].capabilities"),
12228            ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
12229            ("probe_capabilities", "capabilities"),
12230            ("probe_network", "capabilities.network"),
12231            ("probe_peer_fetch", "capabilities.peer_fetch"),
12232            ("probe_filesystem", "capabilities.filesystem"),
12233            ("probe_shell", "capabilities.shell"),
12234            (
12235                "probe_interpreter_runtime",
12236                "capabilities.shell.interpreter_runtime[0]",
12237            ),
12238            (
12239                "probe_interpreter_runtime_dir",
12240                "capabilities.shell.interpreter_runtime[0].dirs[0]",
12241            ),
12242            (
12243                "probe_staged_runtime",
12244                "capabilities.shell.staged_runtime[0]",
12245            ),
12246            ("probe_spawn", "capabilities.spawn"),
12247            ("probe_env", "capabilities.env"),
12248            ("probe_limits", "capabilities.limits"),
12249            ("probe_resources", "capabilities.resources"),
12250            ("probe_state", "capabilities.state"),
12251            ("probe_task_io", "capabilities.task_io"),
12252            ("probe_conversation", "capabilities.conversation"),
12253            ("probe_install", "capabilities.install"),
12254            ("probe_inference", "inference"),
12255            ("probe_driver", "inference.driver"),
12256            ("probe_compaction", "inference.compaction"),
12257            ("probe_context", "context"),
12258            ("probe_observability", "observability"),
12259            ("probe_eval", "observability.eval"),
12260            ("probe_scorer", "observability.eval.scorers[0]"),
12261            ("probe_trace", "trace"),
12262            ("probe_network_config", "network"),
12263            ("probe_lifecycle", "lifecycle"),
12264            ("probe_exports", "exports"),
12265            ("probe_files", "exports.files"),
12266            ("probe_peer_files", "exports.peer_files"),
12267            ("probe_control", "control"),
12268        ]
12269        .into_iter()
12270        .map(|(key, path)| (key.to_string(), path.to_string()))
12271        .collect();
12272
12273        assert_eq!(keys, expected);
12274    }
12275
12276    /// The names serde matches on for one struct body: field names in declaration order, with a
12277    /// `#[serde(rename = "...")]` field under its renamed spelling, and the overflow map itself
12278    /// excluded because it claims no name of its own.
12279    fn serde_field_names(body: &[&str]) -> Vec<String> {
12280        let mut names = Vec::new();
12281        let mut rename: Option<String> = None;
12282        for line in body {
12283            let trimmed = line.trim();
12284            if trimmed.starts_with("#[") {
12285                if let Some(rest) = trimmed.split("rename = \"").nth(1) {
12286                    if !trimmed.contains("rename_all") {
12287                        rename = rest.split('"').next().map(str::to_string);
12288                    }
12289                }
12290                continue;
12291            }
12292            if trimmed.starts_with("//") {
12293                continue;
12294            }
12295            let Some(field) = line
12296                .strip_prefix("    ")
12297                .and_then(|rest| rest.split(':').next())
12298            else {
12299                continue;
12300            };
12301            if field.is_empty()
12302                || !field
12303                    .chars()
12304                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
12305            {
12306                continue;
12307            }
12308            if field == "unknown" {
12309                rename = None;
12310                continue;
12311            }
12312            names.push(rename.take().unwrap_or_else(|| field.to_string()));
12313        }
12314        names
12315    }
12316
12317    /// The `KNOWN_KEYS` array `impl RawBlock for <name>` declares, read out of the source text so
12318    /// the assertion compares two independent readings of the same file rather than one value
12319    /// against itself.
12320    fn known_keys_of(source: &str, name: &str) -> Vec<String> {
12321        let header = format!("impl RawBlock for {name} {{");
12322        let body = source
12323            .split_once(&header)
12324            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
12325            .1;
12326        let array = body
12327            .split_once("KNOWN_KEYS: &'static [&'static str] = &[")
12328            .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
12329            .1
12330            .split_once("];")
12331            .expect("KNOWN_KEYS array is unterminated")
12332            .0;
12333        array
12334            .split('"')
12335            .skip(1)
12336            .step_by(2)
12337            .map(str::to_string)
12338            .collect()
12339    }
12340}
12341
12342#[cfg(test)]
12343mod control_tests {
12344    use super::*;
12345
12346    const TOOL_ENTRY: &str = "  - name: card-api\n    version: 0.1.0\n    runtime: tool\n    gateway:\n      endpoint: https://cards.example.com\n      api_key: ${CARD_TOKEN}\n";
12347
12348    /// An `http` agent capsule whose driver is keyed by `${DRIVER_KEY}` and whose `card-api` tool
12349    /// is keyed by `${CARD_TOKEN}`, with `control` spliced in at the top level.
12350    fn agent(transport: &str, control: &str) -> String {
12351        let driver = if transport == "http" {
12352            "  - name: drv\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.example.com\n      api_key: ${DRIVER_KEY}\n"
12353        } else {
12354            "  - name: drv\n    version: 0.1.0\n    runtime: driver\n"
12355        };
12356        format!(
12357            "name: cap\nversion: 0.0.1\nartifacts:\n{driver}{TOOL_ENTRY}inference:\n  transport: {transport}\n  model: m\n  driver:\n    artifact: drv\n{control}"
12358        )
12359    }
12360
12361    fn refusal(yaml: &str) -> (String, String) {
12362        match RuntimeManifest::from_yaml_str(yaml) {
12363            Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
12364            other => panic!("expected InvalidControl, got {other:?}"),
12365        }
12366    }
12367
12368    #[test]
12369    fn control_absent_empty_and_empty_lists_load_as_none() {
12370        for control in [
12371            "",
12372            "control: {}\n",
12373            "control:\n",
12374            "control:\n  settings: []\n  secrets: []\n",
12375        ] {
12376            let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
12377                .unwrap_or_else(|err| panic!("{control:?}: {err}"));
12378            assert_eq!(manifest.control, None, "{control:?}");
12379        }
12380    }
12381
12382    #[test]
12383    fn control_parses_settings_and_secrets_in_manifest_order() {
12384        let yaml = agent(
12385            "http",
12386            "control:\n  settings: [inference.max_tokens]\n  secrets: [CARD_TOKEN]\n",
12387        );
12388        for manifest in [
12389            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
12390            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
12391        ] {
12392            let control = manifest.control.unwrap();
12393            assert_eq!(
12394                control.settings,
12395                vec![ControllableSetting::InferenceMaxTokens]
12396            );
12397            assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
12398            assert!(control.declares_secret("CARD_TOKEN"));
12399            assert!(!control.declares_secret("DRIVER_KEY"));
12400        }
12401    }
12402
12403    #[test]
12404    fn control_setting_wire_names_round_trip() {
12405        for setting in ControllableSetting::ALL {
12406            assert_eq!(
12407                ControllableSetting::from_wire_name(setting.wire_name()),
12408                Some(*setting)
12409            );
12410        }
12411        assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
12412    }
12413
12414    /// Every refusal names `control.settings` or `control.secrets` as its field and quotes the
12415    /// entry it refused.
12416    #[test]
12417    fn control_manifest_refusals_name_the_field() {
12418        let cases: &[(&str, &str, &str, &str)] = &[
12419            (
12420                "http",
12421                "control:\n  settings: [inference.model]\n",
12422                "control.settings",
12423                "'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
12424            ),
12425            (
12426                "process",
12427                "control:\n  settings: [inference.max_tokens]\n",
12428                "control.settings",
12429                "'inference.max_tokens' is controllable only under inference.transport: http",
12430            ),
12431            (
12432                "http",
12433                "control:\n  settings: [inference.max_tokens, inference.max_tokens]\n",
12434                "control.settings",
12435                "'inference.max_tokens' is listed more than once",
12436            ),
12437            (
12438                "http",
12439                "control:\n  secrets: [CARD_TOKEN, CARD_TOKEN]\n",
12440                "control.secrets",
12441                "'CARD_TOKEN' is listed more than once",
12442            ),
12443            (
12444                "http",
12445                "control:\n  secrets: [card-token]\n",
12446                "control.secrets",
12447                "'card-token' is not a credential name",
12448            ),
12449            (
12450                "http",
12451                "control:\n  secrets: [OTHER_TOKEN]\n",
12452                "control.secrets",
12453                "'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
12454            ),
12455            (
12456                "http",
12457                "control:\n  secrets: [DRIVER_KEY]\n",
12458                "control.secrets",
12459                "'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.drv.gateway.api_key)",
12460            ),
12461            (
12462                "http",
12463                "control:\n  settings: inference.max_tokens\n",
12464                "control.settings",
12465                "must be a list of names",
12466            ),
12467            (
12468                "http",
12469                "control:\n  secrets: [1]\n",
12470                "control.secrets",
12471                "must be a list of names",
12472            ),
12473        ];
12474        for (transport, control, field, message) in cases {
12475            let (got_field, got_message) = refusal(&agent(transport, control));
12476            assert_eq!(&got_field, field, "{control:?}");
12477            assert!(
12478                got_message.starts_with(message),
12479                "{control:?}: {got_message}"
12480            );
12481            let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
12482                .unwrap_err()
12483                .to_string();
12484            assert!(
12485                rendered.contains(&format!("invalid control config for '{field}'")),
12486                "{rendered}"
12487            );
12488        }
12489    }
12490
12491    #[test]
12492    fn control_on_a_capsule_with_no_inference_is_refused() {
12493        let yaml = format!(
12494            "name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n  secrets: [CARD_TOKEN]\n"
12495        );
12496        let (field, message) = refusal(&yaml);
12497        assert_eq!(field, "control");
12498        assert!(message.contains("no inference: block"), "{message}");
12499    }
12500
12501    #[test]
12502    fn control_unknown_key_is_reported_once_at_its_block() {
12503        let manifest = RuntimeManifest::from_yaml_str(&agent(
12504            "http",
12505            "control:\n  settings: [inference.max_tokens]\n  agent_may_call: true\n",
12506        ))
12507        .unwrap();
12508        let keys: Vec<(&str, &str)> = manifest
12509            .unknown_keys
12510            .iter()
12511            .map(|key| (key.key.as_str(), key.block_path.as_str()))
12512            .collect();
12513        assert_eq!(keys, vec![("agent_may_call", "control")]);
12514        assert_eq!(
12515            manifest.control.unwrap().settings,
12516            vec![ControllableSetting::InferenceMaxTokens]
12517        );
12518    }
12519}
12520
12521#[cfg(test)]
12522mod driver_choice_tests {
12523    use super::*;
12524
12525    const DRV: &str = "  - name: drv\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.example.com\n      api_key: ${DRIVER_KEY}\n";
12526    const ALT: &str = "  - name: alt\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://alt.example.com\n      api_key: ${ALT_KEY}\n";
12527    const ALT_NO_GATEWAY: &str = "  - name: alt\n    version: 0.1.0\n    runtime: driver\n";
12528    const TOOL: &str = "  - name: card-api\n    version: 0.1.0\n    runtime: tool\n";
12529    const GPT: &str = "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: alt\n";
12530    const HAIKU: &str =
12531        "    - name: haiku\n      model: haiku\n      driver:\n        artifact: drv\n";
12532    const SWITCHABLE: &str = "control:\n  settings: [inference.driver]\n";
12533
12534    /// An `http` capsule on `drv` with model `m`, `artifacts` after `drv`, `alternates` as the
12535    /// `inference.alternates` list body (empty for no key), and `control` at the top level.
12536    fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
12537        let alternates = if alternates.is_empty() {
12538            String::new()
12539        } else {
12540            format!("  alternates:\n{alternates}")
12541        };
12542        format!(
12543            "name: cap\nversion: 0.0.1\nartifacts:\n{DRV}{artifacts}inference:\n  transport: http\n  model: m\n  driver:\n    artifact: drv\n{alternates}{control}"
12544        )
12545    }
12546
12547    fn refusal(yaml: &str) -> (String, String) {
12548        match RuntimeManifest::from_yaml_str(yaml) {
12549            Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
12550            | Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
12551            other => panic!("expected an inference or control refusal, got {other:?}"),
12552        }
12553    }
12554
12555    #[test]
12556    fn driver_choice_alternates_parse_in_manifest_order() {
12557        let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
12558        for manifest in [
12559            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
12560            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
12561        ] {
12562            let inference = manifest.inference.unwrap();
12563            assert_eq!(
12564                inference.alternates,
12565                vec![
12566                    InferenceAlternate {
12567                        name: "gpt".to_string(),
12568                        model: "gpt-5".to_string(),
12569                        driver: "alt".to_string(),
12570                    },
12571                    InferenceAlternate {
12572                        name: "haiku".to_string(),
12573                        model: "haiku".to_string(),
12574                        driver: "drv".to_string(),
12575                    },
12576                ]
12577            );
12578            let control = manifest.control.unwrap();
12579            assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
12580            assert!(control.agent_settings.is_empty());
12581        }
12582    }
12583
12584    #[test]
12585    fn driver_choice_absent_alternates_are_empty() {
12586        let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
12587        assert!(manifest.inference.unwrap().alternates.is_empty());
12588    }
12589
12590    #[test]
12591    fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
12592        let manifest = RuntimeManifest::from_yaml_str(&capsule(
12593            ALT,
12594            GPT,
12595            "control:\n  agent_settings: [inference.driver]\n",
12596        ))
12597        .unwrap();
12598        let control = manifest.control.unwrap();
12599        assert!(control.settings.is_empty());
12600        assert_eq!(
12601            control.agent_settings,
12602            vec![ControllableSetting::InferenceDriver]
12603        );
12604        assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
12605        assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
12606        assert!(control.secrets.is_empty());
12607    }
12608
12609    #[test]
12610    fn driver_choice_wire_name_round_trips() {
12611        assert_eq!(
12612            ControllableSetting::from_wire_name("inference.driver"),
12613            Some(ControllableSetting::InferenceDriver)
12614        );
12615        assert_eq!(
12616            ControllableSetting::InferenceDriver.wire_name(),
12617            "inference.driver"
12618        );
12619        assert_eq!(
12620            ControllableSetting::AGENT_SETTABLE,
12621            &[ControllableSetting::InferenceDriver]
12622        );
12623    }
12624
12625    /// An alternate's driver may carry `gateway:`, which a driver entry nothing names may not.
12626    #[test]
12627    fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
12628        RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
12629            .expect("an alternate's driver carries its own gateway");
12630        match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
12631            Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
12632                assert!(message.contains("inference.alternates"), "{message}")
12633            }
12634            other => panic!("expected InvalidArtifact, got {other:?}"),
12635        }
12636    }
12637
12638    /// Two models of one provider: the alternate shares the primary's driver and gateway.
12639    #[test]
12640    fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
12641        let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
12642        assert_eq!(manifest.inference.unwrap().alternates[0].driver, "drv");
12643    }
12644
12645    #[test]
12646    fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
12647        let manifest = RuntimeManifest::from_yaml_str(&capsule(
12648            ALT,
12649            GPT,
12650            "control:\n  settings: [inference.driver]\n  secrets: [ALT_KEY]\n",
12651        ))
12652        .unwrap();
12653        assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
12654        let (field, message) = refusal(&capsule(
12655            ALT,
12656            GPT,
12657            "control:\n  settings: [inference.driver]\n  secrets: [DRIVER_KEY]\n",
12658        ));
12659        assert_eq!(field, "control.secrets");
12660        assert!(
12661            message.contains("keys the configured inference driver's gateway"),
12662            "{message}"
12663        );
12664    }
12665
12666    #[test]
12667    fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
12668        let alternate = "    - name: gpt\n      model: gpt-5\n      temperature: 1\n      driver:\n        artifact: alt\n";
12669        let manifest =
12670            RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
12671        let keys: Vec<(&str, &str)> = manifest
12672            .unknown_keys
12673            .iter()
12674            .map(|key| (key.key.as_str(), key.block_path.as_str()))
12675            .collect();
12676        assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
12677    }
12678
12679    /// Every refusal the alternates and the driver setting can draw, each naming its field and
12680    /// rendering as the manifest error `E-MAN-003` maps from.
12681    #[test]
12682    fn driver_choice_manifest_refusals_name_the_field() {
12683        let alternate = |name: &str, model: &str, driver: &str| {
12684            format!("    - name: {name}\n      model: {model}\n      driver:\n        artifact: {driver}\n")
12685        };
12686        let process = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: drv\n    version: 0.1.0\n    runtime: driver\ninference:\n  transport: process\n  driver:\n    artifact: drv\n";
12687        let cases: Vec<(String, &str, &str)> = vec![
12688            (
12689                format!("{process}  alternates:\n{GPT}"),
12690                "inference.alternates",
12691                "is not valid with transport: process",
12692            ),
12693            (
12694                format!("{process}control:\n  settings: [inference.driver]\n"),
12695                "control.settings",
12696                "'inference.driver' is controllable only under inference.transport: http",
12697            ),
12698            (
12699                capsule(ALT, &alternate("primary", "gpt-5", "alt"), SWITCHABLE),
12700                "inference.alternates[0].name",
12701                "'primary' is reserved",
12702            ),
12703            (
12704                capsule(
12705                    ALT,
12706                    &format!("{}{}", alternate("gpt", "gpt-5", "alt"), alternate("gpt", "gpt-4", "alt")),
12707                    SWITCHABLE,
12708                ),
12709                "inference.alternates[1].name",
12710                "'gpt' names more than one alternate",
12711            ),
12712            (
12713                capsule(ALT, &alternate("GPT", "gpt-5", "alt"), SWITCHABLE),
12714                "inference.alternates[0].name",
12715                "'GPT' is not a driver choice name",
12716            ),
12717            (
12718                capsule(ALT, &alternate("5gpt", "gpt-5", "alt"), SWITCHABLE),
12719                "inference.alternates[0].name",
12720                "'5gpt' is not a driver choice name",
12721            ),
12722            (
12723                capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "alt"), SWITCHABLE),
12724                "inference.alternates[0].name",
12725                "is not a driver choice name",
12726            ),
12727            (
12728                capsule(ALT, "    - name: gpt\n      model: \"\"\n      driver:\n        artifact: alt\n", SWITCHABLE),
12729                "inference.alternates[0].model",
12730                "missing required field",
12731            ),
12732            (
12733                capsule(ALT, "    - name: gpt\n      model: gpt-5\n", SWITCHABLE),
12734                "inference.alternates[0].driver.artifact",
12735                "missing required field",
12736            ),
12737            (
12738                capsule("", &alternate("gpt", "gpt-5", "nowhere"), SWITCHABLE),
12739                "inference.alternates[0].driver.artifact",
12740                "artifact 'nowhere' is not declared in artifacts:",
12741            ),
12742            (
12743                capsule(TOOL, &alternate("gpt", "gpt-5", "card-api"), SWITCHABLE),
12744                "inference.alternates[0].driver.artifact",
12745                "artifact 'card-api' is declared with runtime: tool",
12746            ),
12747            (
12748                capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "alt"), SWITCHABLE),
12749                "inference.alternates[0].driver.artifact",
12750                "artifact 'alt' declares no gateway.endpoint",
12751            ),
12752            (
12753                capsule(
12754                    ALT,
12755                    "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: alt\n        config:\n          thinking: enabled\n",
12756                    SWITCHABLE,
12757                ),
12758                "inference.alternates[0].driver.config",
12759                "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",
12760            ),
12761            (
12762                capsule("", &alternate("same", "m", "drv"), SWITCHABLE),
12763                "inference.alternates[0]",
12764                "'same' is driver 'drv' with model 'm', which is the primary choice",
12765            ),
12766            (
12767                capsule(
12768                    ALT,
12769                    &format!("{}{}", alternate("gpt", "gpt-5", "alt"), alternate("gpt-too", "gpt-5", "alt")),
12770                    SWITCHABLE,
12771                ),
12772                "inference.alternates[1]",
12773                "'gpt-too' is driver 'alt' with model 'gpt-5', which alternate 'gpt' already is",
12774            ),
12775            (
12776                capsule(ALT, GPT, ""),
12777                "inference.alternates",
12778                "declares alternates that nothing could ever select",
12779            ),
12780            (
12781                capsule(ALT, GPT, "control:\n  settings: [inference.max_tokens]\n"),
12782                "inference.alternates",
12783                "declares alternates that nothing could ever select",
12784            ),
12785            (
12786                capsule("", "", SWITCHABLE),
12787                "control.settings",
12788                "'inference.driver' selects among inference.alternates, and this capsule declares none",
12789            ),
12790            (
12791                capsule("", "", "control:\n  agent_settings: [inference.driver]\n"),
12792                "control.agent_settings",
12793                "'inference.driver' selects among inference.alternates, and this capsule declares none",
12794            ),
12795            (
12796                capsule(ALT, GPT, "control:\n  agent_settings: [inference.max_tokens]\n"),
12797                "control.agent_settings",
12798                "'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
12799            ),
12800            (
12801                capsule(
12802                    ALT,
12803                    GPT,
12804                    "control:\n  agent_settings: [inference.driver, inference.driver]\n",
12805                ),
12806                "control.agent_settings",
12807                "'inference.driver' is listed more than once",
12808            ),
12809            (
12810                capsule(ALT, GPT, "control:\n  agent_settings: inference.driver\n"),
12811                "control.agent_settings",
12812                "must be a list of names",
12813            ),
12814        ];
12815        for (yaml, field, message) in &cases {
12816            let (got_field, got_message) = refusal(yaml);
12817            assert_eq!(&got_field, field, "{yaml}");
12818            assert!(got_message.contains(message), "{yaml}\n{got_message}");
12819            let rendered = RuntimeManifest::from_yaml_str(yaml)
12820                .unwrap_err()
12821                .to_string();
12822            assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
12823        }
12824    }
12825}
12826
12827#[cfg(test)]
12828mod network_authentication_tests {
12829    use super::*;
12830
12831    fn network_authentication_of(
12832        block: &str,
12833    ) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
12834        RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
12835            .map(|manifest| manifest.network.and_then(|network| network.authentication))
12836    }
12837
12838    fn network_authentication_refusal(block: &str) -> (String, String) {
12839        match network_authentication_of(block) {
12840            Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
12841            other => panic!("expected InvalidNetworkConfig, got {other:?}"),
12842        }
12843    }
12844
12845    #[test]
12846    fn network_authentication_with_credentials_parses_sorted_by_name() {
12847        let auth = network_authentication_of(
12848            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
12849             watcher:\n        scopes: [tasks/get, stream/watch]\n      \
12850             reader:\n        scopes: [resources/files]\n",
12851        )
12852        .unwrap()
12853        .expect("declared");
12854        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
12855        assert_eq!(
12856            auth.credentials,
12857            vec![
12858                DoorCredential {
12859                    name: "reader".to_string(),
12860                    scopes: vec!["resources/files".to_string()],
12861                },
12862                DoorCredential {
12863                    name: "watcher".to_string(),
12864                    // Declared order, not sorted.
12865                    scopes: vec!["tasks/get".to_string(), "stream/watch".to_string()],
12866                },
12867            ]
12868        );
12869    }
12870
12871    #[test]
12872    fn network_authentication_scheme_alone_parses() {
12873        let auth = network_authentication_of("network:\n  authentication:\n    scheme: bearer\n")
12874            .unwrap()
12875            .expect("declared");
12876        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
12877        assert!(auth.credentials.is_empty());
12878    }
12879
12880    #[test]
12881    fn network_authentication_absent_is_a_public_door() {
12882        assert_eq!(network_authentication_of("").unwrap(), None);
12883        assert_eq!(
12884            network_authentication_of("network:\n  internal_port: 8080\n").unwrap(),
12885            None
12886        );
12887        let manifest = RuntimeManifest::from_yaml_str(
12888            "name: cap\nversion: 0.1.0\nnetwork:\n  internal_port: 8080\n",
12889        )
12890        .unwrap();
12891        assert_eq!(
12892            manifest.network,
12893            Some(NetworkConfig {
12894                internal_port: Some(8080),
12895                authentication: None,
12896            })
12897        );
12898    }
12899
12900    #[test]
12901    fn network_authentication_without_a_scheme_is_refused() {
12902        let (field, message) = network_authentication_refusal(
12903            "network:\n  authentication:\n    credentials:\n      w:\n        scopes: [tasks/get]\n",
12904        );
12905        assert_eq!(field, "network.authentication.scheme");
12906        assert!(message.contains("required"), "{message}");
12907    }
12908
12909    #[test]
12910    fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
12911        for scheme in ["mtls", "Bearer", "basic"] {
12912            let (field, message) = network_authentication_refusal(&format!(
12913                "network:\n  authentication:\n    scheme: {scheme}\n"
12914            ));
12915            assert_eq!(field, "network.authentication.scheme");
12916            assert!(message.contains("bearer"), "{message}");
12917            assert!(message.contains(scheme), "{message}");
12918        }
12919    }
12920
12921    #[test]
12922    fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
12923        let long = format!("a{}", "b".repeat(32));
12924        for name in [
12925            "operator",
12926            "Watcher",
12927            "1st",
12928            "_x",
12929            "-x",
12930            "wat.cher",
12931            long.as_str(),
12932        ] {
12933            let (field, message) = network_authentication_refusal(&format!(
12934                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
12935                 '{name}':\n        scopes: [tasks/get]\n"
12936            ));
12937            assert_eq!(field, "network.authentication.credentials", "{name}");
12938            assert!(message.contains(name), "{message}");
12939        }
12940        let longest = format!("a{}", "b".repeat(31));
12941        for name in ["w", "a-b_c9", longest.as_str()] {
12942            network_authentication_of(&format!(
12943                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
12944                 {name}:\n        scopes: [tasks/get]\n"
12945            ))
12946            .unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
12947        }
12948    }
12949
12950    #[test]
12951    fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
12952        let credential = |body: &str| {
12953            format!(
12954                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
12955                 watcher:\n{body}"
12956            )
12957        };
12958        let field = "network.authentication.credentials.watcher.scopes";
12959
12960        let (got, message) = network_authentication_refusal(&credential("        {}\n"));
12961        assert_eq!(got, field);
12962        assert!(message.contains("required"), "{message}");
12963
12964        let (got, message) = network_authentication_refusal(&credential("        scopes: []\n"));
12965        assert_eq!(got, field);
12966        assert!(message.contains("no scope"), "{message}");
12967
12968        let (got, message) =
12969            network_authentication_refusal(&credential("        scopes: [tasks/list]\n"));
12970        assert_eq!(got, field);
12971        assert!(message.contains("tasks/list"), "{message}");
12972        assert!(message.contains("resources/files"), "{message}");
12973
12974        let (got, message) = network_authentication_refusal(&credential(
12975            "        scopes: [agent/getAuthenticatedExtendedCard]\n",
12976        ));
12977        assert_eq!(got, field);
12978        assert!(
12979            message.contains("agent/getAuthenticatedExtendedCard"),
12980            "{message}"
12981        );
12982
12983        let (got, message) = network_authentication_refusal(&credential(
12984            "        scopes: [tasks/get, stream/watch, tasks/get]\n",
12985        ));
12986        assert_eq!(got, field);
12987        assert!(message.contains("more than once"), "{message}");
12988    }
12989
12990    #[test]
12991    fn network_authentication_accepts_every_door_scope() {
12992        let auth = network_authentication_of(&format!(
12993            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
12994             everything:\n        scopes: [{}]\n",
12995            DOOR_SCOPES.join(", ")
12996        ))
12997        .unwrap()
12998        .unwrap();
12999        assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
13000        assert_eq!(OPERATOR_CREDENTIAL, "operator");
13001    }
13002
13003    #[test]
13004    fn network_authentication_is_refused_beside_spawn_allow() {
13005        let (field, message) = network_authentication_refusal(
13006            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
13007             allow: [worker]\n",
13008        );
13009        assert_eq!(field, "network.authentication");
13010        assert!(message.contains("capabilities.spawn.allow"), "{message}");
13011        assert!(message.contains("token"), "{message}");
13012        let error = RuntimeManifest::from_yaml_str(
13013            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n\
13014             capabilities:\n  spawn:\n    allow: [worker]\n",
13015        )
13016        .unwrap_err()
13017        .to_string();
13018        assert!(error.contains("network.authentication"), "{error}");
13019        assert!(error.contains("capabilities.spawn.allow"), "{error}");
13020
13021        // An empty allow-list delegates nothing, so it does not conflict.
13022        network_authentication_of(
13023            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
13024             allow: []\n",
13025        )
13026        .unwrap();
13027    }
13028
13029    #[test]
13030    fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
13031        let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
13032            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n    \
13033             schemes: x\n    credentials:\n      watcher:\n        scopes: [tasks/get]\n        \
13034             scope: [tasks/get]\n",
13035        )
13036        .unwrap()
13037        .unknown_keys
13038        .into_iter()
13039        .map(|key| (key.key, key.block_path, key.nearest_known))
13040        .collect();
13041        assert_eq!(
13042            keys,
13043            vec![
13044                (
13045                    "schemes".to_string(),
13046                    "network.authentication".to_string(),
13047                    Some("scheme".to_string())
13048                ),
13049                (
13050                    "scope".to_string(),
13051                    "network.authentication.credentials.watcher".to_string(),
13052                    Some("scopes".to_string())
13053                ),
13054            ]
13055        );
13056    }
13057}