Skip to main content

murmur_artifact/
runtime_manifest.rs

1use std::{fs, net::IpAddr, path::Path};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use url::Url;
6
7use crate::{
8    artifact_name::{ArtifactName, ArtifactNameError},
9    build::artifact_name_format_error,
10    manifest::{REGISTRY_BLOCK_KEY, REGISTRY_BLOCK_MESSAGE},
11    manifest_path::MANIFEST_FILENAME,
12    shell_words::split_shell_words,
13    trace_capture::{resolve_trace_capture, TraceCapture},
14    unknown_manifest_keys::{nearest_known_key, UnknownManifestKey},
15};
16
17// ── Lifecycle types ───────────────────────────────────────────────────────────
18
19#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum TaskAcceptance {
22    None,
23    #[default]
24    Single,
25    Queue,
26}
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum AfterTask {
31    #[default]
32    Exit,
33    Sleep,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ConversationMode {
39    #[default]
40    Stateless,
41    Threaded,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct LifecycleConfig {
46    pub task_acceptance: TaskAcceptance,
47    pub after_task: AfterTask,
48    pub queue_depth: usize,
49    /// If set, request-input calls that receive no follow-up within this many seconds
50    /// transition the task to "failed" with reason "input-timeout". Absent = wait indefinitely.
51    pub input_timeout_secs: Option<u64>,
52    /// Whether tasks sharing a contextId accumulate conversation history across turns.
53    /// Stateless (default): each task is fully independent; Threaded: history is loaded
54    /// and persisted per contextId within a session.
55    pub conversation_mode: ConversationMode,
56    /// Maximum times an `on-task-end` hook may reopen a single task (re-run its agent
57    /// loop with injected feedback). Defaults to 1 when absent. `0` disables reopening
58    /// entirely. Unlike `inference.max_turns`, `0` is a valid explicit value. Reopening
59    /// never grants turns past `inference.max_turns` — the two budgets share one
60    /// cumulative turn count.
61    pub max_task_reopens: u32,
62    /// How long a shell command runs in the foreground before it is demoted to the background.
63    /// `None` when not declared: a command runs in the foreground until it exits, nothing is
64    /// demoted on a clock, and the `background-command` tool is not offered. With a value, a
65    /// command that exits inside this window returns its output to the turn; one that outruns it
66    /// hands the turn a handle, keeps running, and reports back later as a `completion`-origin
67    /// task. `0` demotes at the first poll after the spawn, so effectively every command detaches.
68    pub shell_grace_secs: Option<u64>,
69    /// The single bound on a delegation, covering both waits one can have: how long a sub-capsule
70    /// started by `delegate-task` is watched, and how long a plan `capsule` step waits for its
71    /// answer. Defaults to 600 seconds. Absent is a ceiling rather than an absence: a capsule that
72    /// never declares this one is still bounded, because an unbounded delegation leaves a wedged
73    /// sub-capsule running for as long as the capsule that started it. `0` gives up at the first
74    /// poll after the task is delivered.
75    ///
76    /// Reaching the deadline ends the sub-capsule. A `delegate-task` caller is told in a
77    /// `completion`-origin task carrying `status: terminated` and a detail naming the bound; a plan
78    /// step's call returns `timed_out`. The clock starts when the sub-capsule reports itself ready,
79    /// not when its process is spawned, so a slow host does not spend this window on staging.
80    /// `MURMUR_DELEGATION_TIMEOUT_SECS` overrides this value when it names a positive number of
81    /// seconds.
82    pub delegation_deadline_secs: u64,
83    /// How long before a `mur run --deadline` the runtime tells the agent, once, that its session
84    /// is about to end. `None` takes the default the runtime derives from the deadline: five
85    /// minutes, or a tenth of the deadline when that is shorter, and at least one second. `0`
86    /// sends no notice. Inert for a session launched with no deadline.
87    pub deadline_notice_secs: Option<u64>,
88    /// Keeps a task's agent working past its turn ends: each time an attempt ends at a turn end,
89    /// the runtime sends [`KeepWorking::message`] as the next user message in the same
90    /// conversation, up to [`KeepWorking::limit`] times per task. `None` (the default, and what a
91    /// manifest without `lifecycle.keep_working` gets) ends a task at its first turn end.
92    pub keep_working: Option<KeepWorking>,
93    /// What "done" means for a task: each time an attempt stops at a turn end, the runtime runs
94    /// [`DoneCheck::command`] in the work folder and, while it fails, reopens the task with its
95    /// output, up to [`DoneCheck::limit`] times per task. `None` (the default, and what a manifest
96    /// without `lifecycle.done_check` gets) ends a task when the model stops.
97    pub done_check: Option<DoneCheck>,
98}
99
100/// `lifecycle.keep_working`: what the runtime sends when a task's attempt ends at a turn end, and
101/// how many times per task it does so. Both fields are required; see
102/// [`LifecycleConfig::keep_working`].
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct KeepWorking {
105    /// Sent verbatim, unfenced, as the next user message. Never blank.
106    pub message: String,
107    /// How many continuations one task may receive. At least 1.
108    pub limit: u32,
109}
110
111/// `lifecycle.done_check`: the command whose exit status says whether a task's work is done, and
112/// how many times per task a failing run may reopen the task; see [`LifecycleConfig::done_check`].
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct DoneCheck {
115    /// As declared. Split with [`split_shell_words`]; its first word is a
116    /// `capabilities.shell.allow` entry.
117    pub command: String,
118    /// How many times one task may be reopened by a failing run, counted across hook reopens and
119    /// separately from `max_task_reopens`. `0` runs and records the check without reopening.
120    pub limit: u32,
121    /// How long one run may take before the runtime kills it. At least 1.
122    pub timeout_secs: u64,
123}
124
125impl DoneCheck {
126    /// The default [`DoneCheck::timeout_secs`].
127    pub const DEFAULT_TIMEOUT_SECS: u64 = 600;
128
129    /// [`DoneCheck::command`] as the program and its arguments.
130    pub fn argv(&self) -> Vec<String> {
131        split_shell_words(&self.command)
132    }
133}
134
135impl LifecycleConfig {
136    /// Whether a session under this lifecycle is still there to receive work that arrives after
137    /// the task which started it ended — a demoted shell command's completion, a delegation's
138    /// outcome.
139    ///
140    /// `queue` + `sleep` is the only combination that qualifies, and the delegation lane and the
141    /// shell lane must agree on it: a capsule warned about one and not the other would be told its
142    /// lifecycle is both adequate and inadequate for the same reason.
143    pub fn can_receive_background_tasks(&self) -> bool {
144        self.task_acceptance == TaskAcceptance::Queue && self.after_task == AfterTask::Sleep
145    }
146
147    /// Whether this lifecycle's queue has room for a waiting task: `false` exactly for
148    /// `task_acceptance: queue` at `queue_depth: 0`, which the A2A door would turn into a door
149    /// refusing every task, with nowhere for background completions to wait.
150    ///
151    /// The manifest refuses such a lifecycle at load
152    /// ([`RuntimeManifestError::QueueWithoutRoom`]) and staging refuses one a
153    /// `--lifecycle-task-acceptance queue` override produces; both ask this. `queue_depth` means
154    /// nothing under `single` or `none`, so any value passes there.
155    pub fn queue_has_room(&self) -> bool {
156        !(self.task_acceptance == TaskAcceptance::Queue && self.queue_depth == 0)
157    }
158
159    /// How many tasks a capsule under this lifecycle holds at once: one running task plus
160    /// `queue_depth` waiting ones under `queue`, a single task under `single`, none under `none`.
161    ///
162    /// A `queue` lifecycle at `queue_depth: 0` is refused at load and at staging
163    /// ([`Self::queue_has_room`]), so no running capsule has one. The A2A door is the authority
164    /// this mirrors: the task registry in `capsule-runtime` (`TaskRegistry::can_accept`) decides
165    /// what is rejected, and a test there fails if the two drift apart.
166    pub fn tasks_held_at_once(&self) -> usize {
167        match self.task_acceptance {
168            TaskAcceptance::None => 0,
169            TaskAcceptance::Single => 1,
170            TaskAcceptance::Queue => self.queue_depth + 1,
171        }
172    }
173}
174
175impl Default for LifecycleConfig {
176    fn default() -> Self {
177        Self {
178            task_acceptance: TaskAcceptance::Single,
179            after_task: AfterTask::Exit,
180            queue_depth: 1,
181            input_timeout_secs: None,
182            conversation_mode: ConversationMode::Stateless,
183            max_task_reopens: 1,
184            shell_grace_secs: None,
185            delegation_deadline_secs: 600,
186            deadline_notice_secs: None,
187            keep_working: None,
188            done_check: None,
189        }
190    }
191}
192
193/// Lifecycle override: allows a parent (mur-roost) or CLI flag to constrain
194/// the lifecycle down from what the manifest declares.
195#[derive(Debug, Clone, Default)]
196pub struct LifecycleOverride {
197    pub task_acceptance: Option<TaskAcceptance>,
198    pub after_task: Option<AfterTask>,
199}
200
201/// Artifact taxonomy for entries declared in a capsule manifest.
202///
203/// Variant meanings:
204///
205/// - `Tool`: agent-callable tool (WASM component or native binary). Visible in MURMUR.md and the
206///   LLM tool list. Implementation details (wasm vs native) are declared in the artifact's own
207///   murmur.yaml via `implementation:`.
208/// - `Driver`: inference driver implemented as a WASM component. The runtime calls it as a
209///   processor. Hidden from the LLM.
210/// - `Hook`: event-triggered artifact implemented as a WASM component. The runtime calls it at
211///   lifecycle points; behavioral contract (binding, execution_mode, commit_policy) is declared
212///   in the artifact's own murmur.yaml. Hidden from the LLM.
213/// - `Skill`: documentation artifact; no executable binary. Visible in the LLM tool inventory.
214///   Invoking a skill by name returns its `skill.md` content as the tool result (just-in-time
215///   injection). No WASM/native dispatch occurs. A skill bound via
216///   `inference.system_prompt_artifact` is excluded from the callable inventory because it is
217///   already part of the system prompt.
218#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
219#[serde(rename_all = "lowercase")]
220pub enum ArtifactRuntime {
221    Tool,
222    Driver,
223    Hook,
224    Skill,
225}
226
227impl ArtifactRuntime {
228    /// Whether artifacts of this role appear in the LLM's tool inventory.
229    ///
230    /// Deliberately an exhaustive match with one arm per variant and no wildcard: a new
231    /// `ArtifactRuntime` variant must state its own visibility here, so adding a role that
232    /// should be LLM-visible cannot silently default to hidden.
233    pub fn is_llm_visible(&self) -> bool {
234        match self {
235            ArtifactRuntime::Tool => true,
236            ArtifactRuntime::Skill => true,
237            ArtifactRuntime::Driver => false,
238            ArtifactRuntime::Hook => false,
239        }
240    }
241
242    pub fn as_str(&self) -> &'static str {
243        match self {
244            ArtifactRuntime::Tool => "tool",
245            ArtifactRuntime::Driver => "driver",
246            ArtifactRuntime::Hook => "hook",
247            ArtifactRuntime::Skill => "skill",
248        }
249    }
250}
251
252/// How a `runtime: tool` artifact is implemented.
253///
254/// Declared via `implementation:` in the artifact's own murmur.yaml.
255/// Defaults to `Wasm` when the field is absent.
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum ArtifactImplementation {
258    Wasm,
259    Native,
260}
261
262/// Read the `implementation:` field from an artifact's murmur.yaml.
263/// Defaults to `Wasm` when the field is absent or unparseable.
264pub fn read_tool_implementation(
265    artifact_manifest_path: &Path,
266) -> Result<ArtifactImplementation, RuntimeManifestError> {
267    let content =
268        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
269            path: artifact_manifest_path.display().to_string(),
270            source,
271        })?;
272    Ok(parse_tool_implementation_from_yaml(&content))
273}
274
275/// Parse `implementation:` from an in-memory manifest YAML string.
276/// Defaults to `Wasm` when the field is absent or unparseable.
277pub fn parse_tool_implementation_from_yaml(yaml: &str) -> ArtifactImplementation {
278    serde_norway::from_str::<serde_norway::Value>(yaml)
279        .map(|v| {
280            v.as_mapping()
281                .and_then(|m| m.get(serde_norway::Value::String("implementation".to_string())))
282                .and_then(serde_norway::Value::as_str)
283                .map(|s| match s {
284                    "native" => ArtifactImplementation::Native,
285                    _ => ArtifactImplementation::Wasm,
286                })
287                .unwrap_or(ArtifactImplementation::Wasm)
288        })
289        .unwrap_or(ArtifactImplementation::Wasm)
290}
291
292// ── Hook configuration types ──────────────────────────────────────────────────
293
294/// Which lifecycle event(s) a hook binds to.
295///
296/// Declared via `binding:` in the artifact's own murmur.yaml.
297/// Defaults to `All` when the field is absent (receives all session events).
298#[derive(Debug, Clone, PartialEq, Eq)]
299pub enum HookBinding {
300    /// `"on-stage"` — fires during `stage_session`; always blocking.
301    OnStage,
302    /// `"on-session-start"` — fires once before the first inference turn.
303    OnSessionStart,
304    /// `"on-task-start"` — fires once per task, before that task's first inference turn.
305    OnTaskStart,
306    /// `"on-inference"` — fires after each inference response is parsed.
307    OnInference,
308    /// `"on-tool-call"` — fires after each model-requested tool invocation.
309    OnToolCall,
310    /// `"on-shell"` — fires after each shell command returns.
311    OnShell,
312    /// `"on-compaction"` — fires when the session token threshold is reached.
313    OnCompaction,
314    /// `"on-task-end"` — fires once per task, after that task's agent loop returns.
315    OnTaskEnd,
316    /// `"on-session-end"` — fires at session teardown.
317    OnSessionEnd,
318    /// No `binding:` field — receives all session events (not on-stage).
319    All,
320}
321
322impl HookBinding {
323    /// The manifest spelling of this binding, which is also the WIT lifecycle function
324    /// name the runtime dispatches to — the key `capsule-runtime`'s honored-arm table is
325    /// written against. [`HookBinding::All`] has no spelling of its own (it is what an
326    /// omitted `binding:` means), so it renders as `"all"` for diagnostics only.
327    #[must_use]
328    pub fn as_str(&self) -> &'static str {
329        match self {
330            Self::OnStage => "on-stage",
331            Self::OnSessionStart => "on-session-start",
332            Self::OnTaskStart => "on-task-start",
333            Self::OnInference => "on-inference",
334            Self::OnToolCall => "on-tool-call",
335            Self::OnShell => "on-shell",
336            Self::OnCompaction => "on-compaction",
337            Self::OnTaskEnd => "on-task-end",
338            Self::OnSessionEnd => "on-session-end",
339            Self::All => "all",
340        }
341    }
342}
343
344/// Whether the runtime waits for the hook result.
345///
346/// Declared via `execution_mode:` in the artifact's own murmur.yaml.
347/// Defaults to `Blocking` when the field is absent.
348#[derive(Debug, Clone, PartialEq, Eq)]
349pub enum HookExecutionMode {
350    /// `"blocking"` — runtime waits for result before proceeding.
351    Blocking,
352    /// `"async"` — runtime proceeds immediately; result not committed.
353    Async,
354}
355
356/// What the runtime does with a successful hook output.
357///
358/// Declared via `commit_policy:` in the artifact's own murmur.yaml.
359/// Defaults to `None` when the field is absent.
360#[derive(Debug, Clone, PartialEq, Eq)]
361pub enum HookCommitPolicy {
362    /// `"none"` — output discarded.
363    None,
364    /// `"replace-context"` — runtime replaces conversation history. Valid only with on-compaction.
365    ReplaceContext,
366    /// `"write-manifests"` — runtime writes tool manifests to the runtime directory's tools/. Valid
367    /// only with on-stage.
368    WriteManifests,
369    /// `"reopen-task"` — runtime re-runs the task's agent loop with the hook's feedback.
370    /// Valid only with on-task-end.
371    ReopenTask,
372    /// `"seed-context"` — runtime places the hook's messages at the head of the task's
373    /// first message list, under the `context.seed_budget` ceiling. Valid only with
374    /// on-task-start.
375    SeedContext,
376    /// `"deny"` — the hook decides, immediately before the call is dispatched, whether the
377    /// call happens at all. Valid only with on-shell and on-tool-call, and only with an
378    /// explicit `binding:` naming one of them.
379    ///
380    /// The only policy that *subtracts*: every other value commits something the hook
381    /// produced, this one refuses something the capsule asked for. It narrows the manifest's
382    /// grant and can never widen it.
383    Deny,
384}
385
386impl HookCommitPolicy {
387    /// The manifest spelling of this policy, for diagnostics.
388    #[must_use]
389    pub fn as_str(&self) -> &'static str {
390        match self {
391            Self::None => "none",
392            Self::ReplaceContext => "replace-context",
393            Self::WriteManifests => "write-manifests",
394            Self::ReopenTask => "reopen-task",
395            Self::SeedContext => "seed-context",
396            Self::Deny => "deny",
397        }
398    }
399}
400
401/// What the runtime does with a lifecycle event for an `execution_mode: async` hook
402/// whose job queue is already full.
403///
404/// Declared via `on_overflow:` on the hook's entry in the **capsule operator's own**
405/// manifest — not in the hook artifact's bundled murmur.yaml, because keeping up with
406/// the agent loop is the operator's trade-off, not the hook author's. Inert on a hook
407/// that turns out to be `execution_mode: blocking` (a blocking hook is never queued);
408/// the operator-manifest parser cannot tell the two apart, since the mode lives in the
409/// artifact's own manifest and is only known at staging time.
410#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
411#[serde(rename_all = "snake_case")]
412pub enum HookOverflowPolicy {
413    /// `"drop"` (the default) — discard the event and count it. The agent loop never
414    /// waits on a slow hook; telemetry is lossy under sustained overload, and the loss
415    /// is reported rather than silent.
416    #[default]
417    Drop,
418    /// `"block"` — the agent loop waits for the hook's worker to make room. No event is
419    /// lost, at the cost of putting a slow hook back on the critical path.
420    Block,
421}
422
423impl HookOverflowPolicy {
424    /// The manifest spelling of this policy, for diagnostics.
425    #[must_use]
426    pub fn as_str(&self) -> &'static str {
427        match self {
428            Self::Drop => "drop",
429            Self::Block => "block",
430        }
431    }
432}
433
434/// Behavioral contract for a hook artifact, read from its own murmur.yaml.
435#[derive(Debug, Clone, PartialEq, Eq)]
436pub struct HookConfig {
437    pub binding: HookBinding,
438    pub execution_mode: HookExecutionMode,
439    pub commit_policy: HookCommitPolicy,
440}
441
442impl Default for HookConfig {
443    fn default() -> Self {
444        Self {
445            binding: HookBinding::All,
446            execution_mode: HookExecutionMode::Blocking,
447            commit_policy: HookCommitPolicy::None,
448        }
449    }
450}
451
452/// The one `commit_policy` a hook bound to `binding` can actually have committed, or
453/// `None` when no `commit_policy` value other than `none` is ever honored for it.
454///
455/// This is the manifest-side mirror of `capsule-runtime`'s `HONORED_OUTPUT_ARM` table,
456/// which decides — keyed on the same lifecycle function names [`HookBinding::as_str`]
457/// produces — which `hook-output` arm the runtime commits for each event. Two bindings
458/// return `None` for different reasons, and the distinction matters:
459///
460/// - `on-session-start` and `on-session-end` honor no arm at all; every output from them
461///   is discarded.
462/// - `on-tool-call` and `on-shell` honor `deny`, and only at their decision-point dispatch
463///   — the one that runs before the call. Their post-call observation dispatch honors
464///   nothing, which is why the runtime's table is keyed on the dispatch phase as well as
465///   the event name.
466/// - `on-inference` *does* honor an arm (`artifact`), but that arm has no
467///   `commit_policy` spelling — [`HookCommitPolicy`] has no `Artifact` variant — so no
468///   non-`none` policy is declarable for it either.
469///
470/// [`HookBinding::All`] (an omitted `binding:`) also returns `None`, and that is a third
471/// reason again: an `All`-bound hook is dispatched to *every* event, so there is no single
472/// honored policy to name. It is deliberately **not** validated against this function —
473/// [`parse_hook_config_from_yaml`] accepts every `commit_policy` for it, with the single
474/// exception of [`HookCommitPolicy::Deny`], which that function rejects outright for `All`.
475#[must_use]
476pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
477    match binding {
478        HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
479        HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
480        HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
481        HookBinding::OnTaskStart => Some(HookCommitPolicy::SeedContext),
482        HookBinding::OnToolCall | HookBinding::OnShell => Some(HookCommitPolicy::Deny),
483        HookBinding::OnSessionStart
484        | HookBinding::OnInference
485        | HookBinding::OnSessionEnd
486        | HookBinding::All => None,
487    }
488}
489
490/// Read a hook's behavioral contract from its murmur.yaml at the given path.
491pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
492    let content =
493        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
494            path: artifact_manifest_path.display().to_string(),
495            source,
496        })?;
497    parse_hook_config_from_yaml(&content).map_err(|msg| {
498        RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
499    })
500}
501
502/// Parse hook behavioral contract from an in-memory manifest YAML string.
503///
504/// `commit_policy: deny` is the one policy an omitted `binding:` cannot carry. Every other
505/// policy is accepted for [`HookBinding::All`] because an `All`-bound hook reaches every
506/// event and the runtime simply discards what a given event does not honor. `deny` is not
507/// discardable in that way: it is answered at a *decision point* standing in front of a call,
508/// and an `All`-bound hook would be asked to decide on shell and tool calls it was never
509/// written to judge — with every failure to answer, including a trap or a timeout, refusing
510/// the call. The binding is therefore required to name which of the two events the hook gates.
511pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
512    let v: serde_norway::Value =
513        serde_norway::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
514
515    let binding = v
516        .as_mapping()
517        .and_then(|m| m.get(serde_norway::Value::String("binding".to_string())))
518        .and_then(serde_norway::Value::as_str)
519        .map(|s| match s {
520            "on-stage" => Ok(HookBinding::OnStage),
521            "on-session-start" => Ok(HookBinding::OnSessionStart),
522            "on-task-start" => Ok(HookBinding::OnTaskStart),
523            "on-inference" => Ok(HookBinding::OnInference),
524            "on-tool-call" => Ok(HookBinding::OnToolCall),
525            "on-shell" => Ok(HookBinding::OnShell),
526            "on-compaction" => Ok(HookBinding::OnCompaction),
527            "on-task-end" => Ok(HookBinding::OnTaskEnd),
528            "on-session-end" => Ok(HookBinding::OnSessionEnd),
529            other => Err(format!(
530                "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
531                 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
532            )),
533        })
534        .transpose()?
535        .unwrap_or(HookBinding::All);
536
537    let execution_mode = v
538        .as_mapping()
539        .and_then(|m| m.get(serde_norway::Value::String("execution_mode".to_string())))
540        .and_then(serde_norway::Value::as_str)
541        .map(|s| match s {
542            "blocking" => Ok(HookExecutionMode::Blocking),
543            "async" => Ok(HookExecutionMode::Async),
544            other => Err(format!(
545                "unknown execution_mode '{other}'; expected: blocking, async"
546            )),
547        })
548        .transpose()?
549        .unwrap_or(HookExecutionMode::Blocking);
550
551    let commit_policy = v
552        .as_mapping()
553        .and_then(|m| m.get(serde_norway::Value::String("commit_policy".to_string())))
554        .and_then(serde_norway::Value::as_str)
555        .map(|s| match s {
556            "none" => Ok(HookCommitPolicy::None),
557            "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
558            "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
559            "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
560            "seed-context" => Ok(HookCommitPolicy::SeedContext),
561            "deny" => Ok(HookCommitPolicy::Deny),
562            other => Err(format!(
563                "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
564                 reopen-task, seed-context, deny"
565            )),
566        })
567        .transpose()?
568        .unwrap_or(HookCommitPolicy::None);
569
570    // Validation
571    if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
572        return Err(format!(
573            "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
574             (got '{}')",
575            commit_policy.as_str()
576        ));
577    }
578    if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
579        return Err(
580            "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
581             'on-stage'"
582                .to_string(),
583        );
584    }
585    // The one policy `All` cannot carry: a hook that does not name which of the two gated
586    // events it decides on would be dispatched at decision points it was never written for,
587    // and every non-`none` answer there refuses a call.
588    if binding == HookBinding::All && commit_policy == HookCommitPolicy::Deny {
589        return Err(
590            "commit_policy 'deny' requires an explicit binding: 'on-shell' or 'on-tool-call'; \
591             a hook with no binding: is dispatched at every event, including decision points \
592             it was not written to decide"
593                .to_string(),
594        );
595    }
596    // `binding` is the single source of truth for what the runtime commits; a declared
597    // `commit_policy` the binding can never honor is a mistake that is fully knowable here,
598    // at staging time, rather than mid-session as a `hook_dispatch_error`. An omitted
599    // `binding:` (`All`) reaches every event, so every other policy stays valid for it.
600    if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
601        let honored = commit_policy_for_binding(&binding);
602        if honored.as_ref() != Some(&commit_policy) {
603            let note = if binding == HookBinding::OnInference {
604                " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
605            } else {
606                ""
607            };
608            return Err(format!(
609                "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
610                 commit_policy '{}'{note}",
611                commit_policy.as_str(),
612                binding.as_str(),
613                binding.as_str(),
614                honored.as_ref().map_or("none", HookCommitPolicy::as_str)
615            ));
616        }
617    }
618
619    Ok(HookConfig {
620        binding,
621        execution_mode,
622        commit_policy,
623    })
624}
625
626#[derive(Debug, Clone, PartialEq, Eq)]
627pub struct RuntimeManifest {
628    pub name: String,
629    pub version: String,
630    pub artifacts: Vec<RuntimeArtifact>,
631    pub capabilities: Option<Capabilities>,
632    pub inference: Option<InferenceConfig>,
633    pub context: Option<ContextConfig>,
634    pub observability: Option<ObservabilityConfig>,
635    pub trace: Option<TraceConfig>,
636    pub network: Option<NetworkConfig>,
637    pub lifecycle: Option<LifecycleConfig>,
638    /// Read-only views onto the workdir that the operator opens to processes outside the capsule.
639    /// `None` means nothing is exported and every request to the resource plane is denied.
640    pub exports: Option<Exports>,
641    /// What a controller holding this session's control token may change while it runs. `None`
642    /// — the block absent, `control: {}`, or both lists empty — means the capsule has no control
643    /// surface and mints no token.
644    pub control: Option<ControlConfig>,
645    /// The `deploy:` block. `None` when the manifest declares none.
646    pub deploy: Option<DeployConfig>,
647    /// Every key the manifest declared that this build does not recognize, captured by each
648    /// `Raw*` block's `#[serde(flatten)]` overflow map instead of dropped.
649    ///
650    /// Ordered by the walk rather than by the manifest's own line order: a containing block's own
651    /// keys precede the blocks nested inside it, and one block's keys are alphabetical.
652    ///
653    /// Empty for a manifest written entirely in keys this build knows. Never a reason to refuse a
654    /// manifest — it is reported as `W-SEC-019` by
655    /// [`crate::unknown_manifest_keys::warn_on_unknown_manifest_keys`] and nothing else reads it.
656    pub unknown_keys: Vec<UnknownManifestKey>,
657}
658
659impl RuntimeManifest {
660    pub fn effective_lifecycle(&self) -> LifecycleConfig {
661        self.lifecycle.clone().unwrap_or_default()
662    }
663
664    /// Whether this capsule's door serves tasks a peer capsule's runtime sends it: `true` only
665    /// when the manifest declares `exports.peer_tasks.accept: true`. See
666    /// [`Exports::accepts_peer_tasks`].
667    ///
668    /// The capsule's own manifest is the only input. Nothing a launcher, an operator or a
669    /// formation passes can change the answer.
670    pub fn accepts_peer_tasks(&self) -> bool {
671        self.exports
672            .as_ref()
673            .is_some_and(Exports::accepts_peer_tasks)
674    }
675
676    /// The `mur` release `mur deploy` installs on the target: `deploy.mur_version`, or `None`
677    /// when the block or the key is absent or the value is blank.
678    pub fn deploy_mur_version(&self) -> Option<&str> {
679        self.deploy.as_ref()?.mur_version.as_deref()
680    }
681}
682
683/// The top-level `deploy:` block, read by `mur deploy` only. `mur run` takes no meaning from it.
684#[derive(Debug, Clone, Default, PartialEq, Eq)]
685pub struct DeployConfig {
686    /// The `mur` release `mur deploy` downloads, caches at `~/.murmur/bin/mur-{version}-{platform}`
687    /// and installs on the target. `None` for an absent or blank value, in which case the
688    /// deploying `mur`'s own version is installed.
689    pub mur_version: Option<String>,
690}
691
692/// A setting of a running session that a controller may change over the control surface, when
693/// `control.settings` names it.
694///
695/// Each variant is one manifest key, spelled by [`Self::wire_name`] in `control.settings`, in the
696/// control surface's paths and bodies, and in the trace. What a value must be, and where the agent
697/// loop reads it, is the runtime's to decide per variant.
698#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
699pub enum ControllableSetting {
700    /// `inference.max_tokens`: the per-turn output cap the agent loop sends as `max_tokens`.
701    /// `transport: http` only, on the same terms as the manifest key.
702    InferenceMaxTokens,
703    /// `inference.driver`: which driver choice serves the agent loop's inference calls, by name —
704    /// [`PRIMARY_DRIVER_CHOICE`] or an `inference.alternates` entry's `name`. `transport: http`
705    /// only, and only on a capsule that declares at least one alternate.
706    InferenceDriver,
707}
708
709impl ControllableSetting {
710    /// Every setting a manifest may list, in the order refusals name them.
711    pub const ALL: &'static [ControllableSetting] =
712        &[Self::InferenceMaxTokens, Self::InferenceDriver];
713
714    /// Every setting `control.agent_settings` may list: the ones the agent may change about
715    /// itself through a runtime-provided tool.
716    pub const AGENT_SETTABLE: &'static [ControllableSetting] = &[Self::InferenceDriver];
717
718    /// The manifest key this setting is, as written in `control.settings`.
719    pub fn wire_name(self) -> &'static str {
720        match self {
721            Self::InferenceMaxTokens => "inference.max_tokens",
722            Self::InferenceDriver => "inference.driver",
723        }
724    }
725
726    /// The setting `name` spells, or `None` for anything outside [`Self::ALL`].
727    pub fn from_wire_name(name: &str) -> Option<Self> {
728        Self::ALL
729            .iter()
730            .copied()
731            .find(|setting| setting.wire_name() == name)
732    }
733}
734
735/// The top-level `control:` block: what a controller may change on a running session.
736///
737/// Parsed only to a non-empty declaration; `control: {}` and two empty lists parse as no block.
738/// Nothing a controller sets is persisted, so this is also everything a restart starts from.
739#[derive(Debug, Clone, PartialEq, Eq, Default)]
740pub struct ControlConfig {
741    /// Settings a controller may change, in manifest order, each listed once.
742    pub settings: Vec<ControllableSetting>,
743    /// Credential names a controller supplies at run time, in manifest order, each listed once.
744    /// Every name backs some artifact's `gateway.api_key: ${NAME}`, and none backs the configured
745    /// inference driver's. Such a name is never looked up in the config or the environment.
746    pub secrets: Vec<String>,
747    /// Settings the agent may change about itself, in manifest order, each listed once. Only
748    /// [`ControllableSetting::AGENT_SETTABLE`] members. The grant is the existence of the
749    /// runtime-provided tool that changes the setting, and never a route to the control surface.
750    pub agent_settings: Vec<ControllableSetting>,
751}
752
753impl ControlConfig {
754    /// Whether `name` is a credential a controller supplies.
755    pub fn declares_secret(&self, name: &str) -> bool {
756        self.secrets.iter().any(|secret| secret == name)
757    }
758
759    /// Whether a controller may change `setting`.
760    pub fn controller_may_set(&self, setting: ControllableSetting) -> bool {
761        self.settings.contains(&setting)
762    }
763
764    /// Whether the agent may change `setting`.
765    pub fn agent_may_set(&self, setting: ControllableSetting) -> bool {
766        self.agent_settings.contains(&setting)
767    }
768}
769
770/// The top-level `network:` block, which configures the A2A door. Unrelated to
771/// `capabilities.network`, which is the guest's egress.
772#[derive(Debug, Clone, PartialEq, Eq)]
773pub struct NetworkConfig {
774    /// Internal port the capsule expects to listen on. Default 14159 when absent.
775    pub internal_port: Option<u16>,
776    /// `network.authentication`: how a caller of the door proves it may call. `None` is a public
777    /// door, which answers every caller that reaches the port.
778    pub authentication: Option<NetworkAuthentication>,
779}
780
781/// Every scope a door token can carry: the door's JSON-RPC methods that a token gates, by wire
782/// name, and `resources/files`, the operator resource plane. The runtime's method table is held
783/// to this list by a test, in both directions.
784pub const DOOR_SCOPES: &[&str] = &[
785    "SendMessage",
786    "SendStreamingMessage",
787    "murmur.WatchSession",
788    "GetTask",
789    "ListTasks",
790    "CancelTask",
791    "SubscribeToTask",
792    "murmur.StopSession",
793    "resources/files",
794];
795
796/// The retired door method names a credential's `scopes` may not list, each with the method that
797/// replaces it: the A2A 0.3 names of the A2A methods, and the 0.3-style names murmur's own methods
798/// had. A manifest naming one is refused with [`RuntimeManifestError::RetiredDoorScope`]: the door
799/// serves no retired name, and no token carries one.
800///
801/// A replacement outside [`DOOR_SCOPES`] is a method every authenticated caller may call, which a
802/// credential lists nothing for. A test holds every replacement to the runtime's method table and
803/// every retired name outside it.
804pub const RETIRED_DOOR_SCOPES: &[(&str, &str)] = &[
805    ("message/send", "SendMessage"),
806    ("message/stream", "SendStreamingMessage"),
807    ("tasks/get", "GetTask"),
808    ("tasks/cancel", "CancelTask"),
809    ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
810    ("stream/watch", "murmur.WatchSession"),
811    ("session/stop", "murmur.StopSession"),
812];
813
814/// The credential the runtime always mints for an authenticated door, holding every scope in
815/// [`DOOR_SCOPES`]. Reserved: no declared credential may take the name.
816pub const OPERATOR_CREDENTIAL: &str = "operator";
817
818/// Longest credential name `network.authentication.credentials` accepts.
819const MAX_CREDENTIAL_NAME_LEN: usize = 32;
820
821/// How a caller of the door authenticates. A required field with one value, so that an
822/// authenticated door is an explicit statement and a second scheme is an addition, not a default
823/// change.
824#[derive(Debug, Clone, Copy, PartialEq, Eq)]
825pub enum AuthenticationScheme {
826    /// `Authorization: Bearer <token>`, with a token the capsule's runtime mints at launch.
827    Bearer,
828}
829
830impl AuthenticationScheme {
831    /// Every scheme a manifest may name, in the order refusals list them.
832    pub const ALL: &'static [AuthenticationScheme] = &[Self::Bearer];
833
834    /// The scheme as written in `network.authentication.scheme`.
835    pub fn wire_name(self) -> &'static str {
836        match self {
837            Self::Bearer => "bearer",
838        }
839    }
840}
841
842/// One declared credential: a token the runtime mints beside the operator token, reaching only
843/// `scopes`.
844#[derive(Debug, Clone, PartialEq, Eq)]
845pub struct DoorCredential {
846    /// Matches `^[a-z][a-z0-9_-]{0,31}$` and is never [`OPERATOR_CREDENTIAL`].
847    pub name: String,
848    /// Non-empty, each entry in [`DOOR_SCOPES`] and listed once, in declared order.
849    pub scopes: Vec<String>,
850}
851
852/// `network.authentication`: the door refuses every caller that presents no token this session
853/// minted.
854#[derive(Debug, Clone, PartialEq, Eq)]
855pub struct NetworkAuthentication {
856    pub scheme: AuthenticationScheme,
857    /// The declared credentials, sorted by name. Empty when only the operator token is minted.
858    pub credentials: Vec<DoorCredential>,
859}
860
861// ── Exports ───────────────────────────────────────────────────────────────────
862
863/// Per-file read ceiling applied when `exports.files.max_bytes` is absent: 10Mi.
864pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
865
866/// What the operator discloses to processes *outside* the capsule, declared under the top-level
867/// `exports:` key.
868///
869/// A sibling of `capabilities:` rather than a member of it: a capability is something the guest
870/// holds and an export is a disclosure the operator makes. Declaring one
871/// gives the agent nothing it did not already have — it widens only the operator's reach inward,
872/// which is why it never enters the achieved-containment computation.
873#[derive(Debug, Clone, PartialEq, Eq)]
874pub struct Exports {
875    /// The declared read-only file surface. `None` — an `exports:` block with no `files:` —
876    /// means the resource plane is not declared and every request to it is denied.
877    pub files: Option<FileExport>,
878    /// The declared peer-handoff surface. A separate authoriser from [`Self::files`], over a
879    /// separate subtree: declaring one grants nothing about the other, and a capsule may declare
880    /// either, both or neither. `None` means the capsule mints no handles and its peer plane
881    /// answers `no_peer_plane`.
882    pub peer_files: Option<PeerFilesExport>,
883    /// The declared willingness to take tasks from other capsules. `None` — no `peer_tasks:` —
884    /// refuses them exactly as `accept: false` does; read it through
885    /// [`Self::accepts_peer_tasks`] rather than matching on it.
886    pub peer_tasks: Option<PeerTasksExport>,
887}
888
889impl Exports {
890    /// Whether this capsule consents to tasks from peer capsules: `true` only for an explicit
891    /// `exports.peer_tasks.accept: true`. Absent and `false` are the same answer.
892    ///
893    /// The one reading rule. [`RuntimeManifest::accepts_peer_tasks`] delegates here, so a caller
894    /// holding only the lowered `exports:` block decides exactly as one holding the manifest.
895    pub fn accepts_peer_tasks(&self) -> bool {
896        self.peer_tasks.is_some_and(|peer_tasks| peer_tasks.accept)
897    }
898}
899
900/// The `exports.peer_tasks` block: whether the capsule's A2A door serves a task another capsule's
901/// runtime sends it.
902///
903/// An export rather than a capability: tasks already arrive through the door, so consenting gives
904/// the agent nothing it did not have and opens the door to one more class of caller. It names no
905/// peer, and has no field to name one — it is a posture of this capsule, not a grant to anyone.
906/// It is independent of `network.authentication`: consenting neither requires nor implies a
907/// token.
908///
909/// A block rather than a bare boolean so that it can gain fields without a breaking change.
910#[derive(Debug, Clone, Copy, PartialEq, Eq)]
911pub struct PeerTasksExport {
912    /// Required, never inferred. `false` is the same posture as declaring no block at all.
913    pub accept: bool,
914}
915
916/// The `exports.files` block: one subtree of the workdir, readable and nothing else.
917#[derive(Debug, Clone, PartialEq, Eq)]
918pub struct FileExport {
919    /// Subtree of the capsule's accessible workdir this export opens, verbatim as declared
920    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
921    /// directory belongs to the runtime, which is the only component that may decide a path is
922    /// inside the root.
923    ///
924    /// Not required to exist when the capsule launches — the agent may create it during a task.
925    pub root: String,
926    /// Required, with exactly one legal value. A required field with one value is what makes the
927    /// read-only posture an explicit operator statement rather than an unstated default, and
928    /// leaves a future write mode somewhere to be declared rather than somewhere to leak in.
929    pub mode: ExportMode,
930    /// Per-file read ceiling in bytes, defaulting to [`DEFAULT_EXPORT_MAX_BYTES`]. Never an
931    /// aggregate budget: each file is judged on its own size, and a subtree whose total exceeds
932    /// this is still listed in full.
933    pub max_bytes: u64,
934}
935
936/// Handle lifetime applied when `exports.peer_files.max_ttl` is absent and the capsule is
937/// ephemeral (`lifecycle.after_task: exit`): 1h.
938pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
939
940/// The largest `exports.peer_files.max_ttl` a *persistent* capsule
941/// (`lifecycle.after_task: sleep`) may declare: 15m.
942///
943/// An ephemeral capsule needs no ceiling because teardown destroys the minting key and with it
944/// every outstanding handle. A persistent one has withdrawn that bound, so the declared lifetime
945/// becomes the only one — and a handle is not a durability mechanism. Enforced by the runtime,
946/// not here, because the rule reads `lifecycle.after_task` and this parser sees one block at a
947/// time.
948pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
949
950/// Per-file read ceiling applied when `exports.peer_files.max_bytes` is absent: 10Mi.
951pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
952
953/// The `exports.peer_files` block: the one subtree a `share-file` handle may name.
954///
955/// Deliberately not a mode of [`FileExport`]. The operator plane addresses files by path and
956/// enumerates them; this plane has no `list` verb and no path addressing at all, and its audience
957/// is another capsule rather than the operator. Two authorisers over two subtrees, so opening one
958/// never widens the other.
959#[derive(Debug, Clone, PartialEq, Eq)]
960pub struct PeerFilesExport {
961    /// Subtree of the capsule's accessible workdir a handle may name, verbatim as declared
962    /// (`out/`). Relative, non-empty and free of `..`, checked here; resolving it against a real
963    /// directory belongs to the runtime. Not required to exist when the capsule launches.
964    pub root: String,
965    /// The declared `max_ttl`, in seconds, or `None` when the manifest declared none.
966    ///
967    /// Kept undefaulted because the two ephemerality cases answer an absent value differently:
968    /// an ephemeral capsule falls back to [`DEFAULT_PEER_HANDLE_TTL_SECS`], and a persistent one
969    /// refuses to launch. Substituting the default here would erase the difference before
970    /// anything could act on it. See [`Self::effective_max_ttl_secs`].
971    pub max_ttl_secs: Option<u64>,
972    /// Per-file ceiling on a redeemed read, defaulting to [`DEFAULT_PEER_FILES_MAX_BYTES`].
973    /// Per file, never an aggregate budget — the same terms as [`FileExport::max_bytes`], and a
974    /// separate value from it.
975    pub max_bytes: u64,
976}
977
978impl PeerFilesExport {
979    /// The handle lifetime ceiling this export actually applies: the declared `max_ttl`, or the
980    /// ephemeral default when none was declared.
981    ///
982    /// Correct for a persistent capsule too, because one that declared no `max_ttl` never
983    /// launches — the runtime refuses it before a plane is built.
984    pub fn effective_max_ttl_secs(&self) -> u64 {
985        self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
986    }
987}
988
989/// The `capabilities.peer_fetch` block: which peers this capsule may redeem a handle against.
990///
991/// A sibling of [`NetworkCapabilities`] rather than a member of it. Fetching a peer's bytes lands
992/// a file in this capsule's own workdir, so it is an ingestion path and a prompt-injection
993/// surface, and it gets its own operator control. Declaring a destination here does not widen
994/// `capabilities.network.allow`, and a destination allowed there is not redeemable unless it is
995/// also named here.
996#[derive(Debug, Clone, PartialEq, Eq)]
997pub struct PeerFetchCapabilities {
998    /// Network destinations a handle may be redeemed against. Required and non-empty when the
999    /// block is present: an empty list is a parse error, never a silent deny. Same syntax as
1000    /// `capabilities.network.allow`, and matched by the same runtime rule matcher.
1001    pub allow: Vec<String>,
1002}
1003
1004/// The accepted spelling of every duration in the manifest, stated once so the parser and the
1005/// error it produces cannot drift apart.
1006pub const DURATION_ACCEPTED_FORM: &str =
1007    "must be a duration: an integer, optionally suffixed s/m/h/d";
1008
1009/// Parses `90`, `30s`, `15m`, `1h`, `14d` into a whole number of seconds.
1010///
1011/// A bare integer is seconds. Suffixes are lowercase and single-character; `5 minutes` and `30S`
1012/// are rejected rather than guessed at, on the same terms as [`parse_byte_size`]. Returns the
1013/// accepted-form sentence as its error so every call site reports the same rule.
1014pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
1015    let trimmed = input.trim();
1016    let (digits, multiplier) = match trimmed.strip_suffix('s') {
1017        Some(digits) => (digits, 1u64),
1018        None => match trimmed.strip_suffix('m') {
1019            Some(digits) => (digits, 60),
1020            None => match trimmed.strip_suffix('h') {
1021                Some(digits) => (digits, 3600),
1022                None => match trimmed.strip_suffix('d') {
1023                    Some(digits) => (digits, 86_400),
1024                    None => (trimmed, 1),
1025                },
1026            },
1027        },
1028    };
1029    // Not trimmed again: the whole input was trimmed above, so a space surviving here is *inside*
1030    // the value (`5 minutes`, `30 m`), which is not a spelling this accepts.
1031    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
1032        return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
1033    }
1034    digits
1035        .parse::<u64>()
1036        .ok()
1037        .and_then(|value| value.checked_mul(multiplier))
1038        .ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
1039}
1040
1041#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1042pub enum ExportMode {
1043    #[serde(rename = "read-only")]
1044    ReadOnly,
1045}
1046
1047impl ExportMode {
1048    pub fn as_str(self) -> &'static str {
1049        match self {
1050            ExportMode::ReadOnly => "read-only",
1051        }
1052    }
1053}
1054
1055impl std::fmt::Display for ExportMode {
1056    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1057        f.write_str(self.as_str())
1058    }
1059}
1060
1061/// The accepted spelling of every byte count in the manifest, stated once so the parser and the
1062/// error it produces cannot drift apart.
1063pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
1064
1065/// Parses `4096`, `1Ki`, `10Mi`, `2Gi` into a byte count.
1066///
1067/// Binary suffixes only, and case-sensitively: `10MB` is rejected rather than guessed at, because
1068/// a manifest that means 10 000 000 and a manifest that means 10 485 760 must not both parse.
1069/// Returns the accepted-form sentence as its error so every call site reports the same rule.
1070pub fn parse_byte_size(input: &str) -> Result<u64, String> {
1071    let trimmed = input.trim();
1072    let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
1073        Some(digits) => (digits, 1024u64),
1074        None => match trimmed.strip_suffix("Mi") {
1075            Some(digits) => (digits, 1024 * 1024),
1076            None => match trimmed.strip_suffix("Gi") {
1077                Some(digits) => (digits, 1024 * 1024 * 1024),
1078                None => (trimmed, 1),
1079            },
1080        },
1081    };
1082    // Deliberately not trimmed again: the whole input was trimmed above, so any space left here
1083    // is *inside* the value (`10 Mi`), which is not a spelling this accepts.
1084    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
1085        return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
1086    }
1087    digits
1088        .parse::<u64>()
1089        .ok()
1090        .and_then(|value| value.checked_mul(multiplier))
1091        .ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
1092}
1093
1094#[derive(Debug, Clone, PartialEq, Eq)]
1095pub struct RuntimeArtifact {
1096    pub name: String,
1097    pub version: String,
1098    pub runtime: ArtifactRuntime,
1099    /// Optional local source path. When set, the runtime resolves the artifact directly from
1100    /// this path instead of from the registry — no `.mur.zip`, no publish. For a skill the path
1101    /// points at a `skill.md` file or at a directory containing one (case-insensitive).
1102    /// Relative paths resolve against the directory containing `murmur.yaml`.
1103    /// Only permitted when `local_source` is true.
1104    pub source: Option<String>,
1105    /// Whether this artifact may be resolved from a local `source:` path. Declared via
1106    /// `local_source:` in the project manifest's artifact entry; when absent it defaults to
1107    /// true for `runtime: skill` and false for every other role, which is exactly the role-based
1108    /// gate this field replaced.
1109    pub local_source: bool,
1110    /// Whether this artifact's payload may be bound as the system prompt via
1111    /// `inference.system_prompt_artifact`. Declared via `prompt_payload:` in the project
1112    /// manifest's artifact entry; when absent it defaults to true for `runtime: skill` and
1113    /// false for every other role.
1114    pub prompt_payload: bool,
1115    /// Per-artifact capability grant, declared via `capabilities:` on this entry in the
1116    /// **capsule operator's own** manifest. Recognized on `runtime: hook`, `runtime: tool`,
1117    /// and `runtime: driver` entries; a `runtime: skill` entry carrying the key is rejected
1118    /// at parse time, because nothing would enforce it and a silently-ignored grant reads
1119    /// like a scoped artifact.
1120    ///
1121    /// What `None` (the key absent) means depends on the role, deliberately:
1122    /// - `hook`: full default-deny — no network and no preopened directory.
1123    /// - `tool`/`driver`: the unchanged capsule-wide ceiling. A declared block *narrows*
1124    ///   from that ceiling and can never widen past it.
1125    ///
1126    /// Deliberately never sourced from the artifact's own bundled `murmur.yaml` (see
1127    /// [`parse_hook_config_from_yaml`], which parses that file and knows nothing about
1128    /// capabilities) — an artifact pulled from a registry cannot self-grant.
1129    ///
1130    /// Reuses the whole [`Capabilities`] type for vocabulary consistency with the
1131    /// capsule-wide block, but only `network` and `filesystem` are consumed by the runtime
1132    /// per-artifact; the other sub-blocks are inert here.
1133    pub capabilities: Option<Capabilities>,
1134    /// What the runtime does when this hook's async job queue is full, declared via
1135    /// `on_overflow:` on this entry. Defaults to [`HookOverflowPolicy::Drop`] when absent.
1136    /// Accepted only on `runtime: hook` entries — every other role is rejected at parse
1137    /// time, since nothing would ever consult it there.
1138    pub on_overflow: HookOverflowPolicy,
1139    /// Operator-authored configuration for this artifact alone, declared via `config:` on this
1140    /// entry. Held as the raw YAML node: this crate accepts any mapping, and the runtime is what
1141    /// lowers it to the JSON delivered as `MURMUR_ARTIFACT_CONFIG` and refuses a shape that
1142    /// cannot travel that way.
1143    ///
1144    /// `None` is the key being absent, which means the variable is absent from the guest
1145    /// environment. `Some(Value::Null)` is `config:` written with nothing under it — a
1146    /// declaration that carries nothing, refused by the runtime rather than treated as absent.
1147    ///
1148    /// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
1149    /// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
1150    /// [`Self::capabilities`]. Operator-sourced only, and never read from the artifact's own
1151    /// bundled `murmur.yaml` — an artifact pulled from a registry cannot configure itself.
1152    ///
1153    /// Plaintext in a file that is also an audit record: secrets belong in `${VAR}` references
1154    /// and the credential-stripping path, not here.
1155    pub config: Option<serde_norway::Value>,
1156    /// The third-party upstream this artifact reaches through the credential gateway, and the
1157    /// key the runtime presents there on its behalf, declared via `gateway:` on this entry.
1158    ///
1159    /// Recognized on `runtime: hook`, `runtime: tool` and `runtime: driver` entries; a
1160    /// `runtime: skill` entry carrying the key is rejected at parse time, on the same terms as
1161    /// [`Self::capabilities`]. A `runtime: driver` entry may carry it only when it is the
1162    /// configured `transport: http` driver, whose gateway is the metered inference gateway.
1163    ///
1164    /// Operator-sourced only, and never read from the artifact's own bundled `murmur.yaml`: the
1165    /// artifact says how the key is presented (`upstream_auth:`), never where it goes.
1166    pub gateway: Option<ArtifactGateway>,
1167}
1168
1169impl RuntimeArtifact {
1170    /// The name a staged session knows this artifact by: the bare part of its full name, or a
1171    /// `source:` entry's own name. It is the model-facing tool name and the directory under
1172    /// `<runtime dir>/tools/`.
1173    #[must_use]
1174    pub fn tool_name(&self) -> &str {
1175        tool_name_of(&self.name)
1176    }
1177
1178    /// The full name this entry resolves by. A `source:` entry, named by its bare name, has none.
1179    pub fn artifact_name(&self) -> Result<ArtifactName, ArtifactNameError> {
1180        ArtifactName::parse(&self.name)
1181    }
1182}
1183
1184/// The bare part of an artifact reference as a manifest writes it: the text after the `/` of a
1185/// full name, or the whole of a bare one.
1186#[must_use]
1187pub fn tool_name_of(name: &str) -> &str {
1188    name.split_once('/').map_or(name, |(_, bare)| bare)
1189}
1190
1191/// One artifact's credential gateway, as the operator declared it on the artifact's entry.
1192///
1193/// Debug never prints a literal key: [`ApiKeyReference`]'s own `Debug` redacts it.
1194#[derive(Debug, Clone, PartialEq, Eq)]
1195pub struct ArtifactGateway {
1196    /// Absolute `http`/`https` upstream URL with a host and no query or fragment. Plain `http`
1197    /// only to `localhost` or a loopback IP literal.
1198    pub endpoint: String,
1199    /// The key presented upstream. `None` for a keyless gateway, or when the manifest was parsed
1200    /// with [`RuntimeManifest::from_yaml_str_without_secrets`].
1201    pub api_key: Option<ApiKeyReference>,
1202    /// `keyless: true` as the operator wrote it: this upstream takes no key, and the gateway
1203    /// forwards with no credential header. Exclusive with `api_key`; kept as written in both
1204    /// secret modes. Never derived from the endpoint's address.
1205    pub keyless: bool,
1206}
1207
1208#[derive(Debug, Clone, PartialEq, Eq)]
1209pub struct InferenceDriver {
1210    pub artifact: String,
1211    pub config: Option<String>,
1212}
1213
1214/// The name of the driver choice `inference.driver.artifact` with `inference.model` forms. Reserved:
1215/// no `inference.alternates` entry may take it.
1216pub const PRIMARY_DRIVER_CHOICE: &str = "primary";
1217
1218/// One `inference.alternates` entry: a named pair of a declared driver and a model that the
1219/// agent loop may be switched to while the capsule runs.
1220///
1221/// Takes no `driver.config` of its own: `inference.driver.config` reaches every declared driver,
1222/// and per-driver settings are that driver's `artifacts[].config`.
1223#[derive(Debug, Clone, PartialEq, Eq)]
1224pub struct InferenceAlternate {
1225    /// `[a-z][a-z0-9-]{0,62}`, unique among the alternates, and never [`PRIMARY_DRIVER_CHOICE`].
1226    pub name: String,
1227    /// The model string the driver sends, never empty.
1228    pub model: String,
1229    /// `driver.artifact`: an `artifacts:` entry with `runtime: driver`, which carries its own
1230    /// `gateway:` unless it is the primary's own driver.
1231    pub driver: String,
1232}
1233
1234/// What a `gateway.api_key` names, as the manifest wrote it. Parsing never resolves it: the
1235/// runtime looks the value up at staging, where it knows where credentials are kept.
1236#[derive(Clone, PartialEq, Eq)]
1237pub enum ApiKeyReference {
1238    /// `${NAME}`: a credential name, looked up in the global config's `credentials:` map and
1239    /// then in the launching environment.
1240    Environment(String),
1241    /// Any other non-empty value, used verbatim as the key.
1242    Literal(String),
1243}
1244
1245impl std::fmt::Debug for ApiKeyReference {
1246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1247        match self {
1248            Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
1249            Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
1250        }
1251    }
1252}
1253
1254#[derive(Debug, Clone, PartialEq, Eq)]
1255pub struct InferenceConfig {
1256    pub transport: String,
1257    pub model: String,
1258    /// WASM driver artifact. Always present: the inference driver under `transport: http`, the
1259    /// process driver under `transport: process`.
1260    pub driver: Option<InferenceDriver>,
1261    /// The executable to run instead of the one the process driver's `describe()` names. Absent
1262    /// for `transport: http`, and optional under `transport: process`, where the driver names its
1263    /// own binary.
1264    pub command: Option<String>,
1265    pub compaction: Option<CompactionConfig>,
1266    pub system_prompt: Option<String>,
1267    pub system_prompt_file: Option<String>,
1268    /// Name of a skill artifact declared in `artifacts:` whose `skill.md` is used as the system
1269    /// prompt. Mutually exclusive with `system_prompt` and `system_prompt_file`. The skill is
1270    /// excluded from the callable tool inventory (it's already in the system prompt).
1271    pub system_prompt_artifact: Option<String>,
1272    /// `inference.instruction_files`: work-folder files appended to the system prompt at launch,
1273    /// each as one labelled block after the manifest's own prompt, as the manifest wrote them and
1274    /// in its order. Every entry is relative and has no `..` component. `None` reads nothing and
1275    /// leaves the system prompt as the prompt keys alone make it; `Some` of an empty list also
1276    /// reads nothing, and the trace records the empty list.
1277    pub instruction_files: Option<Vec<String>>,
1278    /// `inference.max_turns`: the most inference turns one task may take, across its reopens and
1279    /// continuations, enforced as a hard ceiling by the runtime. `None` sets no cap.
1280    pub max_turns: Option<u32>,
1281    /// Maximum output tokens the model may generate per turn (`max_tokens` in the driver wire
1282    /// payload). `None` means the manifest didn't set it, and each driver choice's cap is the
1283    /// `max_output_tokens` its driver's `models:` table declares for its model.
1284    /// `transport: http` only — rejected at parse time under `transport: process`.
1285    ///
1286    /// Unrelated to [`ContextConfig::max_tokens`], which is the session-wide token budget that
1287    /// drives compaction. This one is a per-turn *output* cap.
1288    pub max_tokens: Option<u32>,
1289    /// `inference.max_session_tokens`: the most input plus output tokens this session may spend.
1290    /// `None` sets no session ceiling.
1291    ///
1292    /// Valid on both transports, measured differently on each: under `transport: http` it counts
1293    /// the runtime's own measurement of every driver call, and under `transport: process` the
1294    /// counts the harness's driver reports for each turn. Setting it against a process driver
1295    /// that reports no usage is refused at load.
1296    pub max_session_tokens: Option<u64>,
1297    /// `inference.tool_refresh`: when an artifact installed mid-session reaches the tool array
1298    /// the agent loop sends the model. Always [`ToolRefresh::Compaction`] under
1299    /// `transport: process`, where the key is refused at parse time and nothing reads it.
1300    pub tool_refresh: ToolRefresh,
1301    /// `inference.alternates`: the other driver choices the agent loop may be switched to, in
1302    /// manifest order. Empty under `transport: process`, where the key is refused at parse time,
1303    /// and for every capsule that declares none.
1304    pub alternates: Vec<InferenceAlternate>,
1305    /// `inference.retry`: how a driver call that failed transiently is made again. Always
1306    /// [`InferenceRetry::default`] under `transport: process`, where the key is refused at parse
1307    /// time and the harness retries its own provider calls.
1308    pub retry: InferenceRetry,
1309}
1310
1311/// `inference.retry`: the bounds on making a `transport: http` driver call again after a
1312/// transient failure. Every key is optional and takes its default when omitted.
1313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1314pub struct InferenceRetry {
1315    /// The most retries one call makes after its first attempt, 0–20. 0 disables retrying.
1316    pub max_retries: u32,
1317    /// The backoff before the first retry, in milliseconds, 1–60000. Each later retry doubles it.
1318    pub initial_delay_ms: u32,
1319    /// The longest backoff, and the longest provider-requested wait the runtime honours, in
1320    /// milliseconds: 1–3600000, and at least `initial_delay_ms`.
1321    pub max_delay_ms: u32,
1322}
1323
1324impl InferenceRetry {
1325    /// The most `max_retries` may be.
1326    pub const MAX_RETRIES: u32 = 20;
1327    /// The most `initial_delay_ms` may be.
1328    pub const MAX_INITIAL_DELAY_MS: u32 = 60_000;
1329    /// The most `max_delay_ms` may be.
1330    pub const MAX_DELAY_MS: u32 = 3_600_000;
1331}
1332
1333impl Default for InferenceRetry {
1334    fn default() -> Self {
1335        Self {
1336            max_retries: 6,
1337            initial_delay_ms: 1_000,
1338            max_delay_ms: 60_000,
1339        }
1340    }
1341}
1342
1343/// `inference.tool_refresh`: the turn boundary at which the http agent loop rebuilds the tool
1344/// array it sends the model, once the session's installed set has changed.
1345///
1346/// The array comes first in every provider's cached prompt prefix, so rebuilding it is a full
1347/// prompt-cache miss on the call that carries it. The start of the next task rebuilds it under
1348/// either value.
1349#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1350pub enum ToolRefresh {
1351    /// Rebuild before the first inference call after a compaction commits a replacement
1352    /// context, whose message history is already a full cache miss.
1353    #[default]
1354    Compaction,
1355    /// Rebuild before the next inference call after the install.
1356    Immediate,
1357}
1358
1359impl ToolRefresh {
1360    /// Every value, in the order refusals name them.
1361    pub const ALL: [ToolRefresh; 2] = [Self::Compaction, Self::Immediate];
1362
1363    /// The value as written in the manifest and in `trace.jsonl`.
1364    #[must_use]
1365    pub fn wire_name(self) -> &'static str {
1366        match self {
1367            Self::Compaction => "compaction",
1368            Self::Immediate => "immediate",
1369        }
1370    }
1371}
1372
1373#[derive(Debug, Clone, PartialEq)]
1374pub struct CompactionConfig {
1375    /// Fraction of context_window at which compaction fires (0.0–1.0]. Default: 0.98.
1376    pub threshold: Option<f32>,
1377    /// Model for compaction. None means use the primary inference model.
1378    pub model: Option<String>,
1379    /// System prompt for compaction. None means the compaction hook picks its own default.
1380    /// Mutually exclusive with `system_prompt_file`.
1381    pub system_prompt: Option<String>,
1382    /// Path to a local file whose contents are used as the compaction system prompt,
1383    /// resolved relative to the manifest directory when the session launches. Mutually
1384    /// exclusive with `system_prompt`.
1385    pub system_prompt_file: Option<String>,
1386    /// When true, every committed compaction appends one JSON line recording the
1387    /// replacement summary to `out/compaction-summaries.jsonl` in the session's records
1388    /// directory.
1389    /// None (the key absent) is equivalent to false: nothing is written.
1390    pub dump_summaries: Option<bool>,
1391    /// Output cap of every `run-inference` call made while a hook serves `on-compaction`, and
1392    /// the amount the spend meter reserves for each. Never 0. None keeps the cap other hook
1393    /// calls get: the primary driver's `models:` ceiling for the model called, else the
1394    /// primary choice's cap.
1395    pub max_tokens: Option<u32>,
1396    /// Compact JSON of `inference.compaction.driver_config`, always an object. Its top-level
1397    /// keys replace or add to those of `inference.driver.config` for the calls `max_tokens`
1398    /// caps, and for no other call.
1399    pub driver_config: Option<String>,
1400}
1401
1402// Threshold values are validated to (0.0, 1.0] at parse time so NaN is impossible.
1403impl Eq for CompactionConfig {}
1404
1405/// `context.seed_budget` when the manifest leaves it out: a tenth of the window.
1406/// Small enough that a seed cannot crowd out the task it precedes, large enough that a
1407/// memory hook has room to say something.
1408pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
1409
1410/// `context.seed_overflow_margin` when the manifest leaves it out. Slack above
1411/// `seed_budget` within which an over-budget seed is trimmed rather than summarized —
1412/// a few tokens over must not buy an inference call.
1413pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
1414
1415#[derive(Debug, Clone, PartialEq)]
1416pub struct ContextConfig {
1417    /// `context.max_tokens`: the context window compaction and the seed budget measure against,
1418    /// for every driver choice. `None` leaves each choice the `context_window` its driver's
1419    /// `models:` table declares for its model, and a choice with neither has no window.
1420    pub max_tokens: Option<u32>,
1421    /// Whether the runtime keeps a durable conversation record for this capsule. `true` unless
1422    /// the manifest says `record: off`; `false` turns the mechanism off entirely, creating
1423    /// nothing under `~/.murmur/conversations/`.
1424    pub record: bool,
1425    /// Directory segment under `~/.murmur/conversations/` this capsule's records live in.
1426    /// `None` means the capsule name. Validated as one path segment by the runtime, on the same
1427    /// terms as `capabilities.state.store`. Inert when [`Self::record`] is `false`.
1428    pub record_store: Option<String>,
1429    /// Fraction of [`Self::max_tokens`] an `on-task-start` hook's `seed-context` may
1430    /// occupy, in `0.0..=1.0`. Always populated; [`DEFAULT_SEED_BUDGET`] when the key is
1431    /// absent. Inert without `max_tokens`: a fraction of no ceiling is no ceiling, and a
1432    /// seed with no ceiling is rejected rather than committed unbounded.
1433    pub seed_budget: f32,
1434    /// Slack above the seed budget, as a fraction of it, within which an over-budget seed
1435    /// is trimmed from its front rather than handed to the compaction hook, in
1436    /// `0.0..=1.0`. Always populated; [`DEFAULT_SEED_OVERFLOW_MARGIN`] when the key is
1437    /// absent.
1438    pub seed_overflow_margin: f32,
1439    /// What bounds this capsule's conversation records. `None` — the `retain:` block was absent
1440    /// — keeps every record whole and forever. Inert when [`Self::record`] is `false`.
1441    pub retain: Option<ContextRetainConfig>,
1442    /// The `context.images` block: how many images a tool may return and the driver is shown.
1443    /// Always populated; [`ContextImagesConfig::default`] when the block is absent.
1444    pub images: ContextImagesConfig,
1445}
1446
1447/// `context.images.keep` when the key is absent.
1448pub const DEFAULT_IMAGES_KEEP: u32 = 5;
1449/// `context.images.max_bytes` when the key is absent: 5 MiB.
1450pub const DEFAULT_IMAGES_MAX_BYTES: u64 = 5 * 1024 * 1024;
1451/// `context.images.max_per_turn` when the key is absent.
1452pub const DEFAULT_IMAGES_MAX_PER_TURN: u32 = 5;
1453
1454/// The `context.images` block: the limits on images a tool returns or an A2A message carries.
1455///
1456/// Every key is optional and at least 1. A capsule with no `context:` block, or no `images:`
1457/// block in it, gets [`Self::default`].
1458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1459pub struct ContextImagesConfig {
1460    /// How many of the newest images in the conversation the driver receives as bytes. Each older
1461    /// image reaches it as one line naming the image. Read only on `transport: http`, where the
1462    /// runtime holds the conversation.
1463    pub keep: u32,
1464    /// The largest image, in bytes, a tool result or an A2A message may carry.
1465    pub max_bytes: u64,
1466    /// How many images one model turn's tool calls may return between them, and one A2A
1467    /// message may carry. Read only on `transport: http`.
1468    pub max_per_turn: u32,
1469}
1470
1471impl Default for ContextImagesConfig {
1472    fn default() -> Self {
1473        Self {
1474            keep: DEFAULT_IMAGES_KEEP,
1475            max_bytes: DEFAULT_IMAGES_MAX_BYTES,
1476            max_per_turn: DEFAULT_IMAGES_MAX_PER_TURN,
1477        }
1478    }
1479}
1480
1481// Both fractions are validated to 0.0..=1.0 at parse time, so NaN is impossible.
1482impl Eq for ContextConfig {}
1483
1484#[derive(Debug, Clone, PartialEq, Eq)]
1485pub struct ObservabilityConfig {
1486    pub otel_endpoint: Option<String>,
1487    pub eval: Option<EvalConfig>,
1488}
1489
1490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1491pub struct TraceConfig {
1492    /// How much of each turn's driver request this session's trace keeps — resolved from
1493    /// `trace.capture` by [`crate::trace_capture::resolve_trace_capture`].
1494    pub capture: TraceCapture,
1495    /// What bounds the session directories under the workdir. `None` — the `retain:` block was
1496    /// absent — keeps every session forever; there is no default policy, because a mechanism
1497    /// that deletes an operator's traces unless they opt out is the one default-allow in a
1498    /// runtime that is default-deny everywhere else.
1499    pub retain: Option<TraceRetainConfig>,
1500}
1501
1502/// The `trace.retain` block: what bounds the set of session directories beside the running one.
1503///
1504/// Both keys are optional and ANDed — a session survives only if it is inside every limit
1505/// declared. An empty block is a parse error, not a no-op: omitting `retain:` is how a capsule
1506/// says "keep everything".
1507#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1508pub struct TraceRetainConfig {
1509    /// Newest session directories to keep, counting the running session itself. `None` means
1510    /// the count is unbounded. Never `Some(0)`: truncating the set to nothing would delete the
1511    /// running session's own trace, so it is refused at parse time.
1512    pub max_sessions: Option<u32>,
1513    /// Age beyond which a session directory is removed, in seconds, measured from the
1514    /// millisecond timestamp inside its own uuid-v7 `ses_` id. `None` means no age limit.
1515    pub max_age_secs: Option<u64>,
1516}
1517
1518/// The `context.retain` block: what bounds this capsule's conversation records.
1519///
1520/// Both keys are optional and ANDed. `max_messages` truncates the front of the record the
1521/// launch opens; `max_age_secs` removes a context directory this capsule owns and has not
1522/// written to inside the window.
1523#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1524pub struct ContextRetainConfig {
1525    /// Messages to keep at the tail of a record. `None` means the record grows without bound.
1526    /// Never `Some(0)`: truncating a record to nothing is `mur conversation rm`, not retention.
1527    pub max_messages: Option<u32>,
1528    /// Age beyond which a record this capsule owns is removed, in seconds, measured from the
1529    /// last write to its `conversation.jsonl`. `None` means no age limit.
1530    pub max_age_secs: Option<u64>,
1531}
1532
1533#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1534pub struct EvalConfig {
1535    #[serde(skip_serializing_if = "Option::is_none")]
1536    pub dataset_id: Option<String>,
1537    pub scorers: Vec<ScorerConfig>,
1538}
1539
1540#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1541#[serde(tag = "type", rename_all = "snake_case")]
1542pub enum ScorerConfig {
1543    ExitOk { name: String },
1544    MaxTurns { name: String, max: u32 },
1545    MaxTokens { name: String, max: u64 },
1546    ToolSequence { name: String, expected: Vec<String> },
1547    LlmJudge { name: String },
1548}
1549
1550#[derive(Debug, Clone, PartialEq, Eq)]
1551pub struct NetworkCapabilities {
1552    pub allow: Vec<String>,
1553    /// Whether the capsule's shell subprocess tree may create `AF_UNIX` sockets. `false` unless
1554    /// the manifest says otherwise: a local daemon socket — `/var/run/docker.sock` above all —
1555    /// is an unmediated path to host root, and the `allow` list above only governs IP
1556    /// destinations, so nothing else in this block constrains it. Declaring `true` is the
1557    /// deliberate, auditable widening for a capsule that genuinely needs a local daemon socket;
1558    /// it is capsule-wide and coarse (a domain, not a per-path allowlist). `AF_NETLINK` and
1559    /// `AF_PACKET` have no corresponding key and are always denied.
1560    pub unix_sockets: bool,
1561}
1562
1563#[derive(Debug, Clone, PartialEq, Eq)]
1564pub struct FilesystemCapabilities {
1565    pub scope: Option<String>,
1566    /// Whether anything written *into* the session workdir may be executed. `false` unless the
1567    /// manifest says otherwise, and that default is the whole point: with it, the workdir's own
1568    /// Landlock `PathBeneath` rule withholds the `Execute` right, so the kernel — evaluating the
1569    /// resolved path itself, with no userspace round trip — refuses to exec a binary the capsule
1570    /// produced, whatever it is named. That is what makes `capabilities.shell.allow` a complete
1571    /// and sound statement rather than a name-matching convention.
1572    ///
1573    /// `true` takes the `Execute` right back for compile-and-run workflows (a capsule that
1574    /// `gcc`/`cargo build`s inside its workdir and then runs the result). The cost is not
1575    /// negotiable and is not hidden: anything the capsule writes into the workdir can then run
1576    /// regardless of `shell.allow`, so the allowlist stops being an enforceable property of that
1577    /// capsule. A capsule declaring it can therefore never achieve
1578    /// [`ContainmentClass::Scoped`] — see `capsule_runtime::containment` — and pairing it with
1579    /// `capabilities.containment: scoped` is refused at launch.
1580    pub workdir_exec: bool,
1581    /// Workdir-relative subtrees the capsule may read but must not write, in the same vocabulary
1582    /// [`Self::scope`] uses. Empty when the key is absent, which is the whole workdir writable —
1583    /// the behaviour of every capsule that declares nothing here.
1584    ///
1585    /// Entries are trimmed and empty ones dropped at parse; path *shape* is judged by the
1586    /// runtime (`capsule_runtime::protected_paths`), exactly as `scope`'s shape is.
1587    pub read_only: Vec<String>,
1588    /// Whether a `runtime: hook` entry's `"."` is the session's records directory's `hooks/`,
1589    /// read-write, in place of a workdir [`Self::scope`], so what the hook writes is outside the
1590    /// workdir the capsule's commands write. `false` when the key is absent. Refused at parse on any other role, in the capsule-wide
1591    /// block, and beside a `scope` on the same entry.
1592    pub records: bool,
1593}
1594
1595/// The `capabilities.task_io` block on a `runtime: hook` artifact entry — the operator's
1596/// grant of the `murmur:task-io/read` host import to that one hook. Recognized nowhere else:
1597/// on any other role, and in the capsule-wide block, it is rejected at parse time rather than
1598/// accepted and silently inert.
1599#[derive(Debug, Clone, PartialEq, Eq)]
1600pub struct TaskIoCapabilities {
1601    /// Whether this hook may read the in-scope task's input and result text. Never inferred:
1602    /// a `task_io:` block that omits `read:` is rejected, on the same terms as
1603    /// `capabilities.shell.interpreter_runtime[].dirs[].list_dir`.
1604    pub read: bool,
1605}
1606
1607/// The `capabilities.conversation` block on a `runtime: hook` artifact entry — the operator's
1608/// grant of the `murmur:conversation/read` host import to that one hook. Rejected at parse time
1609/// on any other artifact role; accepted but inert in the capsule-wide block, which warns
1610/// `W-SEC-016` at staging.
1611#[derive(Debug, Clone, PartialEq, Eq)]
1612pub struct ConversationCapabilities {
1613    /// Whether this hook may read the capsule's durable conversation record. Never inferred: a
1614    /// `conversation:` block that omits `read:` is rejected, exactly as `task_io:` is.
1615    pub read: bool,
1616}
1617
1618/// The `capabilities.plan` block — the operator's grant of the runtime-provided `submit-plan`
1619/// tool to the capsule's agent loop. Read from the capsule-wide block only: an artifact entry
1620/// declaring it grants nothing, because a plan is submitted by the agent and not by an artifact.
1621#[derive(Debug, Clone, PartialEq, Eq)]
1622pub struct PlanCapabilities {
1623    /// Whether this capsule's model may submit a plan for the runtime to schedule. Never
1624    /// inferred: a `plan:` block that omits `submit:` is rejected, exactly as `task_io:` is.
1625    pub submit: bool,
1626}
1627
1628/// The `capabilities.install` block — the operator's grant to pull artifacts into a running
1629/// session through `murmur:artifact-manager/manage.pull`. Read from the capsule-wide block only.
1630/// Each entry is an exact artifact name, a `<prefix>*` or `*`, as
1631/// [`crate::install_pattern::validate`] accepts it, kept verbatim in declaration order. At least
1632/// one list is non-empty: omitting the block, not declaring an empty one, is how a capsule grants
1633/// nothing.
1634#[derive(Debug, Clone, PartialEq, Eq)]
1635pub struct InstallCapabilities {
1636    /// Entries admitting skill artifacts (`skill.md`, no executable payload).
1637    pub skill: Vec<String>,
1638    /// Entries admitting tool artifacts, WASM or native alike.
1639    pub tool: Vec<String>,
1640    /// `capabilities.install.from`: where a pull may find what it installs.
1641    pub from: InstallFrom,
1642}
1643
1644/// `capabilities.install.from`: the places a pull is answered from. Absent is
1645/// [`InstallFrom::Stores`].
1646#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1647pub enum InstallFrom {
1648    /// `[stores]`: the project store, then the global store. A pull fetches nothing.
1649    #[default]
1650    Stores,
1651    /// `[stores, sources]`: the stores first, then the GitHub source of the artifact's scope,
1652    /// fetched by the runtime itself and written to no store.
1653    StoresAndSources,
1654}
1655
1656impl InstallFrom {
1657    /// Whether a pull may fetch from a scope's source.
1658    #[must_use]
1659    pub fn sources(self) -> bool {
1660        self == Self::StoresAndSources
1661    }
1662
1663    /// The list as a manifest writes it, without brackets: `stores` or `stores, sources`.
1664    #[must_use]
1665    pub fn as_list(self) -> &'static str {
1666        match self {
1667            Self::Stores => "stores",
1668            Self::StoresAndSources => "stores, sources",
1669        }
1670    }
1671}
1672
1673#[derive(Debug, Clone, PartialEq, Eq)]
1674pub struct ShellCapabilities {
1675    pub allow: Vec<String>,
1676    pub strip_env: Option<Vec<String>>,
1677    pub baseline_env: Option<Vec<String>>,
1678    /// Typed grants that widen an already-allowlisted `allow` binary's Landlock filesystem
1679    /// scope to the exact host directories its import machinery needs *outside* the workdir
1680    /// (a path-based interpreter like CPython, whose stdlib the `DT_NEEDED` closure cannot
1681    /// reach). Empty unless the manifest declares `capabilities.shell.interpreter_runtime`.
1682    /// This can only ever name specific directories with an explicit per-directory
1683    /// enumerability flag — it has no field that expands a whole install prefix.
1684    pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1685    /// Typed staged-runtime grants that name a pinned host runtime tree to bind-mount read-only
1686    /// into a `sealed` capsule's composed root. Empty unless the manifest declares
1687    /// `capabilities.shell.staged_runtime`. Mutually exclusive with [`Self::interpreter_runtime`]
1688    /// per binary: staging the tree *into* the root is what makes widening Landlock *out to* the
1689    /// host unnecessary, so declaring both for one binary is a contradiction, not a layering.
1690    pub staged_runtime: Vec<StagedRuntimeGrant>,
1691}
1692
1693/// One `capabilities.shell.interpreter_runtime` entry: an already-allowlisted binary plus the
1694/// exact host directories outside the workdir its import machinery must reach. Declaring one
1695/// couples the capsule to a specific host interpreter-version layout, so it fires `W-SEC-009`
1696/// at staging; it exists only to bridge until the staged runtime bind-mount ships.
1697#[derive(Debug, Clone, PartialEq, Eq)]
1698pub struct InterpreterRuntimeGrant {
1699    /// A binary that MUST already appear in this same block's `allow`. This mechanism can only
1700    /// narrow filesystem access alongside an exec grant that already exists — it never itself
1701    /// grants exec.
1702    pub binary: String,
1703    /// The host directories to grant. Never empty (a grant with no directories is rejected at
1704    /// parse time).
1705    pub dirs: Vec<InterpreterRuntimeDir>,
1706}
1707
1708/// One directory inside an [`InterpreterRuntimeGrant`], with its author-declared enumerability.
1709#[derive(Debug, Clone, PartialEq, Eq)]
1710pub struct InterpreterRuntimeDir {
1711    /// An absolute host path (must start with `/`) outside the workdir.
1712    pub path: String,
1713    /// `true` grants `Execute + ReadFile + ReadDir` — the directory's own entries can be
1714    /// enumerated (what CPython's `FileFinder` needs for a `sys.path` entry). `false` grants
1715    /// `Execute + ReadFile` only — files inside can still be opened by exact name, but the
1716    /// directory itself cannot be listed. Never inferred: the author must write it explicitly.
1717    pub list_dir: bool,
1718}
1719
1720/// One `capabilities.shell.staged_runtime` entry: an already-allowlisted binary, the absolute host
1721/// path of a pinned runtime tree that already exists on the launch host, and the `pin` string
1722/// identifying which build that tree is.
1723///
1724/// The mechanism this describes is the inverse of [`InterpreterRuntimeGrant`]. That one widens a
1725/// capsule's Landlock scope *outwards* so an interpreter can reach its stdlib where the host
1726/// happens to keep it — which couples the capsule to one host's directory layout and is why it
1727/// fires `W-SEC-009`. This one moves the tree *inwards*: the composed root of a `sealed` capsule
1728/// carries the runtime at the same absolute path, bind-mounted read-only, so nothing outside the
1729/// root has to stay reachable. Declaring it therefore requires an effective `sealed` floor — there
1730/// is no composed root to stage into below that — and it is refused alongside an
1731/// `interpreter_runtime` grant for the same binary.
1732///
1733/// `source_path` is deliberately a path to an *already-pinned tree* (a vendored toolchain
1734/// directory, a baked-in conda env), never a bare install prefix guessed at random: the runtime
1735/// does not resolve, discover or version-sniff it.
1736#[derive(Debug, Clone, PartialEq, Eq)]
1737pub struct StagedRuntimeGrant {
1738    /// A binary that MUST already appear in this same block's `allow`. Like
1739    /// [`InterpreterRuntimeGrant::binary`], this mechanism never itself grants exec — it only says
1740    /// where the runtime behind an existing exec grant comes from.
1741    pub binary: String,
1742    /// Absolute host path of the runtime tree to stage. Bind-mounted read-only at this same
1743    /// absolute path inside the composed root, so `sys.prefix`, shebangs and anything a previous
1744    /// turn recorded keep resolving.
1745    pub source_path: String,
1746    /// Non-empty, explicit identifier of *which build* the tree at `source_path` is. Never
1747    /// inferred from the tree's contents: it exists so a human can compare the declared pin across
1748    /// two hosts and confirm the same interpreter build shipped to both. The runtime treats it as
1749    /// an opaque string and does not parse it.
1750    pub pin: String,
1751}
1752
1753#[derive(Debug, Clone, PartialEq, Eq)]
1754pub struct SpawnCapabilities {
1755    pub allow: Vec<String>,
1756    /// `capabilities.spawn.workdir_snapshot`: the entries of [`Self::allow`] whose every
1757    /// delegation starts in a copy of this capsule's work folder as it stood when the delegation
1758    /// was made, rather than in an empty one. Full names, each also in `allow`; empty when the
1759    /// key is absent.
1760    pub workdir_snapshot: Vec<String>,
1761}
1762
1763/// Host environment variables a WASM guest may observe, from `capabilities.env`.
1764///
1765/// An empty `allow` is legitimate (a declared-but-empty block is a no-op), so unlike
1766/// `capabilities.shell.allow` this is not rejected at parse time.
1767#[derive(Debug, Clone, PartialEq, Eq, Default)]
1768pub struct EnvCapabilities {
1769    pub allow: Vec<String>,
1770}
1771
1772/// Per-guest execution limits from `capabilities.limits`.
1773///
1774/// Every field is `Option` because this type carries only what the manifest actually
1775/// declared — an omitted field stays `None` here and the runtime substitutes its own
1776/// default (see `capsule_runtime::limits::ExecutionLimits`). Keeping the "absent" state
1777/// distinct from "explicitly set" is what lets the runtime own the defaults in one place
1778/// instead of duplicating them into the manifest parser.
1779#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1780pub struct ResourceLimits {
1781    /// Cap on a guest's linear-memory growth, in bytes.
1782    pub memory_bytes: Option<usize>,
1783    /// Cap on a guest's table growth, in elements.
1784    pub table_elements: Option<usize>,
1785    /// Cap on the number of instances a single store may create.
1786    pub instances: Option<usize>,
1787    /// Wall-clock budget for a single guest invocation, in seconds.
1788    pub deadline_seconds: Option<u64>,
1789}
1790
1791/// Host-process (OS-level) resource bounds from `capabilities.resources`.
1792///
1793/// Distinct from [`ResourceLimits`] (`capabilities.limits`) in both mechanism and subject:
1794/// that block bounds a WASM *guest* inside its wasmtime store, this one bounds every *native
1795/// subprocess* the runtime spawns — `rlimit(2)` ceilings applied before `execve`, a Linux
1796/// cgroup v2 scope around the whole process tree, and a periodic workdir-size check. A capsule
1797/// that cannot escape containment can still wedge its host by forking, allocating, or writing
1798/// without bound; this is the block that stops that.
1799///
1800/// Every field is `Option` for the same reason [`ResourceLimits`]'s are: this type carries only
1801/// what the manifest actually declared, and the runtime substitutes its own default for
1802/// anything omitted (see `capsule_runtime::resources::HostResourceLimits`). An omitted field —
1803/// or an omitted block — means defaults, never "unlimited".
1804#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1805pub struct ResourceCapabilities {
1806    /// `RLIMIT_NPROC` headroom for a spawned subprocess that has no cgroup scope: the uid's task
1807    /// count at that spawn plus this value, counted in threads on Linux and processes on macOS.
1808    /// No effect on Linux when the subprocess tree has a cgroup scope, which `cgroup_pids_max`
1809    /// bounds instead.
1810    pub max_processes: Option<u64>,
1811    /// `RLIMIT_NOFILE` hard ceiling on each spawned subprocess.
1812    pub max_open_files: Option<u64>,
1813    /// `RLIMIT_FSIZE` hard ceiling, in bytes, on each spawned subprocess.
1814    pub max_file_size_bytes: Option<u64>,
1815    /// CPU time, in CPU-seconds, after which `RLIMIT_CPU` sends each spawned subprocess
1816    /// `SIGXCPU`, with `SIGKILL` one second later.
1817    pub cpu_seconds: Option<u64>,
1818    /// `RLIMIT_AS` (Linux) / `RLIMIT_DATA` (macOS) hard ceiling, in bytes, on each spawned
1819    /// subprocess.
1820    pub memory_bytes: Option<u64>,
1821    /// cgroup v2 `memory.max`, in bytes — aggregate across the whole subprocess tree. Linux only.
1822    pub cgroup_memory_bytes: Option<u64>,
1823    /// cgroup v2 `pids.max` — aggregate across the whole subprocess tree. Linux only.
1824    pub cgroup_pids_max: Option<u64>,
1825    /// cgroup v2 `cpu.max` quota as a percentage of one core (200 = two cores). Linux only.
1826    pub cgroup_cpu_percent: Option<u32>,
1827    /// cgroup v2 `io.max` read+write bytes/sec on the workdir's backing device. Linux only,
1828    /// best-effort (the backing device cannot always be resolved).
1829    pub cgroup_io_bytes_per_sec: Option<u64>,
1830    /// Ceiling on total workdir size, in bytes, enforced by a periodic check on every platform.
1831    pub workdir_max_bytes: Option<u64>,
1832}
1833
1834/// Durable, capsule-scoped state for one tool, driver or hook: a host directory outside every
1835/// session workdir, preopened into the guest as `state/`.
1836///
1837/// Absent is deny — no declaration means no second preopen and no directory anywhere. The store
1838/// is keyed by capsule rather than by workdir precisely so it survives a launch that gets a fresh
1839/// session workdir, and so two capsules launched in the same directory never share one.
1840#[derive(Debug, Clone, PartialEq, Eq, Default)]
1841pub struct StateCapabilities {
1842    /// Directory name under `~/.murmur/state/`. `None` means "use the capsule name", which is
1843    /// what the overwhelming majority of declarations want.
1844    ///
1845    /// Read from the capsule operator's own manifest entry and never from the artifact's bundled
1846    /// manifest, so a registry-pulled tool cannot claim a store that already exists. The *shape*
1847    /// of the name is not checked here — a store name is a runtime concern, refused as
1848    /// `E-CAP-009`, exactly as `capabilities.filesystem.scope`'s shape is refused as `E-CAP-002`.
1849    pub store: Option<String>,
1850}
1851
1852// ── Containment class ─────────────────────────────────────────────────────────
1853
1854/// How strongly the host must contain a capsule's *subprocess* tree, declared as a floor
1855/// by the operator and satisfied (or not) by the host's kernel.
1856///
1857/// Declaration order is enforcement order — the `Ord` derive is load-bearing, so variants
1858/// must stay listed weakest-first. Combining floors from several sources is `max`, never
1859/// `min` (see [`effective_containment_floor`]): no source can lower what another raised.
1860///
1861/// The class an operator *declares* is a requirement. The class a host actually *achieves*
1862/// is derived only from a live kernel probe (`capsule_runtime::containment`), never from
1863/// this declaration.
1864#[derive(
1865    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1866)]
1867#[serde(rename_all = "snake_case")]
1868pub enum ContainmentClass {
1869    /// No containment at all: subprocesses run as the operator, with their `HOME`, their
1870    /// whole environment, the host filesystem and network, and no `no_new_privs`, so `sudo`
1871    /// works wherever the operator's sudoers allows it. Only the operator chooses it, with
1872    /// `mur run --unconfined` or `containment: unconfined` in `~/.murmur/config.yaml`; a
1873    /// manifest, a project config and `--containment` cannot name it, so it is absent from
1874    /// [`ContainmentClass::DECLARABLE`]. It meets no declared floor.
1875    Unconfined,
1876    /// No kernel mechanism required. Capability declarations are honored by the runtime's
1877    /// own dispatch layer and by convention; anything the host cannot mediate is warned
1878    /// about, not refused. Every host satisfies this, including macOS. The default.
1879    #[default]
1880    Advisory,
1881    /// Landlock filesystem mediation + seccomp exec/network allowlisting over the *host*
1882    /// filesystem: every path outside the workdir must be an explicit manifest grant.
1883    /// Requires Linux 5.13+ with a usable Landlock ABI.
1884    Scoped,
1885    /// A private filesystem root: mount-namespace + `pivot_root` isolation onto a composed
1886    /// root, so the host filesystem is not merely mediated but absent. Achievable on an
1887    /// uncontainerised Linux host with a usable Landlock ABI, unprivileged user namespaces,
1888    /// and — where AppArmor's `restrict_unprivileged_userns` is active — the shipped
1889    /// `mur-sealed` profile loaded. A host that cannot back it refuses the launch with a
1890    /// mechanism-specific reason rather than silently degrading to `Scoped` or `Advisory`.
1891    /// See `docs/content/reference/sealed-containment-manual-verification.md`.
1892    Sealed,
1893}
1894
1895impl ContainmentClass {
1896    /// The lowercase wire name, identical in the manifest, the workspace config, the
1897    /// `--containment` flag, `trace.jsonl`, and `--explain-scope` output.
1898    pub fn as_str(self) -> &'static str {
1899        match self {
1900            Self::Unconfined => "unconfined",
1901            Self::Advisory => "advisory",
1902            Self::Scoped => "scoped",
1903            Self::Sealed => "sealed",
1904        }
1905    }
1906
1907    /// Every value, weakest first.
1908    pub const ALL: [ContainmentClass; 4] =
1909        [Self::Unconfined, Self::Advisory, Self::Scoped, Self::Sealed];
1910
1911    /// The classes a manifest, a project config or `--containment` may name, weakest first —
1912    /// the single source for the "must be one of: …" text.
1913    pub const DECLARABLE: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1914}
1915
1916impl std::fmt::Display for ContainmentClass {
1917    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1918        f.write_str(self.as_str())
1919    }
1920}
1921
1922/// A string that is not a containment class name.
1923#[derive(Debug, Clone, PartialEq, Eq)]
1924pub struct ParseContainmentClassError {
1925    pub value: String,
1926}
1927
1928impl std::fmt::Display for ParseContainmentClassError {
1929    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1930        let names: Vec<&str> = ContainmentClass::DECLARABLE
1931            .iter()
1932            .map(|class| class.as_str())
1933            .collect();
1934        write!(
1935            f,
1936            "must be one of: {}; got '{}'",
1937            names.join(", "),
1938            self.value
1939        )
1940    }
1941}
1942
1943impl std::error::Error for ParseContainmentClassError {}
1944
1945impl std::str::FromStr for ContainmentClass {
1946    type Err = ParseContainmentClassError;
1947
1948    fn from_str(value: &str) -> Result<Self, Self::Err> {
1949        match value.trim() {
1950            "unconfined" => Ok(Self::Unconfined),
1951            "advisory" => Ok(Self::Advisory),
1952            "scoped" => Ok(Self::Scoped),
1953            "sealed" => Ok(Self::Sealed),
1954            other => Err(ParseContainmentClassError {
1955                value: other.to_string(),
1956            }),
1957        }
1958    }
1959}
1960
1961/// Why a manifest cannot declare `capabilities.containment: unconfined`.
1962pub const UNCONFINED_NOT_DECLARABLE: &str = "unconfined is not a declarable floor; only the operator chooses it, with `mur run --unconfined` or `containment: unconfined` in ~/.murmur/config.yaml";
1963
1964/// The one floor a single `mur run` invocation must clear: the *strongest* class any source
1965/// asked for, defaulting to [`ContainmentClass::Advisory`] when no source declares one.
1966///
1967/// Monotonic by construction — an absent source does not participate, and no present source
1968/// can lower what another raised. Argument order carries no precedence. The inputs are
1969/// declarable classes; the unconfined stance is resolved before a floor is computed, so it
1970/// never reaches this function.
1971pub fn effective_containment_floor(
1972    workspace_default: Option<ContainmentClass>,
1973    manifest_declared: Option<ContainmentClass>,
1974    cli_flag: Option<ContainmentClass>,
1975) -> ContainmentClass {
1976    [workspace_default, manifest_declared, cli_flag]
1977        .into_iter()
1978        .flatten()
1979        .max()
1980        .unwrap_or_default()
1981}
1982
1983#[derive(Debug, Clone, PartialEq, Eq)]
1984pub struct Capabilities {
1985    pub network: Option<NetworkCapabilities>,
1986    /// Which peers this capsule may redeem a peer-file handle against. `None` means no
1987    /// `fetch-peer-file` tool exists and nothing can be fetched — absent is deny.
1988    pub peer_fetch: Option<PeerFetchCapabilities>,
1989    pub filesystem: Option<FilesystemCapabilities>,
1990    pub shell: Option<ShellCapabilities>,
1991    pub spawn: Option<SpawnCapabilities>,
1992    pub env: Option<EnvCapabilities>,
1993    pub limits: Option<ResourceLimits>,
1994    pub resources: Option<ResourceCapabilities>,
1995    /// Durable state store for this artifact. `None` — the overwhelmingly common case — is deny:
1996    /// no `state/` preopen, and no directory created anywhere. See [`StateCapabilities`].
1997    pub state: Option<StateCapabilities>,
1998    /// Per-hook grant of the `murmur:task-io/read` host import. Only ever `Some` on a
1999    /// `runtime: hook` artifact entry — see [`TaskIoCapabilities`].
2000    pub task_io: Option<TaskIoCapabilities>,
2001    /// Per-hook grant of the `murmur:conversation/read` host import — see
2002    /// [`ConversationCapabilities`].
2003    pub conversation: Option<ConversationCapabilities>,
2004    /// Capsule-wide grant of the runtime-provided `submit-plan` tool. `None` is deny: the tool's
2005    /// synthetic manifest is not written, so it is absent from the model's inventory entirely.
2006    pub plan: Option<PlanCapabilities>,
2007    /// Capsule-wide grant to pull skills and tools at runtime. `None` is deny: every
2008    /// `manage.pull` is refused. See [`InstallCapabilities`].
2009    pub install: Option<InstallCapabilities>,
2010    /// Minimum containment class this capsule declares. `None` (the overwhelmingly common
2011    /// case) means the capsule states no requirement and inherits whatever the workspace
2012    /// config or `--containment` asks for, defaulting to `advisory`.
2013    pub containment: Option<ContainmentClass>,
2014}
2015
2016#[derive(Debug, Error)]
2017pub enum RuntimeManifestError {
2018    #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
2019    NotFound(String),
2020    #[error("{0}")]
2021    YamlSyntax(String),
2022    #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
2023    MissingField { field: String },
2024    #[error(
2025        "{}: invalid artifact declaration at index {index}: {message}",
2026        MANIFEST_FILENAME
2027    )]
2028    InvalidArtifact { index: usize, message: String },
2029    #[error(
2030        "{}: invalid inference config for '{field}': {message}",
2031        MANIFEST_FILENAME
2032    )]
2033    InvalidInferenceConfig { field: String, message: String },
2034    #[error(
2035        "{}: invalid capability config for '{field}': {message}",
2036        MANIFEST_FILENAME
2037    )]
2038    InvalidCapabilities { field: String, message: String },
2039    /// A `capabilities.shell.allow` entry that is not one bare binary name. `reason` completes
2040    /// "'<entry>' …".
2041    #[error(
2042        "{}: invalid capability config for 'capabilities.shell.allow[{index}]': '{}' {reason}; \
2043         an entry is a bare binary name",
2044        MANIFEST_FILENAME,
2045        entry.escape_debug()
2046    )]
2047    ShellAllowEntry {
2048        index: usize,
2049        entry: String,
2050        reason: &'static str,
2051    },
2052    #[error(
2053        "{}: invalid exports config for '{field}': {message}",
2054        MANIFEST_FILENAME
2055    )]
2056    InvalidExports { field: String, message: String },
2057    #[error(
2058        "{}: invalid control config for '{field}': {message}",
2059        MANIFEST_FILENAME
2060    )]
2061    InvalidControl { field: String, message: String },
2062    #[error(
2063        "{}: invalid network config for '{field}': {message}",
2064        MANIFEST_FILENAME
2065    )]
2066    InvalidNetworkConfig { field: String, message: String },
2067    /// A credential's `scopes` lists a retired door method name, one of [`RETIRED_DOOR_SCOPES`].
2068    /// `replacement` is the method name that replaces it.
2069    #[error(
2070        "{}: invalid network config for '{field}': '{scope}' is a retired name of a door \
2071         method, and the door answers only the names its card lists",
2072        MANIFEST_FILENAME
2073    )]
2074    RetiredDoorScope {
2075        field: String,
2076        scope: String,
2077        replacement: &'static str,
2078    },
2079    /// `lifecycle.task_acceptance: queue` with `lifecycle.queue_depth: 0`; see
2080    /// [`LifecycleConfig::queue_has_room`].
2081    #[error(
2082        "{}: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves task_acceptance: \
2083         queue no room for a waiting task",
2084        MANIFEST_FILENAME
2085    )]
2086    QueueWithoutRoom,
2087    /// A top-level `mur_version` key, whatever its value. The message names the replacement so a
2088    /// surface that prints only the message still points at it.
2089    #[error(
2090        "{}: 'mur_version' is not a top-level key — the mur release a deploy installs is pinned \
2091         as 'deploy.mur_version'",
2092        MANIFEST_FILENAME
2093    )]
2094    TopLevelMurVersion,
2095    #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
2096    InvalidTraceConfig { field: String, message: String },
2097    /// A `lifecycle` field holds a value the runtime cannot act on; `field` is its dotted path.
2098    #[error(
2099        "{}: invalid lifecycle config for '{field}': {message}",
2100        MANIFEST_FILENAME
2101    )]
2102    InvalidLifecycleConfig { field: String, message: String },
2103    /// A field naming an artifact to resolve does not hold a full `<scope>/<name>`, or a
2104    /// `source:` entry's name is not a bare name. `hint` names the form to write instead.
2105    #[error("{}: invalid artifact name in '{field}': {message}", MANIFEST_FILENAME)]
2106    InvalidArtifactName {
2107        field: String,
2108        message: String,
2109        hint: Option<String>,
2110    },
2111    /// The manifest declares a top-level [`REGISTRY_BLOCK_KEY`].
2112    #[error("{}", REGISTRY_BLOCK_MESSAGE)]
2113    RegistryBlock,
2114    /// The manifest names [`HOST_PATHS_KEY`] at the top level, under `capabilities`, under
2115    /// `capabilities.filesystem`, or under an artifact entry's `capabilities`; `field` is the
2116    /// dotted path. Only the operator's `~/.murmur/config.yaml` lends a host folder.
2117    #[error(
2118        "{}: '{field}' cannot be declared in a manifest — host folders are lent by the operator, \
2119         never asked for by a capsule",
2120        MANIFEST_FILENAME
2121    )]
2122    HostPathsDeclared { field: String },
2123    /// A `gateway:` names an upstream but neither binds a non-blank `api_key` nor declares
2124    /// `keyless: true`. Decided on what the manifest wrote, so it fires identically with and
2125    /// without secret resolution.
2126    #[error(
2127        "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
2128         gateway.endpoint '{endpoint}' but binds no credential",
2129        MANIFEST_FILENAME
2130    )]
2131    GatewayWithoutCredential {
2132        index: usize,
2133        name: String,
2134        endpoint: String,
2135    },
2136    #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
2137    Io {
2138        path: String,
2139        #[source]
2140        source: std::io::Error,
2141    },
2142}
2143
2144#[derive(Debug, Deserialize)]
2145struct RawRuntimeManifest {
2146    name: Option<String>,
2147    version: Option<String>,
2148    #[serde(default)]
2149    artifacts: Vec<RawArtifact>,
2150    #[serde(default)]
2151    capabilities: Option<RawCapabilities>,
2152    #[serde(default)]
2153    inference: Option<RawInferenceConfig>,
2154    #[serde(default)]
2155    context: Option<RawContextConfig>,
2156    #[serde(default)]
2157    observability: Option<RawObservabilityConfig>,
2158    #[serde(default)]
2159    trace: Option<RawTraceConfig>,
2160    #[serde(default)]
2161    network: Option<RawNetworkConfig>,
2162    #[serde(default)]
2163    lifecycle: Option<RawLifecycleConfig>,
2164    #[serde(default)]
2165    exports: Option<RawExports>,
2166    #[serde(default)]
2167    control: Option<RawControl>,
2168    #[serde(default)]
2169    deploy: Option<RawDeploy>,
2170    /// Captured only to refuse it, as [`RuntimeManifestError::TopLevelMurVersion`]: the pin is
2171    /// `deploy.mur_version`. Present-detection rather than a typed value, so a number, a blank
2172    /// string and a bare `mur_version:` are all refused.
2173    #[serde(default, deserialize_with = "deserialize_present")]
2174    mur_version: Option<serde_norway::Value>,
2175    /// Captured only to refuse it. `config:` is delivered to one artifact through that artifact's
2176    /// own grant, so a capsule-wide block reaches nothing; without this field it would be one of
2177    /// the unrecognized top-level keys this manifest silently ignores.
2178    #[serde(default, deserialize_with = "deserialize_present")]
2179    config: Option<serde_norway::Value>,
2180    /// Read by `mur build` and `mur publish` off this same file to select the packaging type.
2181    /// Declared here so a manifest that is both runnable and publishable does not report its own
2182    /// build-side keys as unrecognized; the run path takes no meaning from it and never inspects
2183    /// the value, so any shape parses.
2184    #[serde(default)]
2185    #[allow(dead_code)]
2186    runtime: Option<serde_norway::Value>,
2187    /// Read by `mur build` and `mur publish`: it separates a native tool from a wasm one, both in
2188    /// the derived packaging type and in `mur publish`'s platform tagging. Ignored on the run
2189    /// path, and untyped for the same reason as `runtime:` above.
2190    #[serde(default)]
2191    #[allow(dead_code)]
2192    implementation: Option<serde_norway::Value>,
2193    /// Read by `mur build` and `mur publish` as the declared packaging type, which overrides the
2194    /// one derived from `runtime:` and `implementation:`. Ignored on the run path, and untyped for
2195    /// the same reason as `runtime:` above.
2196    #[serde(default)]
2197    #[allow(dead_code)]
2198    execution: Option<serde_norway::Value>,
2199    /// Read by `mur build` as the list of companion files to pack beside the manifest. Ignored on
2200    /// the run path, and untyped for the same reason as `runtime:` above.
2201    #[serde(default)]
2202    #[allow(dead_code)]
2203    requires_files: Option<serde_norway::Value>,
2204    #[serde(flatten)]
2205    unknown: UnknownKeys,
2206}
2207
2208#[derive(Debug, Deserialize)]
2209struct RawDeploy {
2210    #[serde(default)]
2211    mur_version: Option<String>,
2212    #[serde(flatten)]
2213    unknown: UnknownKeys,
2214}
2215
2216#[derive(Debug, Deserialize)]
2217struct RawControl {
2218    /// Untyped, so a value that is not a list of strings is refused as an
2219    /// [`RuntimeManifestError::InvalidControl`] naming the field rather than as a serde type error
2220    /// naming a line number.
2221    #[serde(default)]
2222    settings: Option<serde_norway::Value>,
2223    #[serde(default)]
2224    secrets: Option<serde_norway::Value>,
2225    #[serde(default)]
2226    agent_settings: Option<serde_norway::Value>,
2227    #[serde(flatten)]
2228    unknown: UnknownKeys,
2229}
2230
2231#[derive(Debug, Deserialize)]
2232struct RawExports {
2233    #[serde(default)]
2234    files: Option<RawFileExport>,
2235    #[serde(default)]
2236    peer_files: Option<RawPeerFilesExport>,
2237    #[serde(default)]
2238    peer_tasks: Option<RawPeerTasksExport>,
2239    #[serde(flatten)]
2240    unknown: UnknownKeys,
2241}
2242
2243#[derive(Debug, Deserialize)]
2244struct RawPeerTasksExport {
2245    // `Option` so an omitted `accept` is distinguishable from an explicit `false`: the omission
2246    // is refused rather than defaulted, because consent is never inferred.
2247    #[serde(default)]
2248    accept: Option<bool>,
2249    #[serde(flatten)]
2250    unknown: UnknownKeys,
2251}
2252
2253#[derive(Debug, Deserialize)]
2254struct RawPeerFilesExport {
2255    #[serde(default)]
2256    root: Option<String>,
2257    /// Untyped for the same reason as [`RawFileExport::max_bytes`]: `30m` is a YAML string and a
2258    /// bare `1800` is a YAML integer, and a wrong-shaped value has to reach the parser to be
2259    /// reported as an [`RuntimeManifestError::InvalidExports`] naming the field.
2260    #[serde(default)]
2261    max_ttl: Option<serde_norway::Value>,
2262    #[serde(default)]
2263    max_bytes: Option<serde_norway::Value>,
2264    #[serde(flatten)]
2265    unknown: UnknownKeys,
2266}
2267
2268#[derive(Debug, Deserialize)]
2269struct RawFileExport {
2270    #[serde(default)]
2271    root: Option<String>,
2272    #[serde(default)]
2273    mode: Option<String>,
2274    /// Deliberately untyped: `max_bytes` accepts both a bare integer and a suffixed string, and
2275    /// a wrong-shaped value has to reach [`parse_exports`] to be reported as an
2276    /// [`RuntimeManifestError::InvalidExports`] naming the field rather than as a serde type
2277    /// error naming a line number.
2278    #[serde(default)]
2279    max_bytes: Option<serde_norway::Value>,
2280    #[serde(flatten)]
2281    unknown: UnknownKeys,
2282}
2283
2284#[derive(Debug, Deserialize)]
2285struct RawLifecycleConfig {
2286    #[serde(default)]
2287    task_acceptance: Option<TaskAcceptance>,
2288    #[serde(default)]
2289    after_task: Option<AfterTask>,
2290    #[serde(default)]
2291    queue_depth: Option<usize>,
2292    #[serde(default)]
2293    input_timeout_secs: Option<u64>,
2294    #[serde(default)]
2295    conversation: Option<ConversationMode>,
2296    #[serde(default)]
2297    max_task_reopens: Option<u32>,
2298    #[serde(default)]
2299    shell_grace_secs: Option<u64>,
2300    #[serde(default)]
2301    delegation_deadline_secs: Option<u64>,
2302    #[serde(default)]
2303    deadline_notice_secs: Option<u64>,
2304    #[serde(default)]
2305    keep_working: Option<RawKeepWorking>,
2306    #[serde(default)]
2307    done_check: Option<RawDoneCheck>,
2308    #[serde(flatten)]
2309    unknown: UnknownKeys,
2310}
2311
2312#[derive(Debug, Deserialize)]
2313struct RawDoneCheck {
2314    #[serde(default)]
2315    command: Option<String>,
2316    #[serde(default)]
2317    limit: Option<u32>,
2318    #[serde(default)]
2319    timeout_secs: Option<u64>,
2320    #[serde(flatten)]
2321    unknown: UnknownKeys,
2322}
2323
2324#[derive(Debug, Deserialize)]
2325struct RawKeepWorking {
2326    #[serde(default)]
2327    message: Option<String>,
2328    #[serde(default)]
2329    limit: Option<u32>,
2330    #[serde(flatten)]
2331    unknown: UnknownKeys,
2332}
2333
2334#[derive(Debug, Deserialize)]
2335struct RawNetworkConfig {
2336    internal_port: Option<u16>,
2337    #[serde(default)]
2338    authentication: Option<RawNetworkAuthentication>,
2339    #[serde(flatten)]
2340    unknown: UnknownKeys,
2341}
2342
2343#[derive(Debug, Deserialize)]
2344struct RawNetworkAuthentication {
2345    #[serde(default)]
2346    scheme: Option<String>,
2347    /// Keyed by credential name. A `BTreeMap`, so the lowered credentials come out sorted by name
2348    /// whatever order the manifest wrote them in.
2349    #[serde(default)]
2350    credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
2351    #[serde(flatten)]
2352    unknown: UnknownKeys,
2353}
2354
2355#[derive(Debug, Deserialize)]
2356struct RawDoorCredential {
2357    /// Untyped, so a value that is not a list of strings is refused as an
2358    /// [`RuntimeManifestError::InvalidNetworkConfig`] naming the field rather than as a serde type
2359    /// error naming a line number.
2360    #[serde(default)]
2361    scopes: Option<serde_norway::Value>,
2362    #[serde(flatten)]
2363    unknown: UnknownKeys,
2364}
2365
2366#[derive(Debug, Deserialize)]
2367struct RawContextConfig {
2368    max_tokens: Option<u32>,
2369    /// `on` | `off`. Kept as a raw `String` so an unrecognized value reports as an invalid
2370    /// value for this key rather than as a serde variant error against the whole block.
2371    #[serde(default)]
2372    record: Option<String>,
2373    #[serde(default)]
2374    record_store: Option<String>,
2375    #[serde(default)]
2376    seed_budget: Option<f32>,
2377    #[serde(default)]
2378    seed_overflow_margin: Option<f32>,
2379    /// Untyped so an empty block, a null block and an unknown key inside it are each reported
2380    /// as an invalid `context.retain` rather than as a serde message about a struct field.
2381    #[serde(default, deserialize_with = "present_yaml_value")]
2382    retain: Option<serde_norway::Value>,
2383    #[serde(default)]
2384    images: Option<RawContextImages>,
2385    #[serde(flatten)]
2386    unknown: UnknownKeys,
2387}
2388
2389/// Untyped values, so `10MB` or `-1` reaches [`parse_context_images`] and is reported as an
2390/// invalid `context.images.*` naming the key rather than as a serde type error.
2391#[derive(Debug, Deserialize)]
2392struct RawContextImages {
2393    #[serde(default)]
2394    keep: Option<serde_norway::Value>,
2395    #[serde(default)]
2396    max_bytes: Option<serde_norway::Value>,
2397    #[serde(default)]
2398    max_per_turn: Option<serde_norway::Value>,
2399    #[serde(flatten)]
2400    unknown: UnknownKeys,
2401}
2402
2403#[derive(Debug, Deserialize)]
2404struct RawObservabilityConfig {
2405    #[serde(default)]
2406    otel_endpoint: Option<String>,
2407    #[serde(default)]
2408    eval: Option<RawEvalConfig>,
2409    #[serde(flatten)]
2410    unknown: UnknownKeys,
2411}
2412
2413#[derive(Debug, Deserialize)]
2414struct RawTraceConfig {
2415    /// Untyped so an unparseable mode reaches [`resolve_trace_capture`] and is reported as a
2416    /// [`RuntimeManifestError::InvalidTraceConfig`] naming the field and the accepted values,
2417    /// rather than as a serde message about an unknown variant.
2418    #[serde(default)]
2419    capture: Option<String>,
2420    /// Untyped for the same reason as `context.retain` — see [`RawContextConfig::retain`].
2421    #[serde(default, deserialize_with = "present_yaml_value")]
2422    retain: Option<serde_norway::Value>,
2423    #[serde(flatten)]
2424    unknown: UnknownKeys,
2425}
2426
2427#[derive(Debug, Deserialize)]
2428struct RawEvalConfig {
2429    #[serde(default)]
2430    dataset_id: Option<String>,
2431    #[serde(default)]
2432    scorers: Vec<RawScorerConfig>,
2433    #[serde(flatten)]
2434    unknown: UnknownKeys,
2435}
2436
2437#[derive(Debug, Deserialize)]
2438struct RawScorerConfig {
2439    #[serde(rename = "type")]
2440    scorer_type: String,
2441    name: Option<String>,
2442    max: Option<u64>,
2443    expected: Option<Vec<String>>,
2444    #[serde(flatten)]
2445    unknown: UnknownKeys,
2446}
2447
2448#[derive(Debug, Deserialize)]
2449struct RawArtifact {
2450    name: Option<String>,
2451    version: Option<String>,
2452    runtime: Option<String>,
2453    #[serde(default)]
2454    source: Option<String>,
2455    #[serde(default)]
2456    local_source: Option<bool>,
2457    #[serde(default)]
2458    prompt_payload: Option<bool>,
2459    #[serde(default)]
2460    capabilities: Option<RawCapabilities>,
2461    #[serde(default)]
2462    on_overflow: Option<String>,
2463    /// Untyped, and deserialized through [`deserialize_present`] rather than plain `Option`, so
2464    /// `config:` written with nothing under it arrives as `Some(Value::Null)` instead of
2465    /// collapsing into the absent case. The two mean different things: absent grants no variable,
2466    /// while an empty declaration is a written statement that carries nothing and is refused.
2467    #[serde(default, deserialize_with = "deserialize_present")]
2468    config: Option<serde_norway::Value>,
2469    /// `Some(None)` is `gateway:` written with nothing under it, refused as a block with no
2470    /// endpoint rather than collapsed into the absent case.
2471    #[serde(default, deserialize_with = "deserialize_present")]
2472    gateway: Option<Option<RawArtifactGateway>>,
2473    #[serde(flatten)]
2474    unknown: UnknownKeys,
2475}
2476
2477#[derive(Debug, Deserialize)]
2478struct RawArtifactGateway {
2479    #[serde(default)]
2480    endpoint: Option<String>,
2481    /// `Some(None)` is `api_key:` written as YAML null: present for the contradiction with
2482    /// `keyless: true`, and no credential for the missing-credential refusal.
2483    #[serde(default, deserialize_with = "deserialize_present")]
2484    api_key: Option<Option<String>>,
2485    /// Untyped, so a non-boolean is refused as an invalid declaration naming the field.
2486    #[serde(default)]
2487    keyless: Option<serde_norway::Value>,
2488    #[serde(flatten)]
2489    unknown: UnknownKeys,
2490}
2491
2492/// Deserialize a present key into `Some(value)` even when its value is YAML null, leaving `None`
2493/// to mean the key was absent and `#[serde(default)]` supplied it.
2494///
2495/// serde's own `Option` impl maps a null value to `None`, which erases exactly the distinction
2496/// `config:` rests on.
2497fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
2498where
2499    T: Deserialize<'de>,
2500    D: serde::Deserializer<'de>,
2501{
2502    T::deserialize(deserializer).map(Some)
2503}
2504
2505#[derive(Debug, Deserialize)]
2506struct RawCapabilities {
2507    #[serde(default)]
2508    network: Option<RawNetworkCapabilities>,
2509    #[serde(default)]
2510    peer_fetch: Option<RawPeerFetchCapabilities>,
2511    #[serde(default)]
2512    filesystem: Option<RawFilesystemCapabilities>,
2513    #[serde(default)]
2514    shell: Option<RawShellCapabilities>,
2515    #[serde(default)]
2516    spawn: Option<RawSpawnCapabilities>,
2517    #[serde(default)]
2518    env: Option<RawEnvCapabilities>,
2519    #[serde(default)]
2520    limits: Option<RawResourceLimits>,
2521    #[serde(default)]
2522    resources: Option<RawResourceCapabilities>,
2523    #[serde(default)]
2524    state: Option<RawStateCapabilities>,
2525    #[serde(default)]
2526    task_io: Option<RawTaskIoCapabilities>,
2527    #[serde(default)]
2528    conversation: Option<RawConversationCapabilities>,
2529    #[serde(default)]
2530    plan: Option<RawPlanCapabilities>,
2531    #[serde(default)]
2532    install: Option<RawInstallCapabilities>,
2533    /// Kept as a raw `String` rather than a `ContainmentClass` so a typo reports through
2534    /// `InvalidCapabilities` like every other bad capability value, instead of a bare serde
2535    /// "unknown variant" error attributed to the whole `capabilities:` block.
2536    #[serde(default)]
2537    containment: Option<String>,
2538    #[serde(flatten)]
2539    unknown: UnknownKeys,
2540}
2541
2542#[derive(Debug, Deserialize)]
2543struct RawTaskIoCapabilities {
2544    // `Option` so an omitted `read` is distinguishable from an explicit `false`: the omission
2545    // is rejected outright rather than defaulted, because a capability is never inferred.
2546    #[serde(default)]
2547    read: Option<bool>,
2548    #[serde(flatten)]
2549    unknown: UnknownKeys,
2550}
2551
2552#[derive(Debug, Deserialize)]
2553struct RawConversationCapabilities {
2554    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2555    // than defaulted.
2556    #[serde(default)]
2557    read: Option<bool>,
2558    #[serde(flatten)]
2559    unknown: UnknownKeys,
2560}
2561
2562#[derive(Debug, Deserialize)]
2563struct RawPlanCapabilities {
2564    // `Option` for the reason `RawTaskIoCapabilities::read` is: an omitted key is refused rather
2565    // than defaulted.
2566    #[serde(default)]
2567    submit: Option<bool>,
2568    #[serde(flatten)]
2569    unknown: UnknownKeys,
2570}
2571
2572#[derive(Debug, Deserialize)]
2573struct RawInstallCapabilities {
2574    #[serde(default)]
2575    skill: Vec<String>,
2576    #[serde(default)]
2577    tool: Vec<String>,
2578    #[serde(default)]
2579    from: Option<Vec<String>>,
2580    #[serde(flatten)]
2581    unknown: UnknownKeys,
2582}
2583
2584#[derive(Debug, Deserialize)]
2585struct RawSpawnCapabilities {
2586    #[serde(default)]
2587    allow: Vec<String>,
2588    #[serde(default)]
2589    workdir_snapshot: Vec<String>,
2590    #[serde(flatten)]
2591    unknown: UnknownKeys,
2592}
2593
2594#[derive(Debug, Deserialize)]
2595struct RawEnvCapabilities {
2596    #[serde(default)]
2597    allow: Vec<String>,
2598    #[serde(flatten)]
2599    unknown: UnknownKeys,
2600}
2601
2602#[derive(Debug, Deserialize)]
2603struct RawResourceLimits {
2604    #[serde(default)]
2605    memory_bytes: Option<usize>,
2606    #[serde(default)]
2607    table_elements: Option<usize>,
2608    #[serde(default)]
2609    instances: Option<usize>,
2610    #[serde(default)]
2611    deadline_seconds: Option<u64>,
2612    #[serde(flatten)]
2613    unknown: UnknownKeys,
2614}
2615
2616#[derive(Debug, Deserialize)]
2617struct RawStateCapabilities {
2618    #[serde(default)]
2619    store: Option<String>,
2620    #[serde(flatten)]
2621    unknown: UnknownKeys,
2622}
2623
2624#[derive(Debug, Deserialize)]
2625struct RawResourceCapabilities {
2626    #[serde(default)]
2627    max_processes: Option<u64>,
2628    #[serde(default)]
2629    max_open_files: Option<u64>,
2630    #[serde(default)]
2631    max_file_size_bytes: Option<u64>,
2632    #[serde(default)]
2633    cpu_seconds: Option<u64>,
2634    #[serde(default)]
2635    memory_bytes: Option<u64>,
2636    #[serde(default)]
2637    cgroup_memory_bytes: Option<u64>,
2638    #[serde(default)]
2639    cgroup_pids_max: Option<u64>,
2640    #[serde(default)]
2641    cgroup_cpu_percent: Option<u32>,
2642    #[serde(default)]
2643    cgroup_io_bytes_per_sec: Option<u64>,
2644    #[serde(default)]
2645    workdir_max_bytes: Option<u64>,
2646    #[serde(flatten)]
2647    unknown: UnknownKeys,
2648}
2649
2650#[derive(Debug, Deserialize)]
2651struct RawNetworkCapabilities {
2652    #[serde(default)]
2653    allow: Vec<String>,
2654    #[serde(default)]
2655    unix_sockets: bool,
2656    #[serde(flatten)]
2657    unknown: UnknownKeys,
2658}
2659
2660#[derive(Debug, Deserialize)]
2661struct RawPeerFetchCapabilities {
2662    #[serde(default)]
2663    allow: Vec<String>,
2664    #[serde(flatten)]
2665    unknown: UnknownKeys,
2666}
2667
2668#[derive(Debug, Deserialize)]
2669struct RawFilesystemCapabilities {
2670    #[serde(default)]
2671    scope: Option<String>,
2672    /// Same shape and defaulting convention as `RawNetworkCapabilities::unix_sockets`: a plain
2673    /// `bool` that is always present after parsing and defaults to `false`, never an `Option`.
2674    /// "The key is absent" and "the key says `false`" are the same declaration.
2675    #[serde(default)]
2676    workdir_exec: bool,
2677    /// Always a list after parsing, never an `Option`: an absent key and an empty list are the
2678    /// same declaration — nothing is protected.
2679    #[serde(default)]
2680    read_only: Vec<String>,
2681    /// Same defaulting convention as `workdir_exec`.
2682    #[serde(default)]
2683    records: bool,
2684    #[serde(flatten)]
2685    unknown: UnknownKeys,
2686}
2687
2688#[derive(Debug, Deserialize)]
2689struct RawShellCapabilities {
2690    #[serde(default)]
2691    allow: Vec<String>,
2692    #[serde(default)]
2693    strip_env: Option<Vec<String>>,
2694    #[serde(default)]
2695    baseline_env: Option<Vec<String>>,
2696    #[serde(default)]
2697    interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
2698    #[serde(default)]
2699    staged_runtime: Vec<RawStagedRuntimeGrant>,
2700    #[serde(flatten)]
2701    unknown: UnknownKeys,
2702}
2703
2704#[derive(Debug, Deserialize)]
2705struct RawInterpreterRuntimeGrant {
2706    #[serde(default)]
2707    binary: Option<String>,
2708    #[serde(default)]
2709    dirs: Vec<RawInterpreterRuntimeDir>,
2710    #[serde(flatten)]
2711    unknown: UnknownKeys,
2712}
2713
2714#[derive(Debug, Deserialize)]
2715struct RawInterpreterRuntimeDir {
2716    #[serde(default)]
2717    path: Option<String>,
2718    // `Option` so an omitted `list_dir` is distinguishable from an explicit `false`: the
2719    // omission is rejected outright rather than defaulted, because enumerability is never
2720    // inferred.
2721    #[serde(default)]
2722    list_dir: Option<bool>,
2723    #[serde(flatten)]
2724    unknown: UnknownKeys,
2725}
2726
2727#[derive(Debug, Deserialize)]
2728struct RawStagedRuntimeGrant {
2729    #[serde(default)]
2730    binary: Option<String>,
2731    #[serde(default)]
2732    source_path: Option<String>,
2733    // `Option` so an omitted `pin` reaches `parse_staged_runtime` and is rejected there with a
2734    // field-naming message, rather than defaulting to an empty string that would silently mean
2735    // "unpinned" — the one thing this field exists to make impossible.
2736    #[serde(default)]
2737    pin: Option<String>,
2738    #[serde(flatten)]
2739    unknown: UnknownKeys,
2740}
2741
2742#[derive(Debug, Deserialize)]
2743struct RawInferenceConfig {
2744    transport: Option<String>,
2745    /// Removed: the driver's upstream is `gateway.endpoint` on its artifact entry. Deserialized
2746    /// only so `parse_inference` can refuse it by name, naming where it moved.
2747    #[serde(default, deserialize_with = "deserialize_present")]
2748    endpoint: Option<serde_norway::Value>,
2749    model: Option<String>,
2750    /// Removed: the driver's key is `gateway.api_key` on its artifact entry. Deserialized only so
2751    /// `parse_inference` can refuse it by name, naming where it moved.
2752    #[serde(default, deserialize_with = "deserialize_present")]
2753    api_key: Option<serde_norway::Value>,
2754    #[serde(default)]
2755    command: Option<String>,
2756    #[serde(default)]
2757    system_prompt: Option<String>,
2758    #[serde(default)]
2759    system_prompt_file: Option<String>,
2760    #[serde(default)]
2761    system_prompt_artifact: Option<String>,
2762    #[serde(default)]
2763    instruction_files: Option<Vec<String>>,
2764    #[serde(default)]
2765    driver: Option<RawInferenceDriver>,
2766    #[serde(default)]
2767    compaction: Option<RawCompactionConfig>,
2768    #[serde(default)]
2769    max_turns: Option<u32>,
2770    /// The `inference` spelling of the reopen budget is not a live field. It is deserialized
2771    /// only so `parse_inference` can refuse it; without it serde would ignore the key.
2772    #[serde(default)]
2773    max_task_reopens: Option<u32>,
2774    #[serde(default)]
2775    max_tokens: Option<u32>,
2776    #[serde(default)]
2777    max_session_tokens: Option<u64>,
2778    #[serde(default)]
2779    tool_refresh: Option<String>,
2780    #[serde(default)]
2781    alternates: Option<Vec<RawInferenceAlternate>>,
2782    #[serde(default)]
2783    retry: Option<RawInferenceRetry>,
2784    #[serde(flatten)]
2785    unknown: UnknownKeys,
2786}
2787
2788#[derive(Debug, Deserialize)]
2789struct RawInferenceRetry {
2790    #[serde(default)]
2791    max_retries: Option<u32>,
2792    #[serde(default)]
2793    initial_delay_ms: Option<u32>,
2794    #[serde(default)]
2795    max_delay_ms: Option<u32>,
2796    #[serde(flatten)]
2797    unknown: UnknownKeys,
2798}
2799
2800#[derive(Debug, Deserialize)]
2801struct RawInferenceAlternate {
2802    #[serde(default)]
2803    name: Option<String>,
2804    #[serde(default)]
2805    model: Option<String>,
2806    #[serde(default)]
2807    driver: Option<RawInferenceDriver>,
2808    #[serde(flatten)]
2809    unknown: UnknownKeys,
2810}
2811
2812#[derive(Debug, Deserialize)]
2813struct RawCompactionConfig {
2814    threshold: Option<f32>,
2815    model: Option<String>,
2816    system_prompt: Option<String>,
2817    system_prompt_file: Option<String>,
2818    dump_summaries: Option<bool>,
2819    max_tokens: Option<u32>,
2820    #[serde(default)]
2821    driver_config: Option<serde_norway::Value>,
2822    #[serde(flatten)]
2823    unknown: UnknownKeys,
2824}
2825
2826#[derive(Debug, Deserialize)]
2827struct RawInferenceDriver {
2828    artifact: Option<String>,
2829    #[serde(default)]
2830    config: Option<serde_norway::Value>,
2831    #[serde(flatten)]
2832    unknown: UnknownKeys,
2833}
2834
2835/// The keys one `Raw*` block carried that no field of it claims.
2836///
2837/// A `BTreeMap` so the capture order is the manifest's key order sorted, which keeps the
2838/// `W-SEC-019` lines for one manifest identical on every run. The values are discarded: a key this
2839/// build does not recognize is reported by name and by containing block, and printing whatever an
2840/// operator wrote under it would put manifest contents into a diagnostic that has no use for them.
2841type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2842
2843/// A `Raw*` deserialization block that captures the keys this build does not recognize.
2844///
2845/// Implemented for every `Raw*` struct in this file, which is asserted structurally by
2846/// `every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names` rather than left to
2847/// review: a manifest key added without its block learning about it is exactly the defect
2848/// `W-SEC-019` exists to report.
2849trait RawBlock {
2850    /// Every key serde accepts on this block, in declaration order and under the name serde
2851    /// matches (so a `#[serde(rename)]` field appears under its renamed spelling). This is the
2852    /// candidate set [`crate::unknown_manifest_keys::nearest_known_key`] suggests from.
2853    const KNOWN_KEYS: &'static [&'static str];
2854
2855    fn unknown_keys(&self) -> &UnknownKeys;
2856
2857    /// Descends into the blocks this one owns, in declaration order, so the reported paths read
2858    /// down the manifest the way the document does.
2859    ///
2860    /// Defaulted to a no-op, which is correct for a block that owns no other block. A block that
2861    /// does own one must override this: `every_raw_struct_descends_into_the_blocks_it_owns` reads
2862    /// this file's own text and fails, naming the struct and the field, if it does not — an
2863    /// unwalked block reports none of its keys, silently.
2864    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2865        let _ = (path, out);
2866    }
2867}
2868
2869impl RawBlock for RawRuntimeManifest {
2870    const KNOWN_KEYS: &'static [&'static str] = &[
2871        "name",
2872        "version",
2873        "artifacts",
2874        "capabilities",
2875        "inference",
2876        "context",
2877        "observability",
2878        "trace",
2879        "network",
2880        "lifecycle",
2881        "exports",
2882        "control",
2883        "deploy",
2884        "mur_version",
2885        "config",
2886        "runtime",
2887        "implementation",
2888        "execution",
2889        "requires_files",
2890    ];
2891    fn unknown_keys(&self) -> &UnknownKeys {
2892        &self.unknown
2893    }
2894
2895    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2896        let artifacts = child_path(path, "artifacts");
2897        for (index, artifact) in self.artifacts.iter().enumerate() {
2898            collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2899        }
2900        if let Some(capabilities) = &self.capabilities {
2901            collect_block(capabilities, &child_path(path, "capabilities"), out);
2902        }
2903        if let Some(inference) = &self.inference {
2904            collect_block(inference, &child_path(path, "inference"), out);
2905        }
2906        if let Some(context) = &self.context {
2907            collect_block(context, &child_path(path, "context"), out);
2908        }
2909        if let Some(observability) = &self.observability {
2910            collect_block(observability, &child_path(path, "observability"), out);
2911        }
2912        if let Some(trace) = &self.trace {
2913            collect_block(trace, &child_path(path, "trace"), out);
2914        }
2915        if let Some(network) = &self.network {
2916            collect_block(network, &child_path(path, "network"), out);
2917        }
2918        if let Some(lifecycle) = &self.lifecycle {
2919            collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2920        }
2921        if let Some(exports) = &self.exports {
2922            collect_block(exports, &child_path(path, "exports"), out);
2923        }
2924        if let Some(control) = &self.control {
2925            collect_block(control, &child_path(path, "control"), out);
2926        }
2927        if let Some(deploy) = &self.deploy {
2928            collect_block(deploy, &child_path(path, "deploy"), out);
2929        }
2930    }
2931}
2932
2933impl RawBlock for RawDeploy {
2934    const KNOWN_KEYS: &'static [&'static str] = &["mur_version"];
2935    fn unknown_keys(&self) -> &UnknownKeys {
2936        &self.unknown
2937    }
2938}
2939
2940impl RawBlock for RawControl {
2941    const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
2942    fn unknown_keys(&self) -> &UnknownKeys {
2943        &self.unknown
2944    }
2945}
2946
2947impl RawBlock for RawExports {
2948    const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files", "peer_tasks"];
2949    fn unknown_keys(&self) -> &UnknownKeys {
2950        &self.unknown
2951    }
2952
2953    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2954        if let Some(files) = &self.files {
2955            collect_block(files, &child_path(path, "files"), out);
2956        }
2957        if let Some(peer_files) = &self.peer_files {
2958            collect_block(peer_files, &child_path(path, "peer_files"), out);
2959        }
2960        if let Some(peer_tasks) = &self.peer_tasks {
2961            collect_block(peer_tasks, &child_path(path, "peer_tasks"), out);
2962        }
2963    }
2964}
2965
2966impl RawBlock for RawPeerTasksExport {
2967    const KNOWN_KEYS: &'static [&'static str] = &["accept"];
2968    fn unknown_keys(&self) -> &UnknownKeys {
2969        &self.unknown
2970    }
2971}
2972
2973impl RawBlock for RawPeerFilesExport {
2974    const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2975    fn unknown_keys(&self) -> &UnknownKeys {
2976        &self.unknown
2977    }
2978}
2979
2980impl RawBlock for RawFileExport {
2981    const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2982    fn unknown_keys(&self) -> &UnknownKeys {
2983        &self.unknown
2984    }
2985}
2986
2987/// Resolves `lifecycle.keep_working`, refusing a missing or blank `message` and a missing or zero
2988/// `limit`. The message keeps the operator's bytes as written: only the blank check trims.
2989fn parse_keep_working(raw: RawKeepWorking) -> Result<KeepWorking, RuntimeManifestError> {
2990    let invalid = |field: &str, message: &str| RuntimeManifestError::InvalidLifecycleConfig {
2991        field: format!("lifecycle.keep_working.{field}"),
2992        message: message.to_string(),
2993    };
2994    let message = match raw.message {
2995        None => return Err(invalid("message", "is required")),
2996        Some(message) if message.trim().is_empty() => {
2997            return Err(invalid("message", "must not be blank"))
2998        }
2999        Some(message) => message,
3000    };
3001    let limit = match raw.limit {
3002        None => return Err(invalid("limit", "is required")),
3003        Some(0) => {
3004            return Err(invalid(
3005                "limit",
3006                "must be at least 1; omit lifecycle.keep_working to turn the option off",
3007            ))
3008        }
3009        Some(limit) => limit,
3010    };
3011    Ok(KeepWorking { message, limit })
3012}
3013
3014/// Resolves `lifecycle.done_check`, refusing a missing, blank or wordless `command`, a command
3015/// whose program `shell_allow` (`capabilities.shell.allow`) does not list, a missing `limit` and a
3016/// zero `timeout_secs`. The command keeps the operator's bytes as written.
3017fn parse_done_check(
3018    raw: RawDoneCheck,
3019    shell_allow: &[String],
3020) -> Result<DoneCheck, RuntimeManifestError> {
3021    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidLifecycleConfig {
3022        field: format!("lifecycle.done_check.{field}"),
3023        message,
3024    };
3025    let command = match raw.command {
3026        None => return Err(invalid("command", "is required".into())),
3027        Some(command) if command.trim().is_empty() => {
3028            return Err(invalid("command", "must not be blank".into()))
3029        }
3030        Some(command) => command,
3031    };
3032    let Some(program) = split_shell_words(&command).into_iter().next() else {
3033        return Err(invalid("command", "must name a program".into()));
3034    };
3035    if !shell_allow.contains(&program) {
3036        return Err(invalid(
3037            "command",
3038            format!("runs '{program}', which is not in capabilities.shell.allow"),
3039        ));
3040    }
3041    let Some(limit) = raw.limit else {
3042        return Err(invalid("limit", "is required".into()));
3043    };
3044    let timeout_secs = match raw.timeout_secs {
3045        None => DoneCheck::DEFAULT_TIMEOUT_SECS,
3046        Some(0) => return Err(invalid("timeout_secs", "must be at least 1".into())),
3047        Some(secs) => secs,
3048    };
3049    Ok(DoneCheck {
3050        command,
3051        limit,
3052        timeout_secs,
3053    })
3054}
3055
3056impl RawBlock for RawLifecycleConfig {
3057    const KNOWN_KEYS: &'static [&'static str] = &[
3058        "task_acceptance",
3059        "after_task",
3060        "queue_depth",
3061        "input_timeout_secs",
3062        "conversation",
3063        "max_task_reopens",
3064        "shell_grace_secs",
3065        "delegation_deadline_secs",
3066        "deadline_notice_secs",
3067        "keep_working",
3068        "done_check",
3069    ];
3070    fn unknown_keys(&self) -> &UnknownKeys {
3071        &self.unknown
3072    }
3073
3074    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3075        if let Some(keep_working) = &self.keep_working {
3076            collect_block(keep_working, &child_path(path, "keep_working"), out);
3077        }
3078        if let Some(done_check) = &self.done_check {
3079            collect_block(done_check, &child_path(path, "done_check"), out);
3080        }
3081    }
3082}
3083
3084impl RawBlock for RawDoneCheck {
3085    const KNOWN_KEYS: &'static [&'static str] = &["command", "limit", "timeout_secs"];
3086    fn unknown_keys(&self) -> &UnknownKeys {
3087        &self.unknown
3088    }
3089}
3090
3091impl RawBlock for RawKeepWorking {
3092    const KNOWN_KEYS: &'static [&'static str] = &["message", "limit"];
3093    fn unknown_keys(&self) -> &UnknownKeys {
3094        &self.unknown
3095    }
3096}
3097
3098impl RawBlock for RawNetworkConfig {
3099    const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
3100    fn unknown_keys(&self) -> &UnknownKeys {
3101        &self.unknown
3102    }
3103
3104    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3105        if let Some(authentication) = &self.authentication {
3106            collect_block(authentication, &child_path(path, "authentication"), out);
3107        }
3108    }
3109}
3110
3111impl RawBlock for RawNetworkAuthentication {
3112    const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
3113    fn unknown_keys(&self) -> &UnknownKeys {
3114        &self.unknown
3115    }
3116
3117    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3118        let credentials_path = child_path(path, "credentials");
3119        for (name, credential) in self.credentials.iter().flatten() {
3120            collect_block(credential, &child_path(&credentials_path, name), out);
3121        }
3122    }
3123}
3124
3125impl RawBlock for RawDoorCredential {
3126    const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
3127    fn unknown_keys(&self) -> &UnknownKeys {
3128        &self.unknown
3129    }
3130}
3131
3132impl RawBlock for RawContextConfig {
3133    const KNOWN_KEYS: &'static [&'static str] = &[
3134        "max_tokens",
3135        "record",
3136        "record_store",
3137        "seed_budget",
3138        "seed_overflow_margin",
3139        "retain",
3140        "images",
3141    ];
3142    fn unknown_keys(&self) -> &UnknownKeys {
3143        &self.unknown
3144    }
3145
3146    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3147        if let Some(images) = &self.images {
3148            collect_block(images, &child_path(path, "images"), out);
3149        }
3150    }
3151}
3152
3153impl RawBlock for RawContextImages {
3154    const KNOWN_KEYS: &'static [&'static str] = &["keep", "max_bytes", "max_per_turn"];
3155    fn unknown_keys(&self) -> &UnknownKeys {
3156        &self.unknown
3157    }
3158}
3159
3160impl RawBlock for RawObservabilityConfig {
3161    const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
3162    fn unknown_keys(&self) -> &UnknownKeys {
3163        &self.unknown
3164    }
3165
3166    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3167        if let Some(eval) = &self.eval {
3168            collect_block(eval, &child_path(path, "eval"), out);
3169        }
3170    }
3171}
3172
3173impl RawBlock for RawTraceConfig {
3174    const KNOWN_KEYS: &'static [&'static str] = &["capture", "retain"];
3175    fn unknown_keys(&self) -> &UnknownKeys {
3176        &self.unknown
3177    }
3178}
3179
3180impl RawBlock for RawEvalConfig {
3181    const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
3182    fn unknown_keys(&self) -> &UnknownKeys {
3183        &self.unknown
3184    }
3185
3186    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3187        let scorers = child_path(path, "scorers");
3188        for (index, scorer) in self.scorers.iter().enumerate() {
3189            collect_block(scorer, &format!("{scorers}[{index}]"), out);
3190        }
3191    }
3192}
3193
3194impl RawBlock for RawScorerConfig {
3195    const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
3196    fn unknown_keys(&self) -> &UnknownKeys {
3197        &self.unknown
3198    }
3199}
3200
3201impl RawBlock for RawArtifact {
3202    const KNOWN_KEYS: &'static [&'static str] = &[
3203        "name",
3204        "version",
3205        "runtime",
3206        "source",
3207        "local_source",
3208        "prompt_payload",
3209        "capabilities",
3210        "on_overflow",
3211        "config",
3212        "gateway",
3213    ];
3214    fn unknown_keys(&self) -> &UnknownKeys {
3215        &self.unknown
3216    }
3217
3218    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3219        if let Some(capabilities) = &self.capabilities {
3220            collect_block(capabilities, &child_path(path, "capabilities"), out);
3221        }
3222        if let Some(Some(gateway)) = &self.gateway {
3223            collect_block(gateway, &child_path(path, "gateway"), out);
3224        }
3225    }
3226}
3227
3228impl RawBlock for RawArtifactGateway {
3229    const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
3230    fn unknown_keys(&self) -> &UnknownKeys {
3231        &self.unknown
3232    }
3233}
3234
3235impl RawBlock for RawCapabilities {
3236    const KNOWN_KEYS: &'static [&'static str] = &[
3237        "network",
3238        "peer_fetch",
3239        "filesystem",
3240        "shell",
3241        "spawn",
3242        "env",
3243        "limits",
3244        "resources",
3245        "state",
3246        "task_io",
3247        "conversation",
3248        "plan",
3249        "install",
3250        "containment",
3251    ];
3252    fn unknown_keys(&self) -> &UnknownKeys {
3253        &self.unknown
3254    }
3255
3256    /// Reached from both [`RawRuntimeManifest`] and [`RawArtifact`], so the capsule-wide block and
3257    /// a per-artifact one report through identical code and differ only in the `path` they are
3258    /// handed — `capabilities.shell` against `artifacts[0].capabilities.shell`.
3259    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3260        if let Some(network) = &self.network {
3261            collect_block(network, &child_path(path, "network"), out);
3262        }
3263        if let Some(peer_fetch) = &self.peer_fetch {
3264            collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
3265        }
3266        if let Some(filesystem) = &self.filesystem {
3267            collect_block(filesystem, &child_path(path, "filesystem"), out);
3268        }
3269        if let Some(shell) = &self.shell {
3270            collect_block(shell, &child_path(path, "shell"), out);
3271        }
3272        if let Some(spawn) = &self.spawn {
3273            collect_block(spawn, &child_path(path, "spawn"), out);
3274        }
3275        if let Some(env) = &self.env {
3276            collect_block(env, &child_path(path, "env"), out);
3277        }
3278        if let Some(limits) = &self.limits {
3279            collect_block(limits, &child_path(path, "limits"), out);
3280        }
3281        if let Some(resources) = &self.resources {
3282            collect_block(resources, &child_path(path, "resources"), out);
3283        }
3284        if let Some(state) = &self.state {
3285            collect_block(state, &child_path(path, "state"), out);
3286        }
3287        if let Some(task_io) = &self.task_io {
3288            collect_block(task_io, &child_path(path, "task_io"), out);
3289        }
3290        if let Some(conversation) = &self.conversation {
3291            collect_block(conversation, &child_path(path, "conversation"), out);
3292        }
3293        if let Some(plan) = &self.plan {
3294            collect_block(plan, &child_path(path, "plan"), out);
3295        }
3296        if let Some(install) = &self.install {
3297            collect_block(install, &child_path(path, "install"), out);
3298        }
3299    }
3300}
3301
3302impl RawBlock for RawTaskIoCapabilities {
3303    const KNOWN_KEYS: &'static [&'static str] = &["read"];
3304    fn unknown_keys(&self) -> &UnknownKeys {
3305        &self.unknown
3306    }
3307}
3308
3309impl RawBlock for RawConversationCapabilities {
3310    const KNOWN_KEYS: &'static [&'static str] = &["read"];
3311    fn unknown_keys(&self) -> &UnknownKeys {
3312        &self.unknown
3313    }
3314}
3315
3316impl RawBlock for RawPlanCapabilities {
3317    const KNOWN_KEYS: &'static [&'static str] = &["submit"];
3318    fn unknown_keys(&self) -> &UnknownKeys {
3319        &self.unknown
3320    }
3321}
3322
3323impl RawBlock for RawInstallCapabilities {
3324    const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool", "from"];
3325    fn unknown_keys(&self) -> &UnknownKeys {
3326        &self.unknown
3327    }
3328}
3329
3330impl RawBlock for RawSpawnCapabilities {
3331    const KNOWN_KEYS: &'static [&'static str] = &["allow", "workdir_snapshot"];
3332    fn unknown_keys(&self) -> &UnknownKeys {
3333        &self.unknown
3334    }
3335}
3336
3337impl RawBlock for RawEnvCapabilities {
3338    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3339    fn unknown_keys(&self) -> &UnknownKeys {
3340        &self.unknown
3341    }
3342}
3343
3344impl RawBlock for RawResourceLimits {
3345    const KNOWN_KEYS: &'static [&'static str] = &[
3346        "memory_bytes",
3347        "table_elements",
3348        "instances",
3349        "deadline_seconds",
3350    ];
3351    fn unknown_keys(&self) -> &UnknownKeys {
3352        &self.unknown
3353    }
3354}
3355
3356impl RawBlock for RawStateCapabilities {
3357    const KNOWN_KEYS: &'static [&'static str] = &["store"];
3358    fn unknown_keys(&self) -> &UnknownKeys {
3359        &self.unknown
3360    }
3361}
3362
3363impl RawBlock for RawResourceCapabilities {
3364    const KNOWN_KEYS: &'static [&'static str] = &[
3365        "max_processes",
3366        "max_open_files",
3367        "max_file_size_bytes",
3368        "cpu_seconds",
3369        "memory_bytes",
3370        "cgroup_memory_bytes",
3371        "cgroup_pids_max",
3372        "cgroup_cpu_percent",
3373        "cgroup_io_bytes_per_sec",
3374        "workdir_max_bytes",
3375    ];
3376    fn unknown_keys(&self) -> &UnknownKeys {
3377        &self.unknown
3378    }
3379}
3380
3381impl RawBlock for RawNetworkCapabilities {
3382    const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
3383    fn unknown_keys(&self) -> &UnknownKeys {
3384        &self.unknown
3385    }
3386}
3387
3388impl RawBlock for RawPeerFetchCapabilities {
3389    const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3390    fn unknown_keys(&self) -> &UnknownKeys {
3391        &self.unknown
3392    }
3393}
3394
3395impl RawBlock for RawFilesystemCapabilities {
3396    const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only", "records"];
3397    fn unknown_keys(&self) -> &UnknownKeys {
3398        &self.unknown
3399    }
3400}
3401
3402impl RawBlock for RawShellCapabilities {
3403    const KNOWN_KEYS: &'static [&'static str] = &[
3404        "allow",
3405        "strip_env",
3406        "baseline_env",
3407        "interpreter_runtime",
3408        "staged_runtime",
3409    ];
3410    fn unknown_keys(&self) -> &UnknownKeys {
3411        &self.unknown
3412    }
3413
3414    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3415        let interpreter_runtime = child_path(path, "interpreter_runtime");
3416        for (index, grant) in self.interpreter_runtime.iter().enumerate() {
3417            collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
3418        }
3419        let staged_runtime = child_path(path, "staged_runtime");
3420        for (index, grant) in self.staged_runtime.iter().enumerate() {
3421            collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
3422        }
3423    }
3424}
3425
3426impl RawBlock for RawInterpreterRuntimeGrant {
3427    const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
3428    fn unknown_keys(&self) -> &UnknownKeys {
3429        &self.unknown
3430    }
3431
3432    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3433        let dirs = child_path(path, "dirs");
3434        for (index, dir) in self.dirs.iter().enumerate() {
3435            collect_block(dir, &format!("{dirs}[{index}]"), out);
3436        }
3437    }
3438}
3439
3440impl RawBlock for RawInterpreterRuntimeDir {
3441    const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
3442    fn unknown_keys(&self) -> &UnknownKeys {
3443        &self.unknown
3444    }
3445}
3446
3447impl RawBlock for RawStagedRuntimeGrant {
3448    const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
3449    fn unknown_keys(&self) -> &UnknownKeys {
3450        &self.unknown
3451    }
3452}
3453
3454impl RawBlock for RawInferenceConfig {
3455    const KNOWN_KEYS: &'static [&'static str] = &[
3456        "transport",
3457        "endpoint",
3458        "model",
3459        "api_key",
3460        "command",
3461        "system_prompt",
3462        "system_prompt_file",
3463        "system_prompt_artifact",
3464        "instruction_files",
3465        "driver",
3466        "compaction",
3467        "max_turns",
3468        "max_task_reopens",
3469        "max_tokens",
3470        "max_session_tokens",
3471        "tool_refresh",
3472        "alternates",
3473        "retry",
3474    ];
3475    fn unknown_keys(&self) -> &UnknownKeys {
3476        &self.unknown
3477    }
3478
3479    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3480        if let Some(driver) = &self.driver {
3481            collect_block(driver, &child_path(path, "driver"), out);
3482        }
3483        if let Some(compaction) = &self.compaction {
3484            collect_block(compaction, &child_path(path, "compaction"), out);
3485        }
3486        if let Some(retry) = &self.retry {
3487            collect_block(retry, &child_path(path, "retry"), out);
3488        }
3489        if let Some(alternates) = &self.alternates {
3490            let alternates_path = child_path(path, "alternates");
3491            for (index, alternate) in alternates.iter().enumerate() {
3492                collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
3493            }
3494        }
3495    }
3496}
3497
3498impl RawBlock for RawInferenceRetry {
3499    const KNOWN_KEYS: &'static [&'static str] =
3500        &["max_retries", "initial_delay_ms", "max_delay_ms"];
3501    fn unknown_keys(&self) -> &UnknownKeys {
3502        &self.unknown
3503    }
3504}
3505
3506impl RawBlock for RawInferenceAlternate {
3507    const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
3508    fn unknown_keys(&self) -> &UnknownKeys {
3509        &self.unknown
3510    }
3511
3512    fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3513        if let Some(driver) = &self.driver {
3514            collect_block(driver, &child_path(path, "driver"), out);
3515        }
3516    }
3517}
3518
3519impl RawBlock for RawCompactionConfig {
3520    const KNOWN_KEYS: &'static [&'static str] = &[
3521        "threshold",
3522        "model",
3523        "system_prompt",
3524        "system_prompt_file",
3525        "dump_summaries",
3526        "max_tokens",
3527        "driver_config",
3528    ];
3529    fn unknown_keys(&self) -> &UnknownKeys {
3530        &self.unknown
3531    }
3532}
3533
3534impl RawBlock for RawInferenceDriver {
3535    const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
3536    fn unknown_keys(&self) -> &UnknownKeys {
3537        &self.unknown
3538    }
3539}
3540
3541/// Walks a parsed raw manifest and reports every key no block of it claimed, each block's own
3542/// keys before the blocks it owns, in the order the blocks appear in the manifest's own type.
3543///
3544/// Called once, immediately after `serde_norway` returns and before any field is moved out, so the
3545/// result covers the whole document rather than the parts validation happens to reach. Reporting
3546/// only; nothing here can refuse a manifest.
3547fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
3548    let mut out = Vec::new();
3549    collect_block(raw, "", &mut out);
3550    out
3551}
3552
3553/// One block's captured keys, each paired with the nearest key that block does recognize, then
3554/// the same for every block it owns.
3555///
3556/// `path` is empty for the manifest root, which is what
3557/// [`crate::unknown_manifest_keys::UnknownManifestKey`] renders as "at the top level".
3558fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
3559    for key in block.unknown_keys().keys() {
3560        out.push(UnknownManifestKey {
3561            key: key.clone(),
3562            block_path: path.to_string(),
3563            nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
3564        });
3565    }
3566    block.walk_children(path, out);
3567}
3568
3569/// The dotted path of `field` inside the block at `parent`, built in one place so no
3570/// [`RawBlock::walk_children`] has to reproduce the rule that the manifest root has no prefix.
3571/// An indexed child appends its own `[index]` to the result.
3572fn child_path(parent: &str, field: &str) -> String {
3573    if parent.is_empty() {
3574        return field.to_string();
3575    }
3576    format!("{parent}.{field}")
3577}
3578
3579/// Read a manifest file's text, mapping a missing file to
3580/// [`RuntimeManifestError::NotFound`] and any other read failure to
3581/// [`RuntimeManifestError::Io`].
3582///
3583/// For a caller that needs the manifest text as well as the parsed manifest — `mur doctor` reads
3584/// the references a manifest makes out of the same bytes it parses, so the file is opened once.
3585pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
3586    fs::read_to_string(path).map_err(|source| {
3587        if source.kind() == std::io::ErrorKind::NotFound {
3588            return RuntimeManifestError::NotFound(path.display().to_string());
3589        }
3590
3591        RuntimeManifestError::Io {
3592            path: path.display().to_string(),
3593            source,
3594        }
3595    })
3596}
3597
3598#[must_use = "validated runtime manifest is required before run"]
3599pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
3600    let content = read_runtime_manifest_text(path)?;
3601
3602    RuntimeManifest::from_yaml_str(&content)
3603}
3604
3605/// One `${VAR}` reference a manifest makes, and the field that makes it.
3606#[derive(Debug, Clone, PartialEq, Eq)]
3607pub struct ReferencedEnvVariable {
3608    /// The manifest field holding the reference, e.g. `artifacts.murmur-driver-anthropic.gateway.api_key`.
3609    pub field: String,
3610    /// The variable the reference names.
3611    pub variable: String,
3612}
3613
3614impl ReferencedEnvVariable {
3615    /// Whether the reference is a gateway's `api_key` — a credential the runtime also answers
3616    /// from the global config's `credentials:` map, not only from the environment.
3617    pub fn is_gateway_credential(&self) -> bool {
3618        self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
3619    }
3620}
3621
3622/// The narrow view of a manifest this scan needs: the fields whose value may be an `${ENV_REF}`,
3623/// and nothing else. Every other key is ignored, so a manifest this build would refuse for an
3624/// unrelated reason still yields its references.
3625///
3626/// Deliberately not one of the `Raw*` structs: those carry the unknown-key overflow map that
3627/// makes `warn_on_unknown_manifest_keys` complete, and this one is a second, partial reader of a
3628/// document that reader has already covered.
3629#[derive(Deserialize)]
3630struct ReferencedManifestFields {
3631    #[serde(default)]
3632    artifacts: Vec<ReferencedArtifactFields>,
3633}
3634
3635#[derive(Deserialize)]
3636struct ReferencedArtifactFields {
3637    #[serde(default)]
3638    name: Option<String>,
3639    #[serde(default)]
3640    gateway: Option<ReferencedGatewayFields>,
3641}
3642
3643#[derive(Deserialize)]
3644struct ReferencedGatewayFields {
3645    #[serde(default)]
3646    api_key: Option<String>,
3647}
3648
3649/// Every environment variable `manifest_yaml` references through an `${ENV_REF}`, sorted by
3650/// variable name and deduplicated.
3651///
3652/// Never fails and never reads the environment: unparseable YAML, an absent field and a literal
3653/// (non-reference) value all yield an empty list. What counts as a reference is decided by the
3654/// same `parse_env_reference` the resolving parse applies, so a name reported here is exactly a
3655/// name [`RuntimeManifest::from_yaml_str`] would have looked up.
3656pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
3657    let Ok(raw) = serde_norway::from_str::<ReferencedManifestFields>(manifest_yaml) else {
3658        return Vec::new();
3659    };
3660
3661    let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
3662    for artifact in raw.artifacts {
3663        let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
3664            continue;
3665        };
3666        if let Some(variable) = parse_env_reference(api_key.trim()) {
3667            let name = artifact.name.unwrap_or_default();
3668            referenced.push(ReferencedEnvVariable {
3669                field: format!("artifacts.{}.gateway.api_key", name.trim()),
3670                variable: variable.to_string(),
3671            });
3672        }
3673    }
3674
3675    referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
3676    referenced.dedup();
3677    referenced
3678}
3679
3680/// Whether a parse keeps what the manifest says about a secret it references.
3681///
3682/// The two [`RuntimeManifest`] constructors differ by this value and nothing else, so validation
3683/// cannot drift between a manifest `mur run` accepts and one a referee accepts. Neither reads the
3684/// process environment.
3685#[derive(Clone, Copy, PartialEq, Eq)]
3686enum SecretResolution {
3687    /// Every `gateway.api_key` is kept as an [`ApiKeyReference`], unresolved.
3688    Resolve,
3689    /// Every `gateway.api_key` is left unread. Each parsed [`ArtifactGateway::api_key`] is `None`
3690    /// whatever the manifest wrote.
3691    Skip,
3692}
3693
3694impl RuntimeManifest {
3695    pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
3696        Self::parse(input, SecretResolution::Resolve)
3697    }
3698
3699    /// Parse a manifest without resolving any secret it references.
3700    ///
3701    /// Validation is identical to [`RuntimeManifest::from_yaml_str`] on every block, `artifacts`
3702    /// included, with one difference: every resulting [`ArtifactGateway::api_key`] is `None`,
3703    /// whether the manifest wrote an `${ENV_REF}` or a literal key.
3704    ///
3705    /// For a caller that reads a foreign capsule's manifest for its capability policy alone —
3706    /// `mur-roost` refereeing a delegation — and is entitled to none of the credentials that
3707    /// manifest names.
3708    pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
3709        Self::parse(input, SecretResolution::Skip)
3710    }
3711
3712    fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
3713        let raw: RawRuntimeManifest = serde_norway::from_str(input).map_err(|err| {
3714            if let Some(location) = err.location() {
3715                RuntimeManifestError::YamlSyntax(format!(
3716                    "{}: YAML syntax error at line {}, column {}: {}",
3717                    MANIFEST_FILENAME,
3718                    location.line(),
3719                    location.column(),
3720                    err
3721                ))
3722            } else {
3723                RuntimeManifestError::YamlSyntax(format!(
3724                    "{MANIFEST_FILENAME}: YAML syntax error: {err}"
3725                ))
3726            }
3727        })?;
3728
3729        // The key is not a field of `RawRuntimeManifest`, so it lands in the unknown-key map.
3730        if raw.unknown.contains_key(REGISTRY_BLOCK_KEY) {
3731            return Err(RuntimeManifestError::RegistryBlock);
3732        }
3733        // Neither is `host_paths`, in any block a capsule could write it in.
3734        check_no_host_paths(&raw)?;
3735
3736        // Collected before any field is moved out of `raw`, so the walk sees the whole document
3737        // rather than the part validation happens to reach before its first refusal.
3738        let unknown_keys = collect_unknown_keys(&raw);
3739
3740        if raw.mur_version.is_some() {
3741            return Err(RuntimeManifestError::TopLevelMurVersion);
3742        }
3743
3744        let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
3745            RuntimeManifestError::MissingField {
3746                field: "name".to_string(),
3747            }
3748        })?;
3749
3750        let version = raw
3751            .version
3752            .filter(|s| !s.trim().is_empty())
3753            .ok_or_else(|| RuntimeManifestError::MissingField {
3754                field: "version".to_string(),
3755            })?;
3756
3757        // Read off the raw entries because a parse that skips secrets keeps no `ApiKeyReference`,
3758        // and `control.secrets` is checked against what the manifest wrote either way.
3759        let gateway_references = raw_gateway_references(&raw.artifacts);
3760
3761        let artifacts = raw
3762            .artifacts
3763            .into_iter()
3764            .enumerate()
3765            .map(|(index, artifact)| {
3766                let name = artifact
3767                    .name
3768                    .filter(|s| !s.trim().is_empty())
3769                    .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3770                        index,
3771                        message: "missing required field 'name'".to_string(),
3772                    })?;
3773
3774                let source = artifact.source.filter(|s| !s.trim().is_empty());
3775                check_entry_name(index, &name, source.is_some())?;
3776
3777                let runtime = match artifact.runtime.as_deref() {
3778                    None | Some("tool") => ArtifactRuntime::Tool,
3779                    Some("wasm") | Some("native") => {
3780                        return Err(RuntimeManifestError::InvalidArtifact {
3781                            index,
3782                            message: format!(
3783                                "use 'runtime: tool'; implementation is declared in the \
3784                                 artifact's own manifest (got '{}')",
3785                                artifact.runtime.as_deref().unwrap_or("")
3786                            ),
3787                        });
3788                    }
3789                    Some("driver") => ArtifactRuntime::Driver,
3790                    Some("hook") => ArtifactRuntime::Hook,
3791                    Some("skill") => ArtifactRuntime::Skill,
3792                    Some(other) => {
3793                        return Err(RuntimeManifestError::InvalidArtifact {
3794                            index,
3795                            message: format!(
3796                                "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
3797                            ),
3798                        });
3799                    }
3800                };
3801
3802                // Capabilities are keyed on declared properties, not on the role. When the
3803                // property is absent we derive it from the role, which reproduces the previous
3804                // skill-only gate exactly and leaves every existing manifest unchanged.
3805                let local_source = artifact
3806                    .local_source
3807                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3808                let prompt_payload = artifact
3809                    .prompt_payload
3810                    .unwrap_or(runtime == ArtifactRuntime::Skill);
3811
3812                // `source:` requires the artifact to declare `local_source: true`.
3813                if source.is_some() && !local_source {
3814                    return Err(RuntimeManifestError::InvalidArtifact {
3815                        index,
3816                        message: format!(
3817                            "artifact '{name}' declares 'source:' but does not declare \
3818                             'local_source: true' (runtime: {})",
3819                            runtime.as_str()
3820                        ),
3821                    });
3822                }
3823
3824                // When `source:` is present, `version:` is optional — substitute "local".
3825                // If both are set, `version:` is ignored and a warning is printed to stderr.
3826                let version = if source.is_some() {
3827                    if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
3828                        crate::warn_line(format_args!(
3829                            "warning: artifact '{name}' declares both 'source:' and \
3830                             'version: {explicit}'; version is ignored for local-source skills \
3831                             (using 'local')"
3832                        ));
3833                    }
3834                    "local".to_string()
3835                } else {
3836                    artifact
3837                        .version
3838                        .filter(|s| !s.trim().is_empty())
3839                        .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3840                            index,
3841                            message: "missing required field 'version'".to_string(),
3842                        })?
3843                };
3844
3845                // Per-artifact `capabilities:` is enforced on every role that actually
3846                // executes — `hook` (default-deny grant) and `tool`/`driver` (narrowing
3847                // below the capsule ceiling). A `skill` has no execution surface, so the
3848                // block would be silently ignored there; that reads like a scoped artifact
3849                // when it is not, hence a parse error rather than a no-op.
3850                let capabilities = match artifact.capabilities {
3851                    None => None,
3852                    Some(raw_caps) => {
3853                        // The install grant is held by the session, not by one artifact: a
3854                        // per-artifact block would reach no pull at all.
3855                        if raw_caps.install.is_some() {
3856                            return Err(RuntimeManifestError::InvalidArtifact {
3857                                index,
3858                                message: format!(
3859                                    "artifact '{name}' declares 'capabilities.install:'; the \
3860                                     grant is capsule-wide and is only recognized in the \
3861                                     top-level capabilities block"
3862                                ),
3863                            });
3864                        }
3865                        if runtime == ArtifactRuntime::Skill {
3866                            return Err(RuntimeManifestError::InvalidArtifact {
3867                                index,
3868                                message: format!(
3869                                    "artifact '{name}' declares per-artifact 'capabilities:' but \
3870                                     has 'runtime: {}'; per-artifact capabilities are only \
3871                                     recognized on 'runtime: hook', 'runtime: tool', and \
3872                                     'runtime: driver' entries — use the capsule-wide top-level \
3873                                     'capabilities:' block instead",
3874                                    runtime.as_str()
3875                                ),
3876                            });
3877                        }
3878                        // `task_io` grants the `murmur:task-io/read` host import, which only
3879                        // hook components can be handed. On a tool or driver entry nothing
3880                        // would enforce it, and a silently-inert grant reads like a scoped
3881                        // artifact — the same reason `on_overflow:` below is a parse error off
3882                        // a hook.
3883                        if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
3884                            return Err(RuntimeManifestError::InvalidArtifact {
3885                                index,
3886                                message: format!(
3887                                    "artifact '{name}' declares 'capabilities.task_io:' but has \
3888                                     'runtime: {}'; the key grants the murmur:task-io/read host \
3889                                     import and is only recognized on 'runtime: hook' entries",
3890                                    runtime.as_str()
3891                                ),
3892                            });
3893                        }
3894                        // `filesystem.records` replaces a hook's workdir scope with the records
3895                        // directory's `hooks/`. Only a hook is preopened that way, and a hook has
3896                        // one `"."`, so the two declarations cannot both hold.
3897                        if let Some(filesystem) = raw_caps
3898                            .filesystem
3899                            .as_ref()
3900                            .filter(|filesystem| filesystem.records)
3901                        {
3902                            if runtime != ArtifactRuntime::Hook {
3903                                return Err(RuntimeManifestError::InvalidArtifact {
3904                                    index,
3905                                    message: format!(
3906                                        "artifact '{name}' declares \
3907                                         'capabilities.filesystem.records: true' but has \
3908                                         'runtime: {}'; the key gives a hook the records \
3909                                         directory's hooks/ and is only recognized on \
3910                                         'runtime: hook' entries",
3911                                        runtime.as_str()
3912                                    ),
3913                                });
3914                            }
3915                            if filesystem
3916                                .scope
3917                                .as_deref()
3918                                .is_some_and(|scope| !scope.trim().is_empty())
3919                            {
3920                                return Err(RuntimeManifestError::InvalidArtifact {
3921                                    index,
3922                                    message: format!(
3923                                        "artifact '{name}' declares both \
3924                                         'capabilities.filesystem.records: true' and \
3925                                         'capabilities.filesystem.scope'; a hook's \".\" is \
3926                                         either the records directory's hooks/ or a workdir \
3927                                         scope — declare one"
3928                                    ),
3929                                });
3930                            }
3931                        }
3932                        // `conversation` grants the `murmur:conversation/read` host import, on the
3933                        // same terms as `task_io` above: only a hook component's world can import
3934                        // it, so on any other role the grant would be silently inert.
3935                        if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
3936                            return Err(RuntimeManifestError::InvalidArtifact {
3937                                index,
3938                                message: format!(
3939                                    "artifact '{name}' declares 'capabilities.conversation:' but \
3940                                     has 'runtime: {}'; the key grants the \
3941                                     murmur:conversation/read host import and is only recognized \
3942                                     on 'runtime: hook' entries",
3943                                    runtime.as_str()
3944                                ),
3945                            });
3946                        }
3947                        parse_capabilities(Some(raw_caps))?
3948                    }
3949                };
3950
3951                // `on_overflow:` governs an async hook's job queue, which only exists for
3952                // `runtime: hook`. On any other role it would be silently ignored, so it is
3953                // a parse error for the same reason per-artifact `capabilities:` is on a
3954                // skill. It stays legal on a *blocking* hook entry (simply inert): the
3955                // execution mode lives in the artifact's own manifest and is unknown here.
3956                let on_overflow = match artifact.on_overflow.as_deref() {
3957                    None => HookOverflowPolicy::default(),
3958                    Some(raw) => {
3959                        if runtime != ArtifactRuntime::Hook {
3960                            return Err(RuntimeManifestError::InvalidArtifact {
3961                                index,
3962                                message: format!(
3963                                    "artifact '{name}' declares 'on_overflow:' but has \
3964                                     'runtime: {}'; the key governs an async hook's job queue \
3965                                     and is only recognized on 'runtime: hook' entries",
3966                                    runtime.as_str()
3967                                ),
3968                            });
3969                        }
3970                        match raw {
3971                            "drop" => HookOverflowPolicy::Drop,
3972                            "block" => HookOverflowPolicy::Block,
3973                            other => {
3974                                return Err(RuntimeManifestError::InvalidArtifact {
3975                                    index,
3976                                    message: format!(
3977                                        "artifact '{name}' declares unknown on_overflow \
3978                                         '{other}'; expected: drop, block"
3979                                    ),
3980                                });
3981                            }
3982                        }
3983                    }
3984                };
3985
3986                // `config:` reaches an artifact through that artifact's own grant, and a skill
3987                // holds no grant and runs no component — nothing would ever deliver it. Refused
3988                // rather than ignored, for the reason the two gates above are: a key that reads
3989                // like configuration and is silently dropped is worse than one that is refused.
3990                // The block's *shape* is the runtime's business, not this parser's; only the
3991                // role is decided here.
3992                if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
3993                    return Err(RuntimeManifestError::InvalidArtifact {
3994                        index,
3995                        message: format!(
3996                            "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
3997                             is recognized only on 'runtime: hook', 'runtime: tool', and \
3998                             'runtime: driver' entries",
3999                            runtime.as_str()
4000                        ),
4001                    });
4002                }
4003
4004                let gateway = match artifact.gateway {
4005                    None => None,
4006                    Some(raw_gateway) => {
4007                        if runtime == ArtifactRuntime::Skill {
4008                            return Err(RuntimeManifestError::InvalidArtifact {
4009                                index,
4010                                message: format!(
4011                                    "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
4012                                     the key is recognized only on 'runtime: hook', \
4013                                     'runtime: tool', and 'runtime: driver' entries",
4014                                    runtime.as_str()
4015                                ),
4016                            });
4017                        }
4018                        Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
4019                    }
4020                };
4021
4022                Ok(RuntimeArtifact {
4023                    name,
4024                    version,
4025                    runtime,
4026                    source,
4027                    local_source,
4028                    prompt_payload,
4029                    capabilities,
4030                    on_overflow,
4031                    config: artifact.config,
4032                    gateway,
4033                })
4034            })
4035            .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
4036
4037        // The capsule-wide block reaches capsule, tool and driver components, none of which
4038        // can be handed the murmur:task-io/read import. Rejected rather than ignored, on the
4039        // same terms as declaring it on a non-hook artifact entry.
4040        if raw
4041            .capabilities
4042            .as_ref()
4043            .is_some_and(|caps| caps.task_io.is_some())
4044        {
4045            return Err(RuntimeManifestError::InvalidCapabilities {
4046                field: "capabilities.task_io".to_string(),
4047                message: "is recognized only on 'runtime: hook' artifact entries, not in the \
4048                          capsule-wide capabilities block — the grant is per-hook"
4049                    .to_string(),
4050            });
4051        }
4052        // `filesystem.records` names a hook's preopen, and the capsule-wide block preopens nothing
4053        // for any hook. Refused rather than ignored, on the `task_io` block's terms.
4054        if raw
4055            .capabilities
4056            .as_ref()
4057            .and_then(|caps| caps.filesystem.as_ref())
4058            .is_some_and(|filesystem| filesystem.records)
4059        {
4060            return Err(RuntimeManifestError::InvalidCapabilities {
4061                field: "capabilities.filesystem.records".to_string(),
4062                message: "is recognized only on 'runtime: hook' artifact entries, not in the \
4063                          capsule-wide capabilities block — the grant is per-hook"
4064                    .to_string(),
4065            });
4066        }
4067        // Config is delivered on one artifact's own grant, so there is no capsule-wide form of
4068        // it: the capsule's guest holds no artifact grant, and a top-level block would reach no
4069        // component at all. Refused rather than ignored, on the same terms as the capsule-wide
4070        // `task_io` block above.
4071        if raw.config.is_some() {
4072            return Err(RuntimeManifestError::InvalidCapabilities {
4073                field: "config".to_string(),
4074                message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
4075                          'runtime: driver' entry that reads it — there is no capsule-wide \
4076                          config block"
4077                    .to_string(),
4078            });
4079        }
4080        let capabilities = parse_capabilities(raw.capabilities)?;
4081        let inference = parse_inference(raw.inference)?;
4082        refuse_install_under_process_transport(capabilities.as_ref(), inference.as_ref())?;
4083        validate_driver_gateways(&artifacts, inference.as_ref())?;
4084        validate_driver_choices(&artifacts, inference.as_ref())?;
4085        validate_process_driver_entry(&artifacts, inference.as_ref())?;
4086
4087        // Validate system_prompt_artifact: must name a declared artifact whose payload may be
4088        // bound as the system prompt (`prompt_payload`, defaulted from the role when absent).
4089        if let Some(ref sp_art) = inference
4090            .as_ref()
4091            .and_then(|i| i.system_prompt_artifact.clone())
4092        {
4093            let matching = artifacts.iter().find(|a| &a.name == sp_art);
4094            match matching {
4095                None => {
4096                    return Err(RuntimeManifestError::InvalidInferenceConfig {
4097                        field: "inference.system_prompt_artifact".to_string(),
4098                        message: not_declared_message(&artifacts, sp_art),
4099                    });
4100                }
4101                Some(a) if !a.prompt_payload => {
4102                    return Err(RuntimeManifestError::InvalidInferenceConfig {
4103                        field: "inference.system_prompt_artifact".to_string(),
4104                        message: format!(
4105                            "artifact '{sp_art}' (runtime: {}) does not declare \
4106                             'prompt_payload: true', which system_prompt_artifact requires",
4107                            a.runtime.as_str()
4108                        ),
4109                    });
4110                }
4111                Some(_) => {} // valid
4112            }
4113        }
4114        let context = parse_context(raw.context)?;
4115        let observability = parse_observability(raw.observability);
4116        let trace = raw
4117            .trace
4118            .map(|raw_trace| {
4119                let capture = resolve_trace_capture(raw_trace.capture.as_deref())?;
4120                let retain = parse_trace_retain(raw_trace.retain)?;
4121                Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
4122            })
4123            .transpose()?;
4124        let network = raw
4125            .network
4126            .map(|n| {
4127                Ok::<_, RuntimeManifestError>(NetworkConfig {
4128                    internal_port: n.internal_port,
4129                    authentication: parse_network_authentication(n.authentication)?,
4130                })
4131            })
4132            .transpose()?;
4133        refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
4134        let lifecycle = raw
4135            .lifecycle
4136            .map(|raw_lc| {
4137                let defaults = LifecycleConfig::default();
4138                let lifecycle = LifecycleConfig {
4139                    task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
4140                    after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
4141                    queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
4142                    input_timeout_secs: raw_lc.input_timeout_secs,
4143                    conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
4144                    max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
4145                    shell_grace_secs: raw_lc.shell_grace_secs,
4146                    delegation_deadline_secs: raw_lc
4147                        .delegation_deadline_secs
4148                        .unwrap_or(defaults.delegation_deadline_secs),
4149                    deadline_notice_secs: raw_lc.deadline_notice_secs,
4150                    keep_working: raw_lc.keep_working.map(parse_keep_working).transpose()?,
4151                    done_check: raw_lc
4152                        .done_check
4153                        .map(|raw| {
4154                            let shell_allow = capabilities
4155                                .as_ref()
4156                                .and_then(|capabilities| capabilities.shell.as_ref())
4157                                .map_or(&[][..], |shell| shell.allow.as_slice());
4158                            parse_done_check(raw, shell_allow)
4159                        })
4160                        .transpose()?,
4161                };
4162                if lifecycle.queue_has_room() {
4163                    Ok(lifecycle)
4164                } else {
4165                    Err(RuntimeManifestError::QueueWithoutRoom)
4166                }
4167            })
4168            .transpose()?;
4169
4170        let exports = parse_exports(raw.exports)?;
4171        let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
4172        validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
4173
4174        Ok(Self {
4175            name,
4176            version,
4177            artifacts,
4178            capabilities,
4179            inference,
4180            context,
4181            observability,
4182            trace,
4183            network,
4184            lifecycle,
4185            exports,
4186            control,
4187            deploy: raw.deploy.map(|deploy| DeployConfig {
4188                mur_version: deploy.mur_version.filter(|s| !s.trim().is_empty()),
4189            }),
4190            unknown_keys,
4191        })
4192    }
4193}
4194
4195/// Every artifact entry whose `gateway.api_key` is written as `${NAME}`, as `(artifact, NAME)`.
4196fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
4197    artifacts
4198        .iter()
4199        .filter_map(|artifact| {
4200            let name = artifact.name.as_deref()?.trim();
4201            let written = artifact
4202                .gateway
4203                .as_ref()?
4204                .as_ref()?
4205                .api_key
4206                .as_ref()?
4207                .as_ref()?;
4208            let variable = parse_env_reference(written.trim())?;
4209            Some((name.to_string(), variable.to_string()))
4210        })
4211        .collect()
4212}
4213
4214/// Lowers the top-level `control:` block. A block that declares nothing is no block.
4215///
4216/// `gateway_references` is every `(artifact, NAME)` pair an entry's `gateway.api_key: ${NAME}`
4217/// wrote. A secret must back one of them, and must not back the configured `transport: http`
4218/// driver's: that credential is resolved at staging and keys every turn, so a session launched
4219/// without it could make no inference call at all.
4220fn parse_control(
4221    raw: Option<RawControl>,
4222    gateway_references: &[(String, String)],
4223    inference: Option<&InferenceConfig>,
4224) -> Result<Option<ControlConfig>, RuntimeManifestError> {
4225    let Some(raw) = raw else {
4226        return Ok(None);
4227    };
4228    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
4229        field: field.to_string(),
4230        message,
4231    };
4232    let settings_written = control_string_list("control.settings", raw.settings)?;
4233    let secrets_written = control_string_list("control.secrets", raw.secrets)?;
4234    let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
4235    if settings_written.is_empty()
4236        && secrets_written.is_empty()
4237        && agent_settings_written.is_empty()
4238    {
4239        return Ok(None);
4240    }
4241    let Some(inference) = inference else {
4242        return Err(refuse(
4243            "control",
4244            "is declared on a capsule with no inference: block; the control surface is served on \
4245             an agent capsule's A2A listener, and a script capsule opens none"
4246                .to_string(),
4247        ));
4248    };
4249
4250    let mut settings = Vec::new();
4251    for entry in settings_written {
4252        let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
4253            let controllable: Vec<&str> = ControllableSetting::ALL
4254                .iter()
4255                .map(|setting| setting.wire_name())
4256                .collect();
4257            return Err(refuse(
4258                "control.settings",
4259                format!(
4260                    "'{entry}' is not a controllable setting; the controllable settings are: {}",
4261                    controllable.join(", ")
4262                ),
4263            ));
4264        };
4265        if settings.contains(&setting) {
4266            return Err(refuse(
4267                "control.settings",
4268                format!("'{entry}' is listed more than once"),
4269            ));
4270        }
4271        match setting {
4272            ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
4273                return Err(refuse(
4274                    "control.settings",
4275                    format!(
4276                        "'{entry}' is controllable only under inference.transport: http; a \
4277                         transport: {} harness sets its own output cap",
4278                        inference.transport
4279                    ),
4280                ));
4281            }
4282            ControllableSetting::InferenceMaxTokens => {}
4283            ControllableSetting::InferenceDriver => {
4284                check_driver_setting(inference).map_err(|message| {
4285                    refuse("control.settings", format!("'{entry}' {message}"))
4286                })?;
4287            }
4288        }
4289        settings.push(setting);
4290    }
4291
4292    let mut agent_settings = Vec::new();
4293    for entry in agent_settings_written {
4294        let Some(setting) = ControllableSetting::from_wire_name(&entry)
4295            .filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
4296        else {
4297            let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
4298                .iter()
4299                .map(|setting| setting.wire_name())
4300                .collect();
4301            return Err(refuse(
4302                "control.agent_settings",
4303                format!(
4304                    "'{entry}' is not a setting the agent may change; control.agent_settings \
4305                     accepts only: {}",
4306                    settable.join(", ")
4307                ),
4308            ));
4309        };
4310        if agent_settings.contains(&setting) {
4311            return Err(refuse(
4312                "control.agent_settings",
4313                format!("'{entry}' is listed more than once"),
4314            ));
4315        }
4316        check_driver_setting(inference)
4317            .map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
4318        agent_settings.push(setting);
4319    }
4320
4321    let inference_driver = (inference.transport == "http")
4322        .then(|| {
4323            inference
4324                .driver
4325                .as_ref()
4326                .map(|driver| driver.artifact.as_str())
4327        })
4328        .flatten();
4329    let mut secrets: Vec<String> = Vec::new();
4330    for entry in secrets_written {
4331        if !is_valid_env_variable(&entry) {
4332            return Err(refuse(
4333                "control.secrets",
4334                format!(
4335                    "'{entry}' is not a credential name; a name is uppercase letters, digits and \
4336                     underscores, and does not start with a digit"
4337                ),
4338            ));
4339        }
4340        if secrets.contains(&entry) {
4341            return Err(refuse(
4342                "control.secrets",
4343                format!("'{entry}' is listed more than once"),
4344            ));
4345        }
4346        let backing: Vec<&str> = gateway_references
4347            .iter()
4348            .filter(|(_, variable)| *variable == entry)
4349            .map(|(artifact, _)| artifact.as_str())
4350            .collect();
4351        if backing.is_empty() {
4352            return Err(refuse(
4353                "control.secrets",
4354                format!(
4355                    "'{entry}' is not referenced by any artifact's gateway.api_key as \
4356                     ${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
4357                ),
4358            ));
4359        }
4360        if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
4361            return Err(refuse(
4362                "control.secrets",
4363                format!(
4364                    "'{entry}' keys the configured inference driver's gateway \
4365                     (artifacts.{driver}.gateway.api_key); the inference credential is resolved \
4366                     at launch and cannot be supplied by a controller"
4367                ),
4368            ));
4369        }
4370        secrets.push(entry);
4371    }
4372
4373    Ok(Some(ControlConfig {
4374        settings,
4375        secrets,
4376        agent_settings,
4377    }))
4378}
4379
4380/// Why `inference.driver` cannot be listed for `inference`, or `Ok` when it can: only under
4381/// `transport: http`, and only with at least one alternate to select.
4382fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
4383    if inference.transport != "http" {
4384        return Err(format!(
4385            "is controllable only under inference.transport: http; a transport: {} harness owns \
4386             the model it runs",
4387            inference.transport
4388        ));
4389    }
4390    if inference.alternates.is_empty() {
4391        return Err(
4392            "selects among inference.alternates, and this capsule declares none; declare an \
4393             alternate to switch to"
4394                .to_string(),
4395        );
4396    }
4397    Ok(())
4398}
4399
4400/// `value` as a list of strings, trimmed, for `field`. Absent and YAML null are the empty list.
4401fn control_string_list(
4402    field: &str,
4403    value: Option<serde_norway::Value>,
4404) -> Result<Vec<String>, RuntimeManifestError> {
4405    let refuse = || RuntimeManifestError::InvalidControl {
4406        field: field.to_string(),
4407        message: "must be a list of names".to_string(),
4408    };
4409    let items = match value {
4410        None | Some(serde_norway::Value::Null) => return Ok(Vec::new()),
4411        Some(serde_norway::Value::Sequence(items)) => items,
4412        Some(_) => return Err(refuse()),
4413    };
4414    items
4415        .into_iter()
4416        .map(|item| match item {
4417            serde_norway::Value::String(name) => Ok(name.trim().to_string()),
4418            _ => Err(refuse()),
4419        })
4420        .collect()
4421}
4422
4423/// Lowers `network.authentication`. An absent or null block is a public door.
4424fn parse_network_authentication(
4425    raw: Option<RawNetworkAuthentication>,
4426) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
4427    let Some(raw) = raw else {
4428        return Ok(None);
4429    };
4430    let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
4431        field: field.to_string(),
4432        message,
4433    };
4434    let schemes: Vec<&str> = AuthenticationScheme::ALL
4435        .iter()
4436        .map(|scheme| scheme.wire_name())
4437        .collect();
4438    let scheme = match raw.scheme.as_deref().map(str::trim) {
4439        None | Some("") => {
4440            return Err(refuse(
4441                "network.authentication.scheme",
4442                format!("is required; the schemes are: {}", schemes.join(", ")),
4443            ))
4444        }
4445        Some(written) => AuthenticationScheme::ALL
4446            .iter()
4447            .copied()
4448            .find(|scheme| scheme.wire_name() == written)
4449            .ok_or_else(|| {
4450                refuse(
4451                    "network.authentication.scheme",
4452                    format!(
4453                        "'{written}' is not a door authentication scheme; the schemes are: {}",
4454                        schemes.join(", ")
4455                    ),
4456                )
4457            })?,
4458    };
4459
4460    let mut credentials = Vec::new();
4461    for (name, credential) in raw.credentials.unwrap_or_default() {
4462        if name == OPERATOR_CREDENTIAL {
4463            return Err(refuse(
4464                "network.authentication.credentials",
4465                format!(
4466                    "'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
4467                     token, which holds every scope"
4468                ),
4469            ));
4470        }
4471        if !is_valid_credential_name(&name) {
4472            return Err(refuse(
4473                "network.authentication.credentials",
4474                format!(
4475                    "'{name}' is not a credential name; a name is a lowercase letter followed by \
4476                     at most {} lowercase letters, digits, '_' or '-'",
4477                    MAX_CREDENTIAL_NAME_LEN - 1
4478                ),
4479            ));
4480        }
4481        let field = format!("network.authentication.credentials.{name}.scopes");
4482        let scopes = door_scope_list(&field, credential.scopes)?;
4483        credentials.push(DoorCredential { name, scopes });
4484    }
4485
4486    Ok(Some(NetworkAuthentication {
4487        scheme,
4488        credentials,
4489    }))
4490}
4491
4492/// `^[a-z][a-z0-9_-]{0,31}$`.
4493fn is_valid_credential_name(name: &str) -> bool {
4494    let mut chars = name.chars();
4495    chars.next().is_some_and(|first| first.is_ascii_lowercase())
4496        && name.len() <= MAX_CREDENTIAL_NAME_LEN
4497        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
4498}
4499
4500/// One credential's `scopes`: required, non-empty, each in [`DOOR_SCOPES`] and listed once.
4501fn door_scope_list(
4502    field: &str,
4503    value: Option<serde_norway::Value>,
4504) -> Result<Vec<String>, RuntimeManifestError> {
4505    let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
4506        field: field.to_string(),
4507        message,
4508    };
4509    let items = match value {
4510        None | Some(serde_norway::Value::Null) => {
4511            return Err(refuse(
4512                "is required: a credential reaches only the scopes it lists".to_string(),
4513            ))
4514        }
4515        Some(serde_norway::Value::Sequence(items)) => items,
4516        Some(_) => return Err(refuse("must be a list of scopes".to_string())),
4517    };
4518    if items.is_empty() {
4519        return Err(refuse(
4520            "lists no scope; a credential must reach at least one".to_string(),
4521        ));
4522    }
4523    let mut scopes: Vec<String> = Vec::new();
4524    for item in items {
4525        let serde_norway::Value::String(scope) = item else {
4526            return Err(refuse("must be a list of scopes".to_string()));
4527        };
4528        let scope = scope.trim().to_string();
4529        if let Some(&(_, replacement)) = RETIRED_DOOR_SCOPES
4530            .iter()
4531            .find(|(retired, _)| *retired == scope)
4532        {
4533            return Err(RuntimeManifestError::RetiredDoorScope {
4534                field: field.to_string(),
4535                scope,
4536                replacement,
4537            });
4538        }
4539        if !DOOR_SCOPES.contains(&scope.as_str()) {
4540            return Err(refuse(format!(
4541                "'{scope}' is not a door scope; the scopes are: {}",
4542                DOOR_SCOPES.join(", ")
4543            )));
4544        }
4545        if scopes.contains(&scope) {
4546            return Err(refuse(format!("'{scope}' is listed more than once")));
4547        }
4548        scopes.push(scope);
4549    }
4550    Ok(scopes)
4551}
4552
4553/// Refuses `network.authentication` on a capsule that declares `capabilities.spawn.allow`.
4554///
4555/// A delegated child posts its completion to its parent's door, and nothing issues a child a token
4556/// for that door, so an authenticated parent would refuse every completion it asked for.
4557fn refuse_authenticated_delegating_parent(
4558    network: Option<&NetworkConfig>,
4559    capabilities: Option<&Capabilities>,
4560) -> Result<(), RuntimeManifestError> {
4561    let authenticated = network.is_some_and(|network| network.authentication.is_some());
4562    let delegates = capabilities
4563        .and_then(|capabilities| capabilities.spawn.as_ref())
4564        .is_some_and(|spawn| !spawn.allow.is_empty());
4565    if authenticated && delegates {
4566        return Err(RuntimeManifestError::InvalidNetworkConfig {
4567            field: "network.authentication".to_string(),
4568            message: "cannot be declared together with capabilities.spawn.allow: a delegated \
4569                      child posts its outcome to this door, and a delegated child cannot yet be \
4570                      issued a token for this door"
4571                .to_string(),
4572        });
4573    }
4574    Ok(())
4575}
4576
4577/// Lowers the top-level `exports:` block, rejecting anything the runtime would otherwise have to
4578/// decide about later.
4579///
4580/// The `root` rules are checked here and never again by a caller: a gateway that "cleans up" a
4581/// path is a gateway that has taken over the containment boundary, so the only legal answer to a
4582/// root naming somewhere outside the workdir is a refusal.
4583fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
4584    let Some(raw_exports) = raw else {
4585        return Ok(None);
4586    };
4587    let files = raw_exports.files.map(parse_file_export).transpose()?;
4588    let peer_files = raw_exports
4589        .peer_files
4590        .map(parse_peer_files_export)
4591        .transpose()?;
4592    let peer_tasks = raw_exports
4593        .peer_tasks
4594        .map(|raw| {
4595            raw.accept
4596                .map(|accept| PeerTasksExport { accept })
4597                .ok_or_else(|| RuntimeManifestError::InvalidExports {
4598                    field: "exports.peer_tasks.accept".to_string(),
4599                    message: "must be set explicitly to true or false — consent to peer tasks is \
4600                              never inferred"
4601                        .to_string(),
4602                })
4603        })
4604        .transpose()?;
4605    Ok(Some(Exports {
4606        files,
4607        peer_files,
4608        peer_tasks,
4609    }))
4610}
4611
4612/// Lowers `exports.peer_files`, on exactly the same terms as [`parse_file_export`]: a root that
4613/// could name somewhere outside the workdir is refused here and never repaired later.
4614///
4615/// `max_ttl` is left as the operator declared it, including absent. Whether absent is legal
4616/// depends on `lifecycle.after_task`, which is a different block of the same manifest and a
4617/// different kind of question — one about what survives teardown rather than about whether a
4618/// value is well-formed. The runtime asks it, at launch, and refuses with `E-CAP-008`.
4619fn parse_peer_files_export(
4620    raw: RawPeerFilesExport,
4621) -> Result<PeerFilesExport, RuntimeManifestError> {
4622    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4623        field: field.to_string(),
4624        message,
4625    };
4626
4627    let root = raw
4628        .root
4629        .map(|root| root.trim().to_string())
4630        .filter(|root| !root.is_empty())
4631        .ok_or_else(|| {
4632            invalid(
4633                "exports.peer_files.root",
4634                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4635            )
4636        })?;
4637    check_export_root_shape("exports.peer_files.root", &root)?;
4638
4639    let max_ttl_secs = match raw.max_ttl {
4640        None => None,
4641        Some(value) => {
4642            let text = scalar_text(&value).ok_or_else(|| {
4643                invalid(
4644                    "exports.peer_files.max_ttl",
4645                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
4646                )
4647            })?;
4648            let parsed = parse_duration_secs(&text)
4649                .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
4650            if parsed == 0 {
4651                return Err(invalid(
4652                    "exports.peer_files.max_ttl",
4653                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
4654                ));
4655            }
4656            Some(parsed)
4657        }
4658    };
4659
4660    let max_bytes = match raw.max_bytes {
4661        None => DEFAULT_PEER_FILES_MAX_BYTES,
4662        Some(value) => {
4663            let text = scalar_text(&value).ok_or_else(|| {
4664                invalid(
4665                    "exports.peer_files.max_bytes",
4666                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4667                )
4668            })?;
4669            let parsed = parse_byte_size(&text)
4670                .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
4671            if parsed == 0 {
4672                return Err(invalid(
4673                    "exports.peer_files.max_bytes",
4674                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4675                ));
4676            }
4677            parsed
4678        }
4679    };
4680
4681    Ok(PeerFilesExport {
4682        root,
4683        max_ttl_secs,
4684        max_bytes,
4685    })
4686}
4687
4688/// A YAML scalar as the text the byte-size and duration parsers expect, or `None` for a mapping
4689/// or a sequence — a shape neither parser could report on without quoting the whole node.
4690/// `Some` for a key that is present, whatever its value — including an explicit null.
4691///
4692/// The plain `Option<serde_norway::Value>` a `#[serde(default)]` field gets collapses `retain:`
4693/// with nothing under it into the same `None` an absent key produces, and those two say opposite
4694/// things: absent means "keep everything", and empty is refused.
4695fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_norway::Value>, D::Error>
4696where
4697    D: serde::Deserializer<'de>,
4698{
4699    serde_norway::Value::deserialize(deserializer).map(Some)
4700}
4701
4702fn scalar_text(value: &serde_norway::Value) -> Option<String> {
4703    match value {
4704        serde_norway::Value::String(text) => Some(text.clone()),
4705        serde_norway::Value::Number(number) => Some(number.to_string()),
4706        _ => None,
4707    }
4708}
4709
4710/// Why a manifest path that must stay inside the work folder is refused.
4711enum RelativePathRefusal {
4712    ParentDir,
4713    Absolute,
4714}
4715
4716/// The shape rule shared by every manifest path resolved beneath the accessible workdir:
4717/// relative, no `..`, no absolute prefix. `.` components are allowed.
4718fn relative_path_refusal(path: &str) -> Option<RelativePathRefusal> {
4719    Path::new(path)
4720        .components()
4721        .find_map(|component| match component {
4722            std::path::Component::Normal(_) | std::path::Component::CurDir => None,
4723            std::path::Component::ParentDir => Some(RelativePathRefusal::ParentDir),
4724            std::path::Component::RootDir | std::path::Component::Prefix(_) => {
4725                Some(RelativePathRefusal::Absolute)
4726            }
4727        })
4728}
4729
4730/// The shared `root` shape rule: relative, no `..`, no absolute prefix. Both export blocks
4731/// resolve their root against the same accessible workdir, so both refuse the same shapes with
4732/// the same sentence.
4733fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
4734    let message = match relative_path_refusal(root) {
4735        None => return Ok(()),
4736        Some(RelativePathRefusal::ParentDir) => {
4737            format!("'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}")
4738        }
4739        Some(RelativePathRefusal::Absolute) => {
4740            format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}")
4741        }
4742    };
4743    Err(RuntimeManifestError::InvalidExports {
4744        field: field.to_string(),
4745        message,
4746    })
4747}
4748
4749const INSTRUCTION_FILE_ACCEPTED_FORM: &str = "must be a relative path inside the work folder";
4750
4751/// Checks every `inference.instruction_files` entry and keeps the list as written, in order. The
4752/// runtime resolves each entry beneath the accessible work folder at staging, so a shape that
4753/// could only name a path outside it is refused here, before anything is staged.
4754fn parse_instruction_files(entries: Vec<String>) -> Result<Vec<String>, RuntimeManifestError> {
4755    for (index, entry) in entries.iter().enumerate() {
4756        let problem = if entry.trim().is_empty() {
4757            Some("is empty")
4758        } else {
4759            match relative_path_refusal(entry) {
4760                None => None,
4761                Some(RelativePathRefusal::ParentDir) => Some("contains a '..' component"),
4762                Some(RelativePathRefusal::Absolute) => Some("is absolute"),
4763            }
4764        };
4765        if let Some(problem) = problem {
4766            return Err(RuntimeManifestError::InvalidInferenceConfig {
4767                field: format!("inference.instruction_files[{index}]"),
4768                message: format!("'{entry}' {problem}; {INSTRUCTION_FILE_ACCEPTED_FORM}"),
4769            });
4770        }
4771    }
4772    Ok(entries)
4773}
4774
4775fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
4776    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4777        field: field.to_string(),
4778        message,
4779    };
4780
4781    let root = raw
4782        .root
4783        .map(|root| root.trim().to_string())
4784        .filter(|root| !root.is_empty())
4785        .ok_or_else(|| {
4786            invalid(
4787                "exports.files.root",
4788                format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4789            )
4790        })?;
4791    check_export_root_shape("exports.files.root", &root)?;
4792
4793    let mode = match raw.mode.as_deref().map(str::trim) {
4794        None | Some("") => {
4795            return Err(invalid(
4796                "exports.files.mode",
4797                "is required and must be 'read-only'".to_string(),
4798            ));
4799        }
4800        Some("read-only") => ExportMode::ReadOnly,
4801        Some(other) => {
4802            return Err(invalid(
4803                "exports.files.mode",
4804                format!("'{other}' must be 'read-only'"),
4805            ));
4806        }
4807    };
4808
4809    let max_bytes = match raw.max_bytes {
4810        None => DEFAULT_EXPORT_MAX_BYTES,
4811        Some(value) => {
4812            let Some(text) = scalar_text(&value) else {
4813                return Err(invalid(
4814                    "exports.files.max_bytes",
4815                    format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4816                ));
4817            };
4818            let parsed = parse_byte_size(&text)
4819                .map_err(|message| invalid("exports.files.max_bytes", message))?;
4820            if parsed == 0 {
4821                return Err(invalid(
4822                    "exports.files.max_bytes",
4823                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4824                ));
4825            }
4826            parsed
4827        }
4828    };
4829
4830    Ok(FileExport {
4831        root,
4832        mode,
4833        max_bytes,
4834    })
4835}
4836
4837/// The accepted spelling of `exports.files.root`, stated once so every rejection says the same
4838/// thing about what a legal root looks like.
4839const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
4840
4841/// The accepted spelling of `capabilities.peer_fetch.allow`, stated once.
4842pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
4843
4844/// Structural check on one network destination: an `http`/`https` URL with no path, query or
4845/// fragment, or a bare `host[:port]`.
4846///
4847/// Deliberately shape-only. The authoritative matcher is the runtime's network-rule parser, which
4848/// this crate cannot see; what this catches is the class of entry that could never become a rule
4849/// at all, at the line that wrote it rather than at launch.
4850fn check_network_destination_shape(entry: &str) -> Result<(), String> {
4851    let trimmed = entry.trim();
4852    if trimmed.is_empty() {
4853        return Err(format!(
4854            "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4855        ));
4856    }
4857
4858    if trimmed.contains("://") {
4859        let url = Url::parse(trimmed).map_err(|error| {
4860            format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
4861        })?;
4862        let scheme = url.scheme().to_ascii_lowercase();
4863        if scheme != "http" && scheme != "https" {
4864            return Err(format!(
4865                "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
4866                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4867            ));
4868        }
4869        if url.host_str().is_none_or(str::is_empty) {
4870            return Err(format!(
4871                "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4872            ));
4873        }
4874        if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
4875            return Err(format!(
4876                "'{entry}' must not include a path, query or fragment; \
4877                 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4878            ));
4879        }
4880        return Ok(());
4881    }
4882
4883    if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
4884        return Err(format!(
4885            "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4886        ));
4887    }
4888    // Split from the right so an IPv6 literal's own colons are not mistaken for a port separator.
4889    if let Some((host, port)) = trimmed.rsplit_once(':') {
4890        if !host.ends_with(']') {
4891            if host.is_empty() {
4892                return Err(format!(
4893                    "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4894                ));
4895            }
4896            if port.parse::<u16>().is_err() {
4897                return Err(format!(
4898                    "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4899                ));
4900            }
4901        }
4902    }
4903    Ok(())
4904}
4905
4906/// The `~/.murmur/config.yaml` key that lends contained capsules a host folder, refused wherever a
4907/// manifest writes it.
4908pub const HOST_PATHS_KEY: &str = "host_paths";
4909
4910/// Refuses [`HOST_PATHS_KEY`] at the top level, under `capabilities` and `capabilities.filesystem`,
4911/// and under each artifact entry's `capabilities` and `capabilities.filesystem`. No field claims
4912/// the key, so it is found in each block's unknown-key map before any warning is built from it.
4913fn check_no_host_paths(raw: &RawRuntimeManifest) -> Result<(), RuntimeManifestError> {
4914    fn in_capabilities(capabilities: Option<&RawCapabilities>, prefix: &str) -> Option<String> {
4915        let capabilities = capabilities?;
4916        if capabilities.unknown.contains_key(HOST_PATHS_KEY) {
4917            return Some(format!("{prefix}.{HOST_PATHS_KEY}"));
4918        }
4919        capabilities
4920            .filesystem
4921            .as_ref()
4922            .filter(|filesystem| filesystem.unknown.contains_key(HOST_PATHS_KEY))
4923            .map(|_| format!("{prefix}.filesystem.{HOST_PATHS_KEY}"))
4924    }
4925    let field = if raw.unknown.contains_key(HOST_PATHS_KEY) {
4926        Some(HOST_PATHS_KEY.to_string())
4927    } else {
4928        in_capabilities(raw.capabilities.as_ref(), "capabilities").or_else(|| {
4929            raw.artifacts
4930                .iter()
4931                .enumerate()
4932                .find_map(|(index, artifact)| {
4933                    in_capabilities(
4934                        artifact.capabilities.as_ref(),
4935                        &format!("artifacts[{index}].capabilities"),
4936                    )
4937                })
4938        })
4939    };
4940    match field {
4941        Some(field) => Err(RuntimeManifestError::HostPathsDeclared { field }),
4942        None => Ok(()),
4943    }
4944}
4945
4946fn parse_capabilities(
4947    raw: Option<RawCapabilities>,
4948) -> Result<Option<Capabilities>, RuntimeManifestError> {
4949    let Some(raw_caps) = raw else {
4950        return Ok(None);
4951    };
4952
4953    let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
4954        allow: raw_network.allow,
4955        unix_sockets: raw_network.unix_sockets,
4956    });
4957
4958    let peer_fetch = raw_caps
4959        .peer_fetch
4960        .map(|raw_peer_fetch| {
4961            if raw_peer_fetch.allow.is_empty() {
4962                return Err(RuntimeManifestError::InvalidCapabilities {
4963                    field: "capabilities.peer_fetch.allow".to_string(),
4964                    message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
4965                });
4966            }
4967            for entry in &raw_peer_fetch.allow {
4968                check_network_destination_shape(entry).map_err(|message| {
4969                    RuntimeManifestError::InvalidCapabilities {
4970                        field: "capabilities.peer_fetch.allow".to_string(),
4971                        message,
4972                    }
4973                })?;
4974            }
4975            Ok(PeerFetchCapabilities {
4976                allow: raw_peer_fetch.allow,
4977            })
4978        })
4979        .transpose()?;
4980
4981    let filesystem = raw_caps
4982        .filesystem
4983        .map(|raw_filesystem| FilesystemCapabilities {
4984            scope: raw_filesystem.scope.and_then(|scope| {
4985                let trimmed = scope.trim();
4986                (!trimmed.is_empty()).then(|| trimmed.to_string())
4987            }),
4988            // Copied straight across: a bool has no empty/whitespace case to normalise, and there
4989            // is nothing to validate here — the *consequence* of declaring it (no `scoped`, and a
4990            // refusal when `capabilities.containment: scoped` is also declared) belongs to the
4991            // runtime's containment layer, not to manifest parsing.
4992            workdir_exec: raw_filesystem.workdir_exec,
4993            // Normalised on exactly the terms `scope` above is: trimmed, and an entry left empty
4994            // by that trim is dropped rather than carried as a rule covering nothing. Shape is
4995            // not judged here either — an absolute or escaping entry is the runtime's refusal.
4996            read_only: raw_filesystem
4997                .read_only
4998                .into_iter()
4999                .filter_map(|entry| {
5000                    let trimmed = entry.trim();
5001                    (!trimmed.is_empty()).then(|| trimmed.to_string())
5002                })
5003                .collect(),
5004            // Where it is recognized is decided by the caller, which knows the entry's role.
5005            records: raw_filesystem.records,
5006        });
5007
5008    let shell = raw_caps
5009        .shell
5010        .map(|raw_shell| {
5011            if raw_shell.allow.is_empty() {
5012                return Err(RuntimeManifestError::InvalidCapabilities {
5013                    field: "capabilities.shell.allow".to_string(),
5014                    message: "must contain at least one binary".to_string(),
5015                });
5016            }
5017            for (index, entry) in raw_shell.allow.iter().enumerate() {
5018                if let Some(reason) = shell_allow_entry_error(entry) {
5019                    return Err(RuntimeManifestError::ShellAllowEntry {
5020                        index,
5021                        entry: entry.clone(),
5022                        reason,
5023                    });
5024                }
5025            }
5026
5027            let interpreter_runtime =
5028                parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
5029            // Parsed after `interpreter_runtime` because the mutual-exclusion rule needs the
5030            // already-validated grant list to test against.
5031            let staged_runtime = parse_staged_runtime(
5032                &raw_shell.allow,
5033                &interpreter_runtime,
5034                raw_shell.staged_runtime,
5035            )?;
5036
5037            Ok(ShellCapabilities {
5038                allow: raw_shell.allow,
5039                strip_env: raw_shell.strip_env,
5040                baseline_env: raw_shell.baseline_env,
5041                interpreter_runtime,
5042                staged_runtime,
5043            })
5044        })
5045        .transpose()?;
5046
5047    let spawn = raw_caps
5048        .spawn
5049        .map(|raw_spawn| parse_spawn_allow(raw_spawn.allow, raw_spawn.workdir_snapshot))
5050        .transpose()?;
5051
5052    let env = raw_caps.env.map(|raw_env| EnvCapabilities {
5053        allow: raw_env.allow,
5054    });
5055
5056    let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
5057
5058    let resources = raw_caps
5059        .resources
5060        .map(parse_resource_capabilities)
5061        .transpose()?;
5062
5063    let state = raw_caps.state.map(parse_state_capabilities);
5064
5065    let task_io = raw_caps
5066        .task_io
5067        .map(|raw_task_io| {
5068            raw_task_io
5069                .read
5070                .map(|read| TaskIoCapabilities { read })
5071                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5072                    field: "capabilities.task_io.read".to_string(),
5073                    message: "must be set explicitly to true or false — a capability is never \
5074                              inferred"
5075                        .to_string(),
5076                })
5077        })
5078        .transpose()?;
5079
5080    let conversation = raw_caps
5081        .conversation
5082        .map(|raw_conversation| {
5083            raw_conversation
5084                .read
5085                .map(|read| ConversationCapabilities { read })
5086                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5087                    field: "capabilities.conversation.read".to_string(),
5088                    message: "must be set explicitly to true or false — a capability is never \
5089                              inferred"
5090                        .to_string(),
5091                })
5092        })
5093        .transpose()?;
5094
5095    let plan = raw_caps
5096        .plan
5097        .map(|raw_plan| {
5098            raw_plan
5099                .submit
5100                .map(|submit| PlanCapabilities { submit })
5101                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5102                    field: "capabilities.plan.submit".to_string(),
5103                    message: "must be set explicitly to true or false — a capability is never \
5104                              inferred"
5105                        .to_string(),
5106                })
5107        })
5108        .transpose()?;
5109
5110    let install = raw_caps.install.map(parse_install).transpose()?;
5111
5112    let containment = raw_caps
5113        .containment
5114        .as_deref()
5115        .map(str::trim)
5116        .filter(|value| !value.is_empty())
5117        .map(|value| {
5118            let class = value.parse::<ContainmentClass>().map_err(|error| {
5119                RuntimeManifestError::InvalidCapabilities {
5120                    field: "capabilities.containment".to_string(),
5121                    message: error.to_string(),
5122                }
5123            })?;
5124            if class == ContainmentClass::Unconfined {
5125                return Err(RuntimeManifestError::InvalidCapabilities {
5126                    field: "capabilities.containment".to_string(),
5127                    message: UNCONFINED_NOT_DECLARABLE.to_string(),
5128                });
5129            }
5130            Ok(class)
5131        })
5132        .transpose()?;
5133
5134    Ok(Some(Capabilities {
5135        network,
5136        peer_fetch,
5137        filesystem,
5138        shell,
5139        spawn,
5140        env,
5141        limits,
5142        resources,
5143        state,
5144        task_io,
5145        conversation,
5146        plan,
5147        install,
5148        containment,
5149    }))
5150}
5151
5152/// Lower `capabilities.spawn.allow`: every entry a full name, no two sharing a bare part —
5153/// `delegate-task` offers the model the bare parts, so each must pick out one capsule — and
5154/// `capabilities.spawn.workdir_snapshot`: every entry a full name, listed once and also in `allow`.
5155fn parse_spawn_allow(
5156    allow: Vec<String>,
5157    workdir_snapshot: Vec<String>,
5158) -> Result<SpawnCapabilities, RuntimeManifestError> {
5159    let mut parsed: Vec<ArtifactName> = Vec::with_capacity(allow.len());
5160    for (index, entry) in allow.iter().enumerate() {
5161        let name = ArtifactName::parse(entry).map_err(|error| {
5162            RuntimeManifestError::InvalidArtifactName {
5163                field: format!("capabilities.spawn.allow[{index}]"),
5164                message: error.to_string(),
5165                hint: error.hint(),
5166            }
5167        })?;
5168        if let Some(earlier) = parsed
5169            .iter()
5170            .find(|earlier| earlier.name() == name.name() && **earlier != name)
5171        {
5172            return Err(RuntimeManifestError::InvalidArtifactName {
5173                field: format!("capabilities.spawn.allow[{index}]"),
5174                message: format!(
5175                    "'{earlier}' and '{name}' share the name '{}', which is how delegate-task \
5176                     offers a capsule to the model",
5177                    name.name()
5178                ),
5179                hint: Some(format!(
5180                    "keep one of '{earlier}' and '{name}' in capabilities.spawn.allow"
5181                )),
5182            });
5183        }
5184        parsed.push(name);
5185    }
5186    let mut snapshotted: Vec<ArtifactName> = Vec::with_capacity(workdir_snapshot.len());
5187    for (index, entry) in workdir_snapshot.iter().enumerate() {
5188        let field = format!("capabilities.spawn.workdir_snapshot[{index}]");
5189        let name = ArtifactName::parse(entry).map_err(|error| {
5190            RuntimeManifestError::InvalidArtifactName {
5191                field: field.clone(),
5192                message: error.to_string(),
5193                hint: error.hint(),
5194            }
5195        })?;
5196        if snapshotted.contains(&name) {
5197            return Err(RuntimeManifestError::InvalidArtifactName {
5198                field,
5199                message: format!("'{name}' is listed more than once"),
5200                hint: Some(format!(
5201                    "keep one '{name}' in capabilities.spawn.workdir_snapshot"
5202                )),
5203            });
5204        }
5205        if !parsed.contains(&name) {
5206            return Err(RuntimeManifestError::InvalidArtifactName {
5207                field,
5208                message: format!(
5209                    "'{name}' is not in capabilities.spawn.allow, so this capsule cannot \
5210                     delegate to it"
5211                ),
5212                hint: Some(format!(
5213                    "add '{name}' to capabilities.spawn.allow, or remove it from \
5214                     capabilities.spawn.workdir_snapshot"
5215                )),
5216            });
5217        }
5218        snapshotted.push(name);
5219    }
5220    Ok(SpawnCapabilities {
5221        allow,
5222        workdir_snapshot,
5223    })
5224}
5225
5226/// Refuse an `artifacts[index].name` of the wrong form: a full name for an entry that resolves,
5227/// a bare name for a `source:` entry, which is never resolved or locked.
5228fn check_entry_name(
5229    index: usize,
5230    name: &str,
5231    has_source: bool,
5232) -> Result<(), RuntimeManifestError> {
5233    let field = format!("artifacts[{index}].name");
5234    if !has_source {
5235        return ArtifactName::parse(name).map(|_| ()).map_err(|error| {
5236            RuntimeManifestError::InvalidArtifactName {
5237                field,
5238                message: error.to_string(),
5239                hint: error.hint(),
5240            }
5241        });
5242    }
5243    if let Some((_, bare)) = name.split_once('/') {
5244        return Err(RuntimeManifestError::InvalidArtifactName {
5245            field,
5246            message: format!(
5247                "'{name}' declares 'source:', so it is named by its bare name; a scope says \
5248                 where a resolved artifact comes from, and a source: entry is never resolved"
5249            ),
5250            hint: Some(format!("drop the scope: name it '{bare}'")),
5251        });
5252    }
5253    match artifact_name_format_error(name) {
5254        Some(reason) => Err(RuntimeManifestError::InvalidArtifactName {
5255            field,
5256            message: format!("'{name}' is not a valid artifact name: it {reason}"),
5257            hint: None,
5258        }),
5259        None => Ok(()),
5260    }
5261}
5262
5263/// The refusal of a reference to an artifact `artifacts:` does not declare. When exactly one
5264/// declared entry has `name` as its bare part, the message names that entry, the reference the
5265/// operator most likely meant.
5266fn not_declared_message(artifacts: &[RuntimeArtifact], name: &str) -> String {
5267    let mut same_bare = artifacts
5268        .iter()
5269        .filter(|artifact| artifact.name != name && artifact.tool_name() == tool_name_of(name));
5270    match (same_bare.next(), same_bare.next()) {
5271        (Some(only), None) => format!(
5272            "artifact '{name}' is not declared in artifacts: (the declared '{}' has that name; \
5273             refer to it as '{}')",
5274            only.name, only.name
5275        ),
5276        _ => format!("artifact '{name}' is not declared in artifacts:"),
5277    }
5278}
5279
5280/// Lower `capabilities.install`, refusing a block that names nothing and any entry outside
5281/// [`crate::install_pattern`]'s grammar.
5282fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
5283    if raw.skill.is_empty() && raw.tool.is_empty() {
5284        return Err(RuntimeManifestError::InvalidCapabilities {
5285            field: "capabilities.install".to_string(),
5286            message: "must name at least one 'skill' or 'tool' entry — a capability is never \
5287                      inferred; omit the block to grant no install at all"
5288                .to_string(),
5289        });
5290    }
5291    for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
5292        for (index, entry) in entries.iter().enumerate() {
5293            crate::install_pattern::validate(entry).map_err(|error| {
5294                RuntimeManifestError::InvalidArtifactName {
5295                    field: format!("capabilities.install.{key}[{index}]"),
5296                    message: format!(
5297                        "entry {entry:?} is invalid ({}); expected {}",
5298                        error.reason,
5299                        crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
5300                    ),
5301                    hint: error.hint,
5302                }
5303            })?;
5304        }
5305    }
5306    let from = match raw.from {
5307        None => InstallFrom::Stores,
5308        Some(list) => parse_install_from(&list)?,
5309    };
5310    Ok(InstallCapabilities {
5311        skill: raw.skill,
5312        tool: raw.tool,
5313        from,
5314    })
5315}
5316
5317/// Refuse `capabilities.install` on a `transport: process` capsule: the pull and remove tools are
5318/// offered through the http agent loop's tool array, which that transport never sends.
5319fn refuse_install_under_process_transport(
5320    capabilities: Option<&Capabilities>,
5321    inference: Option<&InferenceConfig>,
5322) -> Result<(), RuntimeManifestError> {
5323    let installs = capabilities.is_some_and(|capabilities| capabilities.install.is_some());
5324    let process = inference.is_some_and(|inference| inference.transport == "process");
5325    if installs && process {
5326        return Err(RuntimeManifestError::InvalidCapabilities {
5327            field: "capabilities.install".to_string(),
5328            message: "is not valid with transport: process; the harness owns its tool list, so a \
5329                      pulled artifact could never reach it"
5330                .to_string(),
5331        });
5332    }
5333    Ok(())
5334}
5335
5336/// The values `capabilities.install.from` accepts, as its refusals name them.
5337const INSTALL_FROM_ACCEPTED: &str = "[stores] or [stores, sources]";
5338
5339/// Lower `capabilities.install.from`: `stores` is required, `sources` optional, each at most
5340/// once and in either order.
5341fn parse_install_from(list: &[String]) -> Result<InstallFrom, RuntimeManifestError> {
5342    let refuse = |message: String| RuntimeManifestError::InvalidCapabilities {
5343        field: "capabilities.install.from".to_string(),
5344        message: format!("{message}; expected {INSTALL_FROM_ACCEPTED}"),
5345    };
5346    if list.is_empty() {
5347        return Err(refuse("is empty".to_string()));
5348    }
5349    let (mut stores, mut sources) = (false, false);
5350    for entry in list {
5351        let seen = match entry.as_str() {
5352            "stores" => &mut stores,
5353            "sources" => &mut sources,
5354            other => return Err(refuse(format!("names {other:?}, which is not a place"))),
5355        };
5356        if *seen {
5357            return Err(refuse(format!("names {entry:?} twice")));
5358        }
5359        *seen = true;
5360    }
5361    if !stores {
5362        return Err(refuse(
5363            "does not name stores, which every pull is answered from first".to_string(),
5364        ));
5365    }
5366    Ok(if sources {
5367        InstallFrom::StoresAndSources
5368    } else {
5369        InstallFrom::Stores
5370    })
5371}
5372
5373/// Lower `capabilities.state`. Infallible: the only field is a name, and a name's *shape* is a
5374/// runtime question (does it resolve to one directory under `~/.murmur/state/`?) rather than a
5375/// manifest-parse one — `capsule_runtime::state_store::validate_store_name` answers it and refuses
5376/// with `E-CAP-009`.
5377///
5378/// Surrounding whitespace is trimmed but an emptied value is *kept*, unlike
5379/// `capabilities.filesystem.scope`, which normalises empty to absent. `store: ""` is a written
5380/// declaration that names nothing, and silently defaulting it to the capsule name would hand a
5381/// store to someone who asked for a different one; it reaches the runtime and is refused there.
5382fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
5383    StateCapabilities {
5384        store: raw.store.map(|store| store.trim().to_string()),
5385    }
5386}
5387
5388/// Why `entry` is not a `capabilities.shell.allow` entry, which must be one path component: the
5389/// binary's name, looked up on `PATH`, and the name of the tool the model calls. `None` for a
5390/// bare binary name.
5391fn shell_allow_entry_error(entry: &str) -> Option<&'static str> {
5392    if entry.is_empty() {
5393        Some("is empty")
5394    } else if entry.contains('\0') {
5395        Some("contains a NUL byte")
5396    } else if entry.contains('/') {
5397        Some("is a path")
5398    } else if entry == "." || entry == ".." {
5399        Some("names a directory")
5400    } else if entry.chars().any(char::is_whitespace) {
5401        Some("contains whitespace")
5402    } else {
5403        None
5404    }
5405}
5406
5407/// Lower and validate `capabilities.shell.interpreter_runtime`. Every rejection is an
5408/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending value, matching the rest
5409/// of this file's "structurally present but semantically invalid" reporting. Nothing here can
5410/// expand a whole install prefix: each grant names specific directories, and each directory
5411/// carries an explicit `list_dir` the author had to write — enumerability is never inferred.
5412fn parse_interpreter_runtime(
5413    allow: &[String],
5414    raw_grants: Vec<RawInterpreterRuntimeGrant>,
5415) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
5416    let mut grants = Vec::with_capacity(raw_grants.len());
5417    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5418        let base = format!("capabilities.shell.interpreter_runtime[{index}]");
5419
5420        let binary = raw_grant
5421            .binary
5422            .filter(|b| !b.trim().is_empty())
5423            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5424                field: format!("{base}.binary"),
5425                message: "must name a binary".to_string(),
5426            })?;
5427
5428        // The whole point of this mechanism: it narrows filesystem access alongside an exec
5429        // grant that already exists. It can never itself grant exec, so the binary must already
5430        // be allowlisted in this same block's `allow`.
5431        if !allow.contains(&binary) {
5432            return Err(RuntimeManifestError::InvalidCapabilities {
5433                field: format!("{base}.binary"),
5434                message: format!(
5435                    "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
5436                     only narrow filesystem access for an already-allowlisted binary, never \
5437                     grant exec"
5438                ),
5439            });
5440        }
5441
5442        if raw_grant.dirs.is_empty() {
5443            return Err(RuntimeManifestError::InvalidCapabilities {
5444                field: format!("{base}.dirs"),
5445                message: "must name at least one directory".to_string(),
5446            });
5447        }
5448
5449        let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
5450        for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
5451            let dir_base = format!("{base}.dirs[{dir_index}]");
5452
5453            let path = raw_dir
5454                .path
5455                .filter(|p| !p.trim().is_empty())
5456                .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5457                    field: format!("{dir_base}.path"),
5458                    message: "must name a host directory".to_string(),
5459                })?;
5460            if !path.starts_with('/') {
5461                return Err(RuntimeManifestError::InvalidCapabilities {
5462                    field: format!("{dir_base}.path"),
5463                    message: format!(
5464                        "'{path}' must be an absolute host path (start with '/') — these are \
5465                         filesystem paths outside the workdir"
5466                    ),
5467                });
5468            }
5469
5470            let list_dir =
5471                raw_dir
5472                    .list_dir
5473                    .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5474                        field: format!("{dir_base}.list_dir"),
5475                        message: format!(
5476                            "'{path}' must set list_dir explicitly to true or false — \
5477                         enumerability is never inferred"
5478                        ),
5479                    })?;
5480
5481            dirs.push(InterpreterRuntimeDir { path, list_dir });
5482        }
5483
5484        grants.push(InterpreterRuntimeGrant { binary, dirs });
5485    }
5486
5487    Ok(grants)
5488}
5489
5490/// Lower and validate `capabilities.shell.staged_runtime`, given this same block's `allow` list
5491/// and its already-validated `interpreter_runtime` grants. Every rejection is a
5492/// [`RuntimeManifestError::InvalidCapabilities`] naming the offending field, matching
5493/// [`parse_interpreter_runtime`] and the rest of this file.
5494///
5495/// Four rules, each of them a thing that would otherwise be silently wrong at launch rather than
5496/// loudly wrong here:
5497///
5498///   1. `binary` must already be in `allow` — staging a runtime tree never itself grants exec.
5499///   2. `binary` must not also carry an `interpreter_runtime` grant — the two are alternatives,
5500///      and declaring both means the author expects a host-widening grant that a composed root
5501///      makes both unnecessary and (once Landlock re-installs inside the root) meaningless.
5502///   3. `source_path` must be absolute — it is a host path outside the workdir, resolved by the
5503///      launch host and not relative to anything the runtime knows.
5504///   4. `pin` must be present and non-empty — it is the value a human compares across two hosts,
5505///      so an absent one defeats the field's only purpose.
5506///
5507/// Nothing here touches the filesystem: whether `source_path` exists on *this* host is a launch
5508/// -time fact, not a manifest-validity one, and a manifest must stay parseable on a machine that
5509/// will never run it (`mur build` on a laptop for a Linux fleet).
5510fn parse_staged_runtime(
5511    allow: &[String],
5512    interpreter_runtime: &[InterpreterRuntimeGrant],
5513    raw_grants: Vec<RawStagedRuntimeGrant>,
5514) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
5515    let mut grants = Vec::with_capacity(raw_grants.len());
5516    for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5517        let base = format!("capabilities.shell.staged_runtime[{index}]");
5518
5519        let binary = raw_grant
5520            .binary
5521            .filter(|b| !b.trim().is_empty())
5522            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5523                field: format!("{base}.binary"),
5524                message: "must name a binary".to_string(),
5525            })?;
5526
5527        if !allow.contains(&binary) {
5528            return Err(RuntimeManifestError::InvalidCapabilities {
5529                field: format!("{base}.binary"),
5530                message: format!(
5531                    "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
5532                     stage the runtime tree behind an already-allowlisted binary, never grant exec"
5533                ),
5534            });
5535        }
5536
5537        if interpreter_runtime
5538            .iter()
5539            .any(|grant| grant.binary == binary)
5540        {
5541            return Err(RuntimeManifestError::InvalidCapabilities {
5542                field: format!("{base}.binary"),
5543                message: format!(
5544                    "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
5545                     are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
5546                     tree into the capsule's own root, which is what makes widening the capsule's \
5547                     host filesystem scope unnecessary rather than something to add to it. Remove \
5548                     the interpreter_runtime grant for '{binary}'"
5549                ),
5550            });
5551        }
5552
5553        let source_path = raw_grant
5554            .source_path
5555            .filter(|p| !p.trim().is_empty())
5556            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5557                field: format!("{base}.source_path"),
5558                message: "must name the host directory holding the pinned runtime tree".to_string(),
5559            })?;
5560        if !source_path.starts_with('/') {
5561            return Err(RuntimeManifestError::InvalidCapabilities {
5562                field: format!("{base}.source_path"),
5563                message: format!(
5564                    "'{source_path}' must be an absolute host path (start with '/') — \
5565                     staged-runtime sources are host filesystem paths outside the workdir"
5566                ),
5567            });
5568        }
5569
5570        let pin = raw_grant
5571            .pin
5572            .map(|p| p.trim().to_string())
5573            .filter(|p| !p.is_empty())
5574            .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5575                field: format!("{base}.pin"),
5576                message: format!(
5577                    "'{source_path}' must declare a non-empty pin identifying which build this \
5578                     tree is — it is never inferred, because it is the value a human compares \
5579                     across two hosts to confirm the same runtime shipped to both"
5580                ),
5581            })?;
5582
5583        grants.push(StagedRuntimeGrant {
5584            binary,
5585            source_path,
5586            pin,
5587        });
5588    }
5589
5590    Ok(grants)
5591}
5592
5593/// Lower `capabilities.limits`, rejecting values that can only ever produce a guest that
5594/// traps on its first instruction. A zero cap is always a manifest authoring mistake, and
5595/// catching it here means `mur run` fails at parse time with a field name rather than
5596/// surfacing much later as an opaque wasm trap.
5597fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
5598    let reject_zero_usize =
5599        |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
5600            match value {
5601                Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5602                    field: format!("capabilities.limits.{field}"),
5603                    message: "must be greater than zero".to_string(),
5604                }),
5605                _ => Ok(()),
5606            }
5607        };
5608
5609    reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
5610    reject_zero_usize(raw.table_elements, "table_elements")?;
5611    reject_zero_usize(raw.instances, "instances")?;
5612    if raw.deadline_seconds == Some(0) {
5613        return Err(RuntimeManifestError::InvalidCapabilities {
5614            field: "capabilities.limits.deadline_seconds".to_string(),
5615            message: "must be greater than zero".to_string(),
5616        });
5617    }
5618
5619    Ok(ResourceLimits {
5620        memory_bytes: raw.memory_bytes,
5621        table_elements: raw.table_elements,
5622        instances: raw.instances,
5623        deadline_seconds: raw.deadline_seconds,
5624    })
5625}
5626
5627/// Lower and validate `capabilities.resources`. Mirrors [`parse_resource_limits`]: nothing is
5628/// defaulted here (the runtime owns the defaults, so "omitted" must stay distinguishable from
5629/// "declared"), and a declared `0` is rejected outright on every field — a zero ceiling is
5630/// never what an author means, and letting it through would turn a typo into a subprocess that
5631/// cannot fork, open a file, or run for a single CPU-second.
5632fn parse_resource_capabilities(
5633    raw: RawResourceCapabilities,
5634) -> Result<ResourceCapabilities, RuntimeManifestError> {
5635    let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
5636        match value {
5637            Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5638                field: format!("capabilities.resources.{field}"),
5639                message: "must be greater than zero".to_string(),
5640            }),
5641            _ => Ok(()),
5642        }
5643    };
5644
5645    reject_zero(raw.max_processes, "max_processes")?;
5646    reject_zero(raw.max_open_files, "max_open_files")?;
5647    reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
5648    reject_zero(raw.cpu_seconds, "cpu_seconds")?;
5649    reject_zero(raw.memory_bytes, "memory_bytes")?;
5650    reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
5651    reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
5652    reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
5653    reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
5654    reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
5655
5656    Ok(ResourceCapabilities {
5657        max_processes: raw.max_processes,
5658        max_open_files: raw.max_open_files,
5659        max_file_size_bytes: raw.max_file_size_bytes,
5660        cpu_seconds: raw.cpu_seconds,
5661        memory_bytes: raw.memory_bytes,
5662        cgroup_memory_bytes: raw.cgroup_memory_bytes,
5663        cgroup_pids_max: raw.cgroup_pids_max,
5664        cgroup_cpu_percent: raw.cgroup_cpu_percent,
5665        cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
5666        workdir_max_bytes: raw.workdir_max_bytes,
5667    })
5668}
5669
5670/// Why a `transport: process` manifest with no `inference.driver` is refused. Names one driver
5671/// artifact by way of example: this is the one message a reader reaches with nothing to go on, and
5672/// an abstract instruction to "name a process driver" leaves them no way to find one.
5673const PROCESS_DRIVER_REQUIRED: &str =
5674    "missing required field; a transport: process capsule must name the process driver that \
5675     knows how to drive its harness. inference.command no longer selects a harness — it only \
5676     overrides the binary the driver names. Add inference.driver.artifact naming a process \
5677     driver, for example murmur/driver-claude-code";
5678
5679fn parse_inference(
5680    raw: Option<RawInferenceConfig>,
5681) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
5682    let Some(raw) = raw else {
5683        return Ok(None);
5684    };
5685
5686    let transport = raw
5687        .transport
5688        .map(|s| s.trim().to_string())
5689        .filter(|s| !s.is_empty())
5690        .unwrap_or_else(|| "http".to_string());
5691
5692    let compaction = parse_compaction(raw.compaction)?;
5693    let system_prompt = optional_trimmed_string(raw.system_prompt);
5694    let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
5695    let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
5696    let instruction_files = raw
5697        .instruction_files
5698        .map(parse_instruction_files)
5699        .transpose()?;
5700
5701    let prompt_sources_set = [
5702        system_prompt.is_some(),
5703        system_prompt_file.is_some(),
5704        system_prompt_artifact.is_some(),
5705    ]
5706    .iter()
5707    .filter(|&&b| b)
5708    .count();
5709    if prompt_sources_set > 1 {
5710        return Err(RuntimeManifestError::InvalidInferenceConfig {
5711            field: "inference.system_prompt".to_string(),
5712            message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
5713                      may be set"
5714                .to_string(),
5715        });
5716    }
5717
5718    if raw.max_turns == Some(0) {
5719        return Err(RuntimeManifestError::InvalidInferenceConfig {
5720            field: "inference.max_turns".to_string(),
5721            message: "must be greater than 0".to_string(),
5722        });
5723    }
5724    let max_turns = raw.max_turns;
5725
5726    // The reopen budget is a lifecycle knob, read from `lifecycle.max_task_reopens`. Nothing
5727    // here consumes the `inference` spelling, so it would be silently inert — reject it rather
5728    // than let it look effective.
5729    if raw.max_task_reopens.is_some() {
5730        return Err(RuntimeManifestError::InvalidInferenceConfig {
5731            field: "inference.max_task_reopens".to_string(),
5732            message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
5733                .to_string(),
5734        });
5735    }
5736
5737    // Left to the unknown-key pass, either field would only draw a warning and the capsule would
5738    // launch with no upstream or credential bound, so each is refused by name, pointing at the
5739    // driver entry's `gateway:` block. Nothing translates it.
5740    for (present, removed, moved_to) in [
5741        (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
5742        (raw.api_key.is_some(), "api_key", "gateway.api_key"),
5743    ] {
5744        if !present {
5745            continue;
5746        }
5747        let entry = raw
5748            .driver
5749            .as_ref()
5750            .and_then(|driver| driver.artifact.as_deref())
5751            .map(str::trim)
5752            .filter(|name| !name.is_empty())
5753            .map(|name| format!("on the artifacts: entry '{name}'"))
5754            .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
5755        return Err(RuntimeManifestError::InvalidInferenceConfig {
5756            field: format!("inference.{removed}"),
5757            message: format!("was removed; set {moved_to} {entry} instead"),
5758        });
5759    }
5760
5761    match transport.as_str() {
5762        "http" => {
5763            let model = required_inference_field(raw.model, "model")?;
5764
5765            let driver_raw =
5766                raw.driver
5767                    .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5768                        field: "inference.driver".to_string(),
5769                        message: "missing required field".to_string(),
5770                    })?;
5771            let driver = parse_inference_driver(driver_raw)?;
5772
5773            // Advisory only: catch an authoring typo at parse time rather than as a confusing
5774            // provider 400. Large values are deliberately NOT clamped — a ceiling here would
5775            // block a model whose real limit is higher than anything we could hard-code.
5776            if raw.max_tokens == Some(0) {
5777                return Err(RuntimeManifestError::InvalidInferenceConfig {
5778                    field: "inference.max_tokens".to_string(),
5779                    message: "must be greater than 0".to_string(),
5780                });
5781            }
5782            if raw.max_session_tokens == Some(0) {
5783                return Err(RuntimeManifestError::InvalidInferenceConfig {
5784                    field: "inference.max_session_tokens".to_string(),
5785                    message: "must be greater than 0".to_string(),
5786                });
5787            }
5788            let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
5789            let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
5790            let retry = parse_inference_retry(raw.retry)?;
5791
5792            Ok(Some(InferenceConfig {
5793                transport,
5794                model,
5795                driver: Some(driver),
5796                command: None,
5797                compaction,
5798                system_prompt,
5799                system_prompt_file,
5800                system_prompt_artifact,
5801                instruction_files,
5802                max_turns,
5803                max_tokens: raw.max_tokens,
5804                max_session_tokens: raw.max_session_tokens,
5805                tool_refresh,
5806                alternates,
5807                retry,
5808            }))
5809        }
5810        "process" => {
5811            // The CLI subprocess path never builds a driver payload, so this value would be
5812            // silently inert here — reject it rather than let it look effective.
5813            if raw.max_tokens.is_some() {
5814                return Err(RuntimeManifestError::InvalidInferenceConfig {
5815                    field: "inference.max_tokens".to_string(),
5816                    message: "is not valid with transport: process".to_string(),
5817                });
5818            }
5819            // The harness owns its tool list and runs its own compaction, so neither trigger has a
5820            // turn boundary here to act on.
5821            if raw.tool_refresh.is_some() {
5822                return Err(RuntimeManifestError::InvalidInferenceConfig {
5823                    field: "inference.tool_refresh".to_string(),
5824                    message: "is not valid with transport: process".to_string(),
5825                });
5826            }
5827            // The harness runs its own compaction, so no `on-compaction` call is made here for
5828            // either key to shape.
5829            for (present, key) in [
5830                (
5831                    compaction.as_ref().is_some_and(|c| c.max_tokens.is_some()),
5832                    "max_tokens",
5833                ),
5834                (
5835                    compaction
5836                        .as_ref()
5837                        .is_some_and(|c| c.driver_config.is_some()),
5838                    "driver_config",
5839                ),
5840            ] {
5841                if present {
5842                    return Err(RuntimeManifestError::InvalidInferenceConfig {
5843                        field: format!("inference.compaction.{key}"),
5844                        message: "is not valid with transport: process".to_string(),
5845                    });
5846                }
5847            }
5848            // The harness owns the conversation and the model it runs, so there is no agent-loop
5849            // call for a switch to reroute.
5850            if raw.alternates.is_some() {
5851                return Err(RuntimeManifestError::InvalidInferenceConfig {
5852                    field: "inference.alternates".to_string(),
5853                    message: "is not valid with transport: process; the harness owns the \
5854                              conversation and the model it runs"
5855                        .to_string(),
5856                });
5857            }
5858            if raw.retry.is_some() {
5859                return Err(RuntimeManifestError::InvalidInferenceConfig {
5860                    field: "inference.retry".to_string(),
5861                    message: "is not valid with transport: process; the harness retries its own \
5862                              provider calls"
5863                        .to_string(),
5864                });
5865            }
5866
5867            // Valid here: the harness's own reported token counts are what this transport's
5868            // turns are metered against, and a driver that reports none is refused at load
5869            // rather than run for ever beneath a ceiling nothing could reach.
5870            if raw.max_session_tokens == Some(0) {
5871                return Err(RuntimeManifestError::InvalidInferenceConfig {
5872                    field: "inference.max_session_tokens".to_string(),
5873                    message: "must be greater than 0".to_string(),
5874                });
5875            }
5876
5877            let driver = raw.driver.map(parse_inference_driver).transpose()?;
5878            let command = optional_trimmed_string(raw.command);
5879            // The driver is what knows how to drive a harness CLI: its flags, its output format,
5880            // its version probe. `command` only says which executable to run instead of the one
5881            // the driver names, so it selects nothing on its own.
5882            if driver.is_none() {
5883                return Err(RuntimeManifestError::InvalidInferenceConfig {
5884                    field: "inference.driver.artifact".to_string(),
5885                    message: PROCESS_DRIVER_REQUIRED.to_string(),
5886                });
5887            }
5888            // model is OPTIONAL for transport: process — an empty string leaves the choice of
5889            // model to the harness, which has an account default of its own. The driver is handed
5890            // `none` rather than an empty string, so it never passes a model flag with no value.
5891            let model = optional_trimmed_string(raw.model).unwrap_or_default();
5892
5893            Ok(Some(InferenceConfig {
5894                transport,
5895                model,
5896                driver,
5897                command,
5898                compaction,
5899                system_prompt,
5900                system_prompt_file,
5901                system_prompt_artifact,
5902                instruction_files,
5903                max_turns,
5904                max_tokens: None,
5905                max_session_tokens: raw.max_session_tokens,
5906                tool_refresh: ToolRefresh::default(),
5907                alternates: Vec::new(),
5908                retry: InferenceRetry::default(),
5909            }))
5910        }
5911        other => Err(RuntimeManifestError::InvalidInferenceConfig {
5912            field: "inference.transport".to_string(),
5913            message: format!("unknown value '{other}'"),
5914        }),
5915    }
5916}
5917
5918/// Lowers `inference.retry` under `transport: http`: each omitted key takes its default, and each
5919/// value is checked against its bounds. `max_delay_ms` below `initial_delay_ms` is refused on
5920/// `max_delay_ms`.
5921fn parse_inference_retry(
5922    raw: Option<RawInferenceRetry>,
5923) -> Result<InferenceRetry, RuntimeManifestError> {
5924    let defaults = InferenceRetry::default();
5925    let Some(raw) = raw else {
5926        return Ok(defaults);
5927    };
5928    let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5929        field: format!("inference.retry.{key}"),
5930        message,
5931    };
5932    let retry = InferenceRetry {
5933        max_retries: raw.max_retries.unwrap_or(defaults.max_retries),
5934        initial_delay_ms: raw.initial_delay_ms.unwrap_or(defaults.initial_delay_ms),
5935        max_delay_ms: raw.max_delay_ms.unwrap_or(defaults.max_delay_ms),
5936    };
5937    if retry.max_retries > InferenceRetry::MAX_RETRIES {
5938        return Err(refuse(
5939            "max_retries",
5940            format!("must be at most {}", InferenceRetry::MAX_RETRIES),
5941        ));
5942    }
5943    if !(1..=InferenceRetry::MAX_INITIAL_DELAY_MS).contains(&retry.initial_delay_ms) {
5944        return Err(refuse(
5945            "initial_delay_ms",
5946            format!(
5947                "must be between 1 and {}",
5948                InferenceRetry::MAX_INITIAL_DELAY_MS
5949            ),
5950        ));
5951    }
5952    if !(1..=InferenceRetry::MAX_DELAY_MS).contains(&retry.max_delay_ms) {
5953        return Err(refuse(
5954            "max_delay_ms",
5955            format!("must be between 1 and {}", InferenceRetry::MAX_DELAY_MS),
5956        ));
5957    }
5958    if retry.max_delay_ms < retry.initial_delay_ms {
5959        return Err(refuse(
5960            "max_delay_ms",
5961            format!(
5962                "must be at least inference.retry.initial_delay_ms ({})",
5963                retry.initial_delay_ms
5964            ),
5965        ));
5966    }
5967    Ok(retry)
5968}
5969
5970/// Whether `name` is an alternate's name: a lowercase letter, then up to 62 lowercase letters,
5971/// digits and hyphens.
5972fn is_valid_driver_choice_name(name: &str) -> bool {
5973    let mut chars = name.chars();
5974    chars.next().is_some_and(|first| first.is_ascii_lowercase())
5975        && name.len() <= 63
5976        && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
5977}
5978
5979/// Lowers `inference.alternates` under `transport: http`, with the rules one entry and the list
5980/// can be judged on alone. What needs `artifacts:` is [`validate_driver_choices`]'s.
5981///
5982/// `primary_driver` and `primary_model` are the primary choice's pair, which no alternate may
5983/// repeat: two names for one pair would make a switch between them a change of nothing.
5984fn parse_alternates(
5985    raw: Option<Vec<RawInferenceAlternate>>,
5986    primary_driver: &str,
5987    primary_model: &str,
5988) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
5989    let mut alternates: Vec<InferenceAlternate> = Vec::new();
5990    for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
5991        let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5992            field: if key.is_empty() {
5993                format!("inference.alternates[{index}]")
5994            } else {
5995                format!("inference.alternates[{index}].{key}")
5996            },
5997            message,
5998        };
5999        let name = optional_trimmed_string(entry.name)
6000            .ok_or_else(|| refuse("name", "missing required field".to_string()))?;
6001        if name == PRIMARY_DRIVER_CHOICE {
6002            return Err(refuse(
6003                "name",
6004                format!(
6005                    "'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
6006                     inference.model; name the alternate something else"
6007                ),
6008            ));
6009        }
6010        if !is_valid_driver_choice_name(&name) {
6011            return Err(refuse(
6012                "name",
6013                format!(
6014                    "'{name}' is not a driver choice name; a name is a lowercase letter followed \
6015                     by up to 62 lowercase letters, digits and hyphens"
6016                ),
6017            ));
6018        }
6019        if alternates.iter().any(|alternate| alternate.name == name) {
6020            return Err(refuse(
6021                "name",
6022                format!("'{name}' names more than one alternate"),
6023            ));
6024        }
6025        let model = optional_trimmed_string(entry.model)
6026            .ok_or_else(|| refuse("model", "missing required field".to_string()))?;
6027        let Some(driver) = entry.driver else {
6028            return Err(refuse(
6029                "driver.artifact",
6030                "missing required field".to_string(),
6031            ));
6032        };
6033        if driver
6034            .config
6035            .as_ref()
6036            .is_some_and(|config| !matches!(config, serde_norway::Value::Null))
6037        {
6038            return Err(refuse(
6039                "driver.config",
6040                "is not valid on an alternate; inference.driver.config applies to every \
6041                 declared driver, and per-driver settings belong in that driver's \
6042                 artifacts[].config"
6043                    .to_string(),
6044            ));
6045        }
6046        let driver = optional_trimmed_string(driver.artifact)
6047            .ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
6048        if driver == primary_driver && model == primary_model {
6049            return Err(refuse(
6050                "",
6051                format!(
6052                    "'{name}' is driver '{driver}' with model '{model}', which is the primary \
6053                     choice; an alternate names a different pair"
6054                ),
6055            ));
6056        }
6057        if let Some(twin) = alternates
6058            .iter()
6059            .find(|alternate| alternate.driver == driver && alternate.model == model)
6060        {
6061            return Err(refuse(
6062                "",
6063                format!(
6064                    "'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
6065                     already is; an alternate names a different pair",
6066                    twin.name
6067                ),
6068            ));
6069        }
6070        alternates.push(InferenceAlternate {
6071            name,
6072            model,
6073            driver,
6074        });
6075    }
6076    Ok(alternates)
6077}
6078
6079/// `inference.tool_refresh` under `transport: http`: absent is [`ToolRefresh::Compaction`].
6080fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
6081    let Some(value) = raw else {
6082        return Ok(ToolRefresh::default());
6083    };
6084    ToolRefresh::ALL
6085        .into_iter()
6086        .find(|refresh| refresh.wire_name() == value)
6087        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6088            field: "inference.tool_refresh".to_string(),
6089            message: format!(
6090                "must be one of: {}; got '{value}'",
6091                ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
6092            ),
6093        })
6094}
6095
6096fn parse_context(
6097    raw: Option<RawContextConfig>,
6098) -> Result<Option<ContextConfig>, RuntimeManifestError> {
6099    let Some(raw) = raw else {
6100        return Ok(None);
6101    };
6102
6103    if let Some(max_tokens) = raw.max_tokens {
6104        if max_tokens == 0 {
6105            return Err(RuntimeManifestError::InvalidInferenceConfig {
6106                field: "context.max_tokens".to_string(),
6107                message: "must be greater than 0".to_string(),
6108            });
6109        }
6110    }
6111
6112    let fraction = |field: &str, value: Option<f32>, default: f32| match value {
6113        None => Ok(default),
6114        Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
6115        Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
6116            field: field.to_string(),
6117            message: "must be between 0.0 and 1.0".to_string(),
6118        }),
6119    };
6120
6121    let record = match raw.record.as_deref().map(str::trim) {
6122        None => true,
6123        Some("on") => true,
6124        Some("off") => false,
6125        Some(other) => {
6126            return Err(RuntimeManifestError::InvalidInferenceConfig {
6127                field: "context.record".to_string(),
6128                message: format!("unknown value '{other}'; expected: on, off"),
6129            })
6130        }
6131    };
6132
6133    Ok(Some(ContextConfig {
6134        max_tokens: raw.max_tokens,
6135        record,
6136        record_store: raw.record_store.map(|store| store.trim().to_string()),
6137        seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
6138        seed_overflow_margin: fraction(
6139            "context.seed_overflow_margin",
6140            raw.seed_overflow_margin,
6141            DEFAULT_SEED_OVERFLOW_MARGIN,
6142        )?,
6143        retain: parse_context_retain(raw.retain)?,
6144        images: parse_context_images(raw.images)?,
6145    }))
6146}
6147
6148/// The `context.images` block, each absent key at its default.
6149fn parse_context_images(
6150    raw: Option<RawContextImages>,
6151) -> Result<ContextImagesConfig, RuntimeManifestError> {
6152    let defaults = ContextImagesConfig::default();
6153    let Some(raw) = raw else {
6154        return Ok(defaults);
6155    };
6156    let invalid = |field: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
6157        field: field.to_string(),
6158        message,
6159    };
6160    let count = |field: &str, value: Option<serde_norway::Value>, default: u32| match value {
6161        None => Ok(default),
6162        Some(value) => value
6163            .as_u64()
6164            .and_then(|n| u32::try_from(n).ok())
6165            .filter(|n| *n >= 1)
6166            .ok_or_else(|| {
6167                let message = match scalar_text(&value) {
6168                    Some(text) => format!("must be a whole number of at least 1, got '{text}'"),
6169                    None => "must be a whole number of at least 1".to_string(),
6170                };
6171                invalid(field, message)
6172            }),
6173    };
6174    let max_bytes = match raw.max_bytes {
6175        None => defaults.max_bytes,
6176        Some(value) => {
6177            let field = "context.images.max_bytes";
6178            let text = scalar_text(&value)
6179                .ok_or_else(|| invalid(field, format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}")))?;
6180            let parsed = parse_byte_size(&text).map_err(|message| invalid(field, message))?;
6181            if parsed == 0 {
6182                return Err(invalid(
6183                    field,
6184                    format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
6185                ));
6186            }
6187            parsed
6188        }
6189    };
6190    Ok(ContextImagesConfig {
6191        keep: count("context.images.keep", raw.keep, defaults.keep)?,
6192        max_bytes,
6193        max_per_turn: count(
6194            "context.images.max_per_turn",
6195            raw.max_per_turn,
6196            defaults.max_per_turn,
6197        )?,
6198    })
6199}
6200
6201/// The keys `trace.retain` accepts, in the order an error lists them.
6202const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
6203
6204/// The keys `context.retain` accepts, in the order an error lists them.
6205const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
6206
6207/// What both `retain:` blocks say when they carry nothing. Stated once so the two errors, which
6208/// travel through different `RuntimeManifestError` variants, cannot drift apart.
6209const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
6210                                  keep everything, which is what no policy means";
6211
6212/// One `retain:` block, reduced to its two recognized keys.
6213///
6214/// Shared by `trace.retain` and `context.retain`: they differ only in the name of the count key,
6215/// so the shape check, the unknown-key refusal and the empty-block refusal are written once.
6216/// `count_key` is `max_sessions` or `max_messages`; `block` is the dotted path an error names.
6217fn parse_retain_block(
6218    block: &str,
6219    count_key: &str,
6220    accepted: &[&str],
6221    raw: serde_norway::Value,
6222    invalid: &dyn Fn(String, String) -> RuntimeManifestError,
6223) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
6224    let mapping = match raw {
6225        serde_norway::Value::Mapping(mapping) => mapping,
6226        // `retain:` with nothing under it parses as null and means exactly `retain: {}`.
6227        serde_norway::Value::Null => {
6228            return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
6229        }
6230        other => {
6231            return Err(invalid(
6232                block.to_string(),
6233                format!(
6234                    "must be a block of keys ({}), got '{other:?}'",
6235                    accepted.join(", ")
6236                ),
6237            ))
6238        }
6239    };
6240    if mapping.is_empty() {
6241        return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
6242    }
6243
6244    let mut count: Option<u32> = None;
6245    let mut max_age_secs: Option<u64> = None;
6246    for (key, value) in &mapping {
6247        let key = key.as_str().unwrap_or_default();
6248        let field = format!("{block}.{key}");
6249        if key == count_key {
6250            let parsed = value
6251                .as_u64()
6252                .and_then(|n| u32::try_from(n).ok())
6253                .ok_or_else(|| {
6254                    invalid(
6255                        field.clone(),
6256                        "must be a whole number of at least 1".to_string(),
6257                    )
6258                })?;
6259            if parsed == 0 {
6260                return Err(invalid(
6261                    field,
6262                    "must be at least 1 — a limit of zero would delete what retention exists \
6263                     to keep; remove the key to leave it unbounded"
6264                        .to_string(),
6265                ));
6266            }
6267            count = Some(parsed);
6268        } else if key == "max_age" {
6269            let text = scalar_text(value).ok_or_else(|| {
6270                invalid(
6271                    field.clone(),
6272                    format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
6273                )
6274            })?;
6275            let parsed =
6276                parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
6277            if parsed == 0 {
6278                return Err(invalid(
6279                    field,
6280                    format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
6281                ));
6282            }
6283            max_age_secs = Some(parsed);
6284        } else {
6285            return Err(invalid(
6286                block.to_string(),
6287                format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
6288            ));
6289        }
6290    }
6291    Ok((count, max_age_secs))
6292}
6293
6294fn parse_trace_retain(
6295    raw: Option<serde_norway::Value>,
6296) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
6297    let Some(raw) = raw else {
6298        return Ok(None);
6299    };
6300    let (max_sessions, max_age_secs) = parse_retain_block(
6301        "trace.retain",
6302        "max_sessions",
6303        &TRACE_RETAIN_KEYS,
6304        raw,
6305        &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
6306    )?;
6307    Ok(Some(TraceRetainConfig {
6308        max_sessions,
6309        max_age_secs,
6310    }))
6311}
6312
6313fn parse_context_retain(
6314    raw: Option<serde_norway::Value>,
6315) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
6316    let Some(raw) = raw else {
6317        return Ok(None);
6318    };
6319    let (max_messages, max_age_secs) = parse_retain_block(
6320        "context.retain",
6321        "max_messages",
6322        &CONTEXT_RETAIN_KEYS,
6323        raw,
6324        &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
6325    )?;
6326    Ok(Some(ContextRetainConfig {
6327        max_messages,
6328        max_age_secs,
6329    }))
6330}
6331
6332fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
6333    raw.map(|raw| ObservabilityConfig {
6334        otel_endpoint: raw
6335            .otel_endpoint
6336            .map(|value| value.trim().to_string())
6337            .filter(|value| !value.is_empty()),
6338        eval: raw.eval.map(parse_eval_config),
6339    })
6340}
6341
6342fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
6343    let scorers = raw
6344        .scorers
6345        .into_iter()
6346        .filter_map(|raw| {
6347            let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
6348            match raw.scorer_type.as_str() {
6349                "exit_ok" => Some(ScorerConfig::ExitOk { name }),
6350                "max_turns" => Some(ScorerConfig::MaxTurns {
6351                    name,
6352                    max: raw.max.unwrap_or(10) as u32,
6353                }),
6354                "max_tokens" => Some(ScorerConfig::MaxTokens {
6355                    name,
6356                    max: raw.max.unwrap_or(100_000),
6357                }),
6358                "tool_sequence" => Some(ScorerConfig::ToolSequence {
6359                    name,
6360                    expected: raw.expected.unwrap_or_default(),
6361                }),
6362                "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
6363                other => {
6364                    crate::warn_line(format_args!(
6365                        "[murmur-artifact] unknown scorer type '{other}' — skipping"
6366                    ));
6367                    None
6368                }
6369            }
6370        })
6371        .collect();
6372
6373    EvalConfig {
6374        dataset_id: raw.dataset_id,
6375        scorers,
6376    }
6377}
6378
6379fn parse_compaction(
6380    raw: Option<RawCompactionConfig>,
6381) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
6382    let Some(raw) = raw else {
6383        return Ok(None);
6384    };
6385
6386    if let Some(threshold) = raw.threshold {
6387        if threshold <= 0.0 || threshold > 1.0 {
6388            return Err(RuntimeManifestError::InvalidInferenceConfig {
6389                field: "inference.compaction.threshold".to_string(),
6390                message: format!("must be in (0.0, 1.0] but got {threshold}"),
6391            });
6392        }
6393    }
6394
6395    if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
6396        return Err(RuntimeManifestError::InvalidInferenceConfig {
6397            field: "inference.compaction.system_prompt".to_string(),
6398            message: "at most one of inference.compaction.system_prompt, \
6399                      inference.compaction.system_prompt_file may be set"
6400                .to_string(),
6401        });
6402    }
6403
6404    if raw.max_tokens == Some(0) {
6405        return Err(RuntimeManifestError::InvalidInferenceConfig {
6406            field: "inference.compaction.max_tokens".to_string(),
6407            message: "must be greater than 0".to_string(),
6408        });
6409    }
6410    let driver_config =
6411        driver_config_json(raw.driver_config, "inference.compaction.driver_config")?;
6412
6413    Ok(Some(CompactionConfig {
6414        threshold: raw.threshold,
6415        model: raw.model,
6416        system_prompt: raw.system_prompt,
6417        system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
6418        // A plain optional boolean: nothing to validate, and deliberately independent of the
6419        // threshold-range and prompt-source checks above.
6420        dump_summaries: raw.dump_summaries,
6421        max_tokens: raw.max_tokens,
6422        driver_config,
6423    }))
6424}
6425
6426fn optional_trimmed_string(value: Option<String>) -> Option<String> {
6427    value
6428        .map(|s| s.trim().to_string())
6429        .filter(|s| !s.is_empty())
6430}
6431
6432fn required_inference_field(
6433    value: Option<String>,
6434    key: &str,
6435) -> Result<String, RuntimeManifestError> {
6436    value
6437        .map(|value| value.trim().to_string())
6438        .filter(|value| !value.is_empty())
6439        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6440            field: format!("inference.{key}"),
6441            message: "missing required field".to_string(),
6442        })
6443}
6444
6445/// One artifact entry's `gateway:` block, checked and with its key reference kept unresolved.
6446///
6447/// The credential rule reads what the manifest wrote, never a resolved value, so it decides the
6448/// same under [`SecretResolution::Resolve`] and [`SecretResolution::Skip`]: a non-blank `api_key`
6449/// is keyed, `keyless: true` with no `api_key` key is keyless, both together is
6450/// [`RuntimeManifestError::InvalidArtifact`], and anything else is
6451/// [`RuntimeManifestError::GatewayWithoutCredential`]. The endpoint's address plays no part.
6452fn parse_artifact_gateway(
6453    index: usize,
6454    name: &str,
6455    raw: Option<RawArtifactGateway>,
6456    secrets: SecretResolution,
6457) -> Result<ArtifactGateway, RuntimeManifestError> {
6458    let (endpoint, api_key, keyless) = match raw {
6459        Some(raw) => (
6460            optional_trimmed_string(raw.endpoint),
6461            raw.api_key,
6462            raw.keyless,
6463        ),
6464        None => (None, None, None),
6465    };
6466    let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
6467        index,
6468        message: format!(
6469            "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
6470             the upstream URL the runtime sends this artifact's keyed requests to"
6471        ),
6472    })?;
6473    validate_gateway_endpoint(name, &endpoint)
6474        .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
6475
6476    let keyless = match keyless {
6477        None | Some(serde_norway::Value::Null) | Some(serde_norway::Value::Bool(false)) => false,
6478        Some(serde_norway::Value::Bool(true)) => true,
6479        Some(_) => {
6480            return Err(RuntimeManifestError::InvalidArtifact {
6481                index,
6482                message: format!(
6483                    "artifact '{name}' declares gateway.keyless with a value that is not a \
6484                     boolean; write keyless: true for an upstream that takes no key"
6485                ),
6486            })
6487        }
6488    };
6489    if keyless && api_key.is_some() {
6490        return Err(RuntimeManifestError::InvalidArtifact {
6491            index,
6492            message: format!(
6493                "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
6494                 gateway either presents a key or is keyless"
6495            ),
6496        });
6497    }
6498    if keyless {
6499        return Ok(ArtifactGateway {
6500            endpoint,
6501            api_key: None,
6502            keyless: true,
6503        });
6504    }
6505
6506    let written = api_key
6507        .flatten()
6508        .map(|value| value.trim().to_string())
6509        .filter(|value| !value.is_empty());
6510    let Some(written) = written else {
6511        return Err(RuntimeManifestError::GatewayWithoutCredential {
6512            index,
6513            name: name.to_string(),
6514            endpoint,
6515        });
6516    };
6517    let api_key = match secrets {
6518        SecretResolution::Skip => None,
6519        SecretResolution::Resolve => Some(match parse_env_reference(&written) {
6520            Some(variable) => ApiKeyReference::Environment(variable.to_string()),
6521            None => ApiKeyReference::Literal(written),
6522        }),
6523    };
6524
6525    Ok(ArtifactGateway {
6526        endpoint,
6527        api_key,
6528        keyless: false,
6529    })
6530}
6531
6532/// Checks a `gateway.endpoint`, the only control on where an artifact's key is sent: a
6533/// gateway-addressed request is readdressed before any `network.allow` rule is consulted, so
6534/// nothing else confines the destination.
6535///
6536/// Accepted: an `https://` URL with a host, or an `http://` URL whose host is `localhost` or an
6537/// IP literal for which [`IpAddr::is_loopback`] returns true (`127.0.0.0/8`, `::1`). An optional
6538/// port and path are kept.
6539///
6540/// Refused:
6541/// - `${` anywhere, checked before parsing: the field is never interpolated, and the URL parser
6542///   accepts `$`, `{` and `}` in a host, so `https://${HOST}` would otherwise parse as a literal
6543///   host.
6544/// - A malformed URL, a scheme other than `http`/`https`, or no host.
6545/// - Userinfo (`user@`, `user:pass@`): `https://api.example.com@evil.example` addresses
6546///   `evil.example`, and the gateway never sends userinfo, so a password there is a second,
6547///   unmanaged credential. The message names the host the URL addresses.
6548/// - A query or a fragment: the gateway readdresses each request by its own path and query, so
6549///   either would be silently dropped.
6550/// - `http://` to any host that is not loopback, including `0.0.0.0` and `::ffff:127.0.0.1`.
6551///
6552/// The endpoint confines the origin only. The guest chooses the whole path and query of every
6553/// gateway request.
6554///
6555/// The error is the whole message, naming `gateway.endpoint` and the artifact entry `artifact`.
6556/// It quotes the endpoint with any userinfo masked, so a password written into the URL is never
6557/// echoed.
6558fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
6559    let shown = mask_endpoint_userinfo(endpoint);
6560    let refuse =
6561        |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
6562    if endpoint.contains("${") {
6563        return Err(refuse(
6564            "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
6565             literally"
6566                .to_string(),
6567        ));
6568    }
6569    let url = Url::parse(endpoint)
6570        .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
6571    let host = match url.scheme() {
6572        "http" | "https" => url
6573            .host_str()
6574            .filter(|host| !host.is_empty())
6575            .ok_or_else(|| refuse("which has no host".to_string()))?,
6576        other => {
6577            return Err(refuse(format!(
6578                "whose scheme '{other}' is unsupported — expected http or https"
6579            )))
6580        }
6581    };
6582    if !url.username().is_empty() || url.password().is_some() {
6583        return Err(refuse(format!(
6584            "which carries userinfo before '@' — the gateway never sends it, and this URL \
6585             addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
6586             the key through gateway.api_key"
6587        )));
6588    }
6589    if url.query().is_some() {
6590        return Err(refuse(
6591            "which carries a query — the gateway keeps each request's own query, so write the \
6592             upstream without one"
6593                .to_string(),
6594        ));
6595    }
6596    if url.fragment().is_some() {
6597        return Err(refuse(
6598            "which carries a fragment — write the upstream without one".to_string(),
6599        ));
6600    }
6601    if url.scheme() == "http" {
6602        let bare = host.trim_start_matches('[').trim_end_matches(']');
6603        let is_loopback = bare == "localhost"
6604            || bare
6605                .parse::<IpAddr>()
6606                .map(|ip| ip.is_loopback())
6607                .unwrap_or(false);
6608        if !is_loopback {
6609            return Err(refuse(format!(
6610                "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
6611                 remote upstream, or http://localhost or a loopback IP literal for a local one"
6612            )));
6613        }
6614    }
6615    Ok(())
6616}
6617
6618/// `endpoint` with everything before the last `@` of its authority replaced by `***`.
6619///
6620/// Textual rather than parsed, so it also masks an endpoint the URL parser rejects.
6621fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
6622    let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
6623    let rest = &endpoint[authority_start..];
6624    let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
6625    match rest[..authority_len].rfind('@') {
6626        Some(at) => format!(
6627            "{}***{}",
6628            &endpoint[..authority_start],
6629            &endpoint[authority_start + at..]
6630        )
6631        .into(),
6632        None => endpoint.into(),
6633    }
6634}
6635
6636/// The cross-entry rules for a `gateway:` on a driver, which need the parsed `inference:` block.
6637///
6638/// A driver's gateway is a metered inference gateway only when that driver is the configured
6639/// `transport: http` driver or an `inference.alternates` entry's driver. On any other driver entry
6640/// it could be spent only through tool dispatch, where nothing meters it, so it is refused. Under
6641/// `transport: http` the configured driver's entry must exist and must carry the upstream.
6642fn validate_driver_gateways(
6643    artifacts: &[RuntimeArtifact],
6644    inference: Option<&InferenceConfig>,
6645) -> Result<(), RuntimeManifestError> {
6646    let configured_driver = inference
6647        .filter(|inference| inference.transport == "http")
6648        .and_then(|inference| inference.driver.as_ref())
6649        .map(|driver| driver.artifact.as_str());
6650    let alternate_drivers: Vec<&str> = inference
6651        .map(|inference| {
6652            inference
6653                .alternates
6654                .iter()
6655                .map(|alternate| alternate.driver.as_str())
6656                .collect()
6657        })
6658        .unwrap_or_default();
6659    let under_process = inference.is_some_and(|inference| inference.transport == "process");
6660
6661    // A reference to nothing is reported as such before any entry is judged against it: a
6662    // misspelt driver name would otherwise read as a gateway on the wrong driver.
6663    if let Some(driver) = configured_driver {
6664        if !artifacts.iter().any(|artifact| artifact.name == driver) {
6665            return Err(RuntimeManifestError::InvalidInferenceConfig {
6666                field: "inference.driver.artifact".to_string(),
6667                message: not_declared_message(artifacts, driver),
6668            });
6669        }
6670    }
6671
6672    for (index, artifact) in artifacts.iter().enumerate() {
6673        if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
6674            continue;
6675        }
6676        if under_process {
6677            return Err(RuntimeManifestError::InvalidArtifact {
6678                index,
6679                message: format!(
6680                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
6681                     valid with transport: process — the CLI subprocess holds its own \
6682                     credentials and a process driver is granted nothing",
6683                    artifact.name
6684                ),
6685            });
6686        }
6687        if configured_driver != Some(artifact.name.as_str())
6688            && !alternate_drivers.contains(&artifact.name.as_str())
6689        {
6690            return Err(RuntimeManifestError::InvalidArtifact {
6691                index,
6692                message: format!(
6693                    "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
6694                     the configured inference driver; a driver's gateway is accepted only on the \
6695                     entry inference.driver.artifact names under transport: http, or on one an \
6696                     inference.alternates entry names",
6697                    artifact.name
6698                ),
6699            });
6700        }
6701    }
6702
6703    if let Some(driver) = configured_driver {
6704        let Some((index, entry)) = artifacts
6705            .iter()
6706            .enumerate()
6707            .find(|(_, artifact)| artifact.name == driver)
6708        else {
6709            return Err(RuntimeManifestError::InvalidInferenceConfig {
6710                field: "inference.driver.artifact".to_string(),
6711                message: not_declared_message(artifacts, driver),
6712            });
6713        };
6714        if entry.gateway.is_none() {
6715            return Err(RuntimeManifestError::InvalidArtifact {
6716                index,
6717                message: format!(
6718                    "artifact '{driver}' is the transport: http inference driver but declares no \
6719                     gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
6720                     on its entry"
6721                ),
6722            });
6723        }
6724    }
6725    Ok(())
6726}
6727
6728/// The cross-entry rules for `inference.alternates`, which need `artifacts:`.
6729///
6730/// Each alternate's `driver.artifact` must be an `artifacts:` entry with `runtime: driver`, and
6731/// that entry must carry its own `gateway:` — the credential a switch attaches — unless it is the
6732/// primary's own driver, whose gateway [`validate_driver_gateways`] already requires.
6733fn validate_driver_choices(
6734    artifacts: &[RuntimeArtifact],
6735    inference: Option<&InferenceConfig>,
6736) -> Result<(), RuntimeManifestError> {
6737    let Some(inference) = inference else {
6738        return Ok(());
6739    };
6740    let primary_driver = inference
6741        .driver
6742        .as_ref()
6743        .map(|driver| driver.artifact.as_str());
6744    for (index, alternate) in inference.alternates.iter().enumerate() {
6745        let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
6746            field: format!("inference.alternates[{index}].driver.artifact"),
6747            message,
6748        };
6749        let driver = alternate.driver.as_str();
6750        let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
6751            return Err(refuse(not_declared_message(artifacts, driver)));
6752        };
6753        if entry.runtime != ArtifactRuntime::Driver {
6754            return Err(refuse(format!(
6755                "artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
6756                 runtime: driver entry",
6757                entry.runtime.as_str()
6758            )));
6759        }
6760        if entry.gateway.is_none() && primary_driver != Some(driver) {
6761            return Err(refuse(format!(
6762                "artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
6763                 its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
6764                 URL>, api_key: ... }}' on its entry"
6765            )));
6766        }
6767    }
6768    Ok(())
6769}
6770
6771/// Refuses alternates nothing could ever select: `inference.driver` is in neither
6772/// `control.settings` nor `control.agent_settings`.
6773fn validate_alternates_selectable(
6774    inference: Option<&InferenceConfig>,
6775    control: Option<&ControlConfig>,
6776) -> Result<(), RuntimeManifestError> {
6777    if inference.is_none_or(|inference| inference.alternates.is_empty()) {
6778        return Ok(());
6779    }
6780    let selectable = control.is_some_and(|control| {
6781        control.controller_may_set(ControllableSetting::InferenceDriver)
6782            || control.agent_may_set(ControllableSetting::InferenceDriver)
6783    });
6784    if selectable {
6785        return Ok(());
6786    }
6787    Err(RuntimeManifestError::InvalidInferenceConfig {
6788        field: "inference.alternates".to_string(),
6789        message: "declares alternates that nothing could ever select; list inference.driver in \
6790                  control.settings for a controller to switch, or in control.agent_settings for \
6791                  the agent to"
6792            .to_string(),
6793    })
6794}
6795
6796/// The cross-entry rules for the `transport: process` driver, which need the parsed
6797/// `inference:` block.
6798///
6799/// The artifact `inference.driver.artifact` names must be declared in `artifacts:` with
6800/// `runtime: driver`. A process driver is granted nothing, so its entry may carry neither
6801/// `capabilities:` nor `config:`; its settings travel in `inference.driver.config`. A `gateway:`
6802/// on it is refused by [`validate_driver_gateways`].
6803fn validate_process_driver_entry(
6804    artifacts: &[RuntimeArtifact],
6805    inference: Option<&InferenceConfig>,
6806) -> Result<(), RuntimeManifestError> {
6807    let Some(driver) = inference
6808        .filter(|inference| inference.transport == "process")
6809        .and_then(|inference| inference.driver.as_ref())
6810        .map(|driver| driver.artifact.as_str())
6811    else {
6812        return Ok(());
6813    };
6814    let Some((index, entry)) = artifacts
6815        .iter()
6816        .enumerate()
6817        .find(|(_, artifact)| artifact.name == driver)
6818    else {
6819        return Err(RuntimeManifestError::InvalidInferenceConfig {
6820            field: "inference.driver.artifact".to_string(),
6821            message: not_declared_message(artifacts, driver),
6822        });
6823    };
6824    if entry.runtime != ArtifactRuntime::Driver {
6825        return Err(RuntimeManifestError::InvalidArtifact {
6826            index,
6827            message: format!(
6828                "artifact '{driver}' is the transport: process driver and must be declared with \
6829                 runtime: driver"
6830            ),
6831        });
6832    }
6833    if entry.capabilities.is_some() || entry.config.is_some() {
6834        return Err(RuntimeManifestError::InvalidArtifact {
6835            index,
6836            message: format!(
6837                "artifact '{driver}' is the transport: process driver, which is granted nothing; \
6838                 remove capabilities:/config: from its entry — driver settings go in \
6839                 inference.driver.config"
6840            ),
6841        });
6842    }
6843    Ok(())
6844}
6845
6846fn parse_inference_driver(
6847    raw: RawInferenceDriver,
6848) -> Result<InferenceDriver, RuntimeManifestError> {
6849    let artifact = raw
6850        .artifact
6851        .map(|value| value.trim().to_string())
6852        .filter(|value| !value.is_empty())
6853        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6854            field: "inference.driver.artifact".to_string(),
6855            message: "missing required field".to_string(),
6856        })?;
6857
6858    let config = driver_config_json(raw.config, "inference.driver.config")?;
6859
6860    Ok(InferenceDriver { artifact, config })
6861}
6862
6863/// Lowers a driver config mapping to compact JSON, or `None` when absent or null. `field` names
6864/// the key in the refusal of anything but a mapping.
6865fn driver_config_json(
6866    raw: Option<serde_norway::Value>,
6867    field: &str,
6868) -> Result<Option<String>, RuntimeManifestError> {
6869    let Some(raw) = raw else {
6870        return Ok(None);
6871    };
6872
6873    if matches!(raw, serde_norway::Value::Null) {
6874        return Ok(None);
6875    }
6876
6877    if !matches!(raw, serde_norway::Value::Mapping(_)) {
6878        return Err(RuntimeManifestError::InvalidInferenceConfig {
6879            field: field.to_string(),
6880            message: "expected mapping/object".to_string(),
6881        });
6882    }
6883
6884    let value =
6885        serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
6886            field: field.to_string(),
6887            message: format!("failed to convert to JSON: {err}"),
6888        })?;
6889
6890    serde_json::to_string(&value).map(Some).map_err(|err| {
6891        RuntimeManifestError::InvalidInferenceConfig {
6892            field: field.to_string(),
6893            message: format!("failed to encode JSON: {err}"),
6894        }
6895    })
6896}
6897
6898fn parse_env_reference(value: &str) -> Option<&str> {
6899    if !value.starts_with("${") || !value.ends_with('}') {
6900        return None;
6901    }
6902
6903    let variable = &value[2..value.len() - 1];
6904    is_valid_env_variable(variable).then_some(variable)
6905}
6906
6907fn is_valid_env_variable(value: &str) -> bool {
6908    let mut chars = value.chars();
6909    let Some(first) = chars.next() else {
6910        return false;
6911    };
6912
6913    if !(first == '_' || first.is_ascii_uppercase()) {
6914        return false;
6915    }
6916
6917    chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
6918}
6919
6920#[cfg(test)]
6921mod tests {
6922    use super::*;
6923    use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
6924
6925    fn manifest_with_trace(block: &str) -> String {
6926        format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
6927    }
6928
6929    fn lifecycle(task_acceptance: TaskAcceptance, queue_depth: usize) -> LifecycleConfig {
6930        LifecycleConfig {
6931            task_acceptance,
6932            queue_depth,
6933            ..Default::default()
6934        }
6935    }
6936
6937    #[test]
6938    fn tasks_held_at_once_under_queue_is_one_running_plus_queue_depth_waiting() {
6939        assert_eq!(lifecycle(TaskAcceptance::Queue, 1).tasks_held_at_once(), 2);
6940        assert_eq!(lifecycle(TaskAcceptance::Queue, 9).tasks_held_at_once(), 10);
6941    }
6942
6943    #[test]
6944    fn queue_has_room_is_false_only_for_a_queue_at_depth_zero() {
6945        assert!(!lifecycle(TaskAcceptance::Queue, 0).queue_has_room());
6946        assert!(lifecycle(TaskAcceptance::Queue, 1).queue_has_room());
6947        assert!(lifecycle(TaskAcceptance::Single, 0).queue_has_room());
6948        assert!(lifecycle(TaskAcceptance::None, 0).queue_has_room());
6949        assert!(lifecycle(TaskAcceptance::Single, 3).queue_has_room());
6950    }
6951
6952    fn parse_lifecycle(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
6953        RuntimeManifest::from_yaml_str(&format!(
6954            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n{block}"
6955        ))
6956    }
6957
6958    #[test]
6959    fn queue_depth_zero_under_queue_is_refused_at_load() {
6960        let error = parse_lifecycle("  task_acceptance: queue\n  queue_depth: 0\n").unwrap_err();
6961        assert!(
6962            matches!(error, RuntimeManifestError::QueueWithoutRoom),
6963            "{error:?}"
6964        );
6965        assert_eq!(
6966            error.to_string(),
6967            "murmur.yaml: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves \
6968             task_acceptance: queue no room for a waiting task"
6969        );
6970    }
6971
6972    /// `queue_depth` means nothing outside `queue`, so `0` there parses.
6973    #[test]
6974    fn queue_depth_zero_outside_queue_still_parses() {
6975        for block in [
6976            "  task_acceptance: single\n  queue_depth: 0\n",
6977            "  task_acceptance: none\n  queue_depth: 0\n",
6978            "  queue_depth: 0\n",
6979        ] {
6980            let lifecycle = parse_lifecycle(block)
6981                .unwrap_or_else(|error| panic!("{block:?}: {error}"))
6982                .effective_lifecycle();
6983            assert_eq!(lifecycle.queue_depth, 0, "{block:?}");
6984        }
6985        let queue = parse_lifecycle("  task_acceptance: queue\n  queue_depth: 1\n")
6986            .unwrap()
6987            .effective_lifecycle();
6988        assert_eq!(queue.task_acceptance, TaskAcceptance::Queue);
6989        assert_eq!(queue.queue_depth, 1);
6990    }
6991
6992    #[test]
6993    fn tasks_held_at_once_under_single_is_one_whatever_the_depth() {
6994        assert_eq!(lifecycle(TaskAcceptance::Single, 1).tasks_held_at_once(), 1);
6995        assert_eq!(lifecycle(TaskAcceptance::Single, 5).tasks_held_at_once(), 1);
6996    }
6997
6998    #[test]
6999    fn tasks_held_at_once_under_none_is_zero_whatever_the_depth() {
7000        assert_eq!(lifecycle(TaskAcceptance::None, 1).tasks_held_at_once(), 0);
7001        assert_eq!(lifecycle(TaskAcceptance::None, 5).tasks_held_at_once(), 0);
7002    }
7003
7004    /// The default is `single` at `queue_depth: 1`, which holds one task; switching only
7005    /// `task_acceptance` to `queue` keeps the default depth and holds two.
7006    #[test]
7007    fn tasks_held_at_once_of_the_default_lifecycle_and_default_depth() {
7008        assert_eq!(LifecycleConfig::default().tasks_held_at_once(), 1);
7009        let queue = RuntimeManifest::from_yaml_str(
7010            "name: cap\nversion: 0.0.1\nlifecycle:\n  task_acceptance: queue\n",
7011        )
7012        .unwrap()
7013        .effective_lifecycle();
7014        assert_eq!(queue.queue_depth, 1);
7015        assert_eq!(queue.tasks_held_at_once(), 2);
7016    }
7017
7018    /// No `trace:` block at all leaves the config absent; the runtime substitutes
7019    /// [`TraceCapture::default`] rather than the manifest carrying a synthesized one.
7020    #[test]
7021    fn absent_trace_block_stays_absent_and_defaults_to_meta() {
7022        let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
7023        assert_eq!(manifest.trace, None);
7024        assert_eq!(TraceCapture::default(), TraceCapture::Meta);
7025    }
7026
7027    #[test]
7028    fn each_capture_mode_parses_from_the_trace_block() {
7029        for (yaml, expected) in [
7030            ("none", TraceCapture::None),
7031            ("meta", TraceCapture::Meta),
7032            ("content", TraceCapture::Content),
7033        ] {
7034            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
7035                "  capture: {yaml}\n"
7036            )))
7037            .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
7038            assert_eq!(
7039                manifest.trace,
7040                Some(TraceConfig {
7041                    capture: expected,
7042                    retain: None
7043                })
7044            );
7045        }
7046    }
7047
7048    // ── retain ───────────────────────────────────────────────────────────────
7049
7050    /// A `trace:` block with only `capture` and a `context:` block with only `record` declare no
7051    /// retention at all: an absent `retain:` is the only way to say "keep everything", and it is
7052    /// what a capsule that never mentions retention gets.
7053    #[test]
7054    fn no_retain_block_anywhere_parses_as_no_policy() {
7055        let manifest = RuntimeManifest::from_yaml_str(
7056            "name: cap\nversion: 0.0.1\ntrace:\n  capture: content\ncontext:\n  record: on\n",
7057        )
7058        .unwrap();
7059        assert_eq!(manifest.trace.unwrap().retain, None);
7060        assert_eq!(manifest.context.unwrap().retain, None);
7061    }
7062
7063    #[test]
7064    fn trace_retain_parses_both_keys_and_the_day_suffix() {
7065        let manifest = RuntimeManifest::from_yaml_str(
7066            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 50\n    max_age: 14d\n",
7067        )
7068        .unwrap();
7069        assert_eq!(
7070            manifest.trace.unwrap().retain,
7071            Some(TraceRetainConfig {
7072                max_sessions: Some(50),
7073                max_age_secs: Some(14 * 86_400),
7074            })
7075        );
7076    }
7077
7078    fn context_images_of(block: &str) -> Result<ContextImagesConfig, RuntimeManifestError> {
7079        RuntimeManifest::from_yaml_str(&format!(
7080            "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n{block}"
7081        ))
7082        .map(|manifest| manifest.context.unwrap().images)
7083    }
7084
7085    #[test]
7086    fn context_images_defaults_apply_without_the_block_and_to_each_absent_key() {
7087        let defaults = ContextImagesConfig {
7088            keep: 5,
7089            max_bytes: 5 * 1024 * 1024,
7090            max_per_turn: 5,
7091        };
7092        assert_eq!(ContextImagesConfig::default(), defaults);
7093        assert_eq!(context_images_of("").unwrap(), defaults);
7094        assert_eq!(
7095            context_images_of("  images:\n    keep: 2\n").unwrap(),
7096            ContextImagesConfig {
7097                keep: 2,
7098                ..defaults
7099            }
7100        );
7101    }
7102
7103    #[test]
7104    fn context_images_parses_every_key() {
7105        assert_eq!(
7106            context_images_of("  images:\n    keep: 1\n    max_bytes: 1Ki\n    max_per_turn: 9\n")
7107                .unwrap(),
7108            ContextImagesConfig {
7109                keep: 1,
7110                max_bytes: 1024,
7111                max_per_turn: 9,
7112            }
7113        );
7114        assert_eq!(
7115            context_images_of("  images:\n    max_bytes: 4096\n")
7116                .unwrap()
7117                .max_bytes,
7118            4096
7119        );
7120    }
7121
7122    #[test]
7123    fn context_images_refuses_zero_and_a_decimal_byte_suffix() {
7124        for (block, field) in [
7125            ("  images:\n    keep: 0\n", "context.images.keep"),
7126            (
7127                "  images:\n    max_per_turn: 0\n",
7128                "context.images.max_per_turn",
7129            ),
7130            ("  images:\n    max_bytes: 0\n", "context.images.max_bytes"),
7131            (
7132                "  images:\n    max_bytes: 10MB\n",
7133                "context.images.max_bytes",
7134            ),
7135            ("  images:\n    keep: many\n", "context.images.keep"),
7136        ] {
7137            match context_images_of(block) {
7138                Err(RuntimeManifestError::InvalidInferenceConfig { field: got, .. }) => {
7139                    assert_eq!(got, field, "{block}")
7140                }
7141                other => panic!("{block}: expected a refusal naming {field}, got {other:?}"),
7142            }
7143        }
7144        let error = context_images_of("  images:\n    keep: 0\n").unwrap_err();
7145        assert!(
7146            error.to_string().contains("got '0'"),
7147            "the value is named as written: {error}"
7148        );
7149    }
7150
7151    #[test]
7152    fn context_images_unknown_key_is_suggested() {
7153        assert_eq!(
7154            unknown_keys_of("name: cap\nversion: 0.1.0\ncontext:\n  images:\n    kep: 3\n"),
7155            vec![(
7156                "kep".to_string(),
7157                "context.images".to_string(),
7158                Some("keep".to_string())
7159            )]
7160        );
7161    }
7162
7163    #[test]
7164    fn context_retain_parses_both_keys_and_either_alone() {
7165        let both = RuntimeManifest::from_yaml_str(
7166            "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n    max_messages: 2000\n    max_age: 90d\n",
7167        )
7168        .unwrap();
7169        assert_eq!(
7170            both.context.unwrap().retain,
7171            Some(ContextRetainConfig {
7172                max_messages: Some(2000),
7173                max_age_secs: Some(90 * 86_400),
7174            })
7175        );
7176
7177        let count_only = RuntimeManifest::from_yaml_str(
7178            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 10\n",
7179        )
7180        .unwrap();
7181        assert_eq!(
7182            count_only.context.unwrap().retain,
7183            Some(ContextRetainConfig {
7184                max_messages: Some(10),
7185                max_age_secs: None,
7186            })
7187        );
7188    }
7189
7190    /// An empty block is not a way to declare no policy — omitting the block is. Both spellings
7191    /// of empty are refused, naming the block an operator has to go and change.
7192    #[test]
7193    fn an_empty_retain_block_is_refused_naming_the_block() {
7194        for (yaml, expect_trace) in [
7195            (
7196                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain: {}\n",
7197                true,
7198            ),
7199            (
7200                "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n",
7201                true,
7202            ),
7203            (
7204                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain: {}\n",
7205                false,
7206            ),
7207            (
7208                "name: cap\nversion: 0.0.1\ncontext:\n  record: on\n  retain:\n",
7209                false,
7210            ),
7211        ] {
7212            let err = RuntimeManifest::from_yaml_str(yaml)
7213                .expect_err("an empty retain block must be refused");
7214            let (field, message) = match (&err, expect_trace) {
7215                (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
7216                    (field, message)
7217                }
7218                (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
7219                    (field, message)
7220                }
7221                _ => panic!("wrong variant for {yaml:?}: {err:?}"),
7222            };
7223            assert_eq!(
7224                field,
7225                if expect_trace {
7226                    "trace.retain"
7227                } else {
7228                    "context.retain"
7229                }
7230            );
7231            assert!(message.contains("at least one key"), "{message}");
7232        }
7233    }
7234
7235    /// A limit of zero would delete what retention exists to keep, so it is
7236    /// refused naming the key rather than accepted as "keep nothing".
7237    #[test]
7238    fn a_zero_retain_limit_is_refused_naming_the_key() {
7239        let trace = RuntimeManifest::from_yaml_str(
7240            "name: cap\nversion: 0.0.1\ntrace:\n  capture: meta\n  retain:\n    max_sessions: 0\n",
7241        )
7242        .expect_err("max_sessions: 0 must be refused");
7243        assert!(
7244            matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
7245                     if field == "trace.retain.max_sessions"),
7246            "{trace:?}"
7247        );
7248
7249        let context = RuntimeManifest::from_yaml_str(
7250            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_messages: 0\n",
7251        )
7252        .expect_err("max_messages: 0 must be refused");
7253        assert!(
7254            matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
7255                     if field == "context.retain.max_messages"),
7256            "{context:?}"
7257        );
7258
7259        let age = RuntimeManifest::from_yaml_str(
7260            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_age: 0\n",
7261        )
7262        .expect_err("max_age: 0 must be refused");
7263        assert!(
7264            matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
7265                     if field == "trace.retain.max_age"),
7266            "{age:?}"
7267        );
7268    }
7269
7270    #[test]
7271    fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
7272        let err = RuntimeManifest::from_yaml_str(
7273            "name: cap\nversion: 0.0.1\ntrace:\n  retain:\n    max_traces: 3\n",
7274        )
7275        .expect_err("an unknown key must be refused");
7276        match err {
7277            RuntimeManifestError::InvalidTraceConfig { field, message } => {
7278                assert_eq!(field, "trace.retain");
7279                assert!(message.contains("max_traces"), "{message}");
7280                assert!(message.contains("max_sessions"), "{message}");
7281            }
7282            other => panic!("expected InvalidTraceConfig, got {other:?}"),
7283        }
7284    }
7285
7286    #[test]
7287    fn an_unparseable_retain_duration_reports_the_accepted_form() {
7288        let err = RuntimeManifest::from_yaml_str(
7289            "name: cap\nversion: 0.0.1\ncontext:\n  retain:\n    max_age: 5 weeks\n",
7290        )
7291        .expect_err("'5 weeks' must be refused");
7292        match err {
7293            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
7294                assert_eq!(field, "context.retain.max_age");
7295                assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
7296            }
7297            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
7298        }
7299    }
7300
7301    #[test]
7302    fn unparseable_capture_value_names_the_field_and_accepted_values() {
7303        let err = RuntimeManifest::from_yaml_str(&manifest_with_trace("  capture: verbose\n"))
7304            .expect_err("'verbose' is not a capture mode");
7305        let rendered = err.to_string();
7306        assert!(rendered.contains("trace.capture"), "{rendered}");
7307        for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
7308            assert!(rendered.contains(accepted), "{rendered}");
7309        }
7310    }
7311
7312    #[test]
7313    fn resources_block_is_optional_and_absent_stays_absent() {
7314        let manifest = RuntimeManifest::from_yaml_str(
7315            r#"
7316name: cap
7317version: 0.0.1
7318capabilities:
7319  shell:
7320    allow: [bash]
7321"#,
7322        )
7323        .unwrap();
7324
7325        // `None` here is "the manifest said nothing", not "unlimited" — the runtime substitutes
7326        // its own defaults (see `capsule_runtime::resources::HostResourceLimits::resolve`), and
7327        // keeping the two states distinct is what lets it own them in one place.
7328        let caps = manifest.capabilities.expect("shell block must parse");
7329        assert_eq!(caps.resources, None);
7330    }
7331
7332    #[test]
7333    fn resources_fields_parse_and_each_stays_independently_optional() {
7334        let manifest = RuntimeManifest::from_yaml_str(
7335            r#"
7336name: cap
7337version: 0.0.1
7338capabilities:
7339  resources:
7340    max_processes: 32
7341    max_open_files: 16
7342    max_file_size_bytes: 1048576
7343    cpu_seconds: 30
7344    memory_bytes: 268435456
7345    cgroup_memory_bytes: 536870912
7346    cgroup_pids_max: 64
7347    cgroup_cpu_percent: 50
7348    cgroup_io_bytes_per_sec: 1048576
7349    workdir_max_bytes: 2097152
7350"#,
7351        )
7352        .unwrap();
7353
7354        let resources = manifest
7355            .capabilities
7356            .and_then(|caps| caps.resources)
7357            .expect("resources block must parse");
7358        assert_eq!(resources.max_processes, Some(32));
7359        assert_eq!(resources.max_open_files, Some(16));
7360        assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
7361        assert_eq!(resources.cpu_seconds, Some(30));
7362        assert_eq!(resources.memory_bytes, Some(268_435_456));
7363        assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
7364        assert_eq!(resources.cgroup_pids_max, Some(64));
7365        assert_eq!(resources.cgroup_cpu_percent, Some(50));
7366        assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
7367        assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
7368    }
7369
7370    #[test]
7371    fn state_block_is_optional_and_absent_stays_absent() {
7372        let manifest = RuntimeManifest::from_yaml_str(
7373            r#"
7374name: cap
7375version: 0.0.1
7376artifacts:
7377  - name: local/notes-tool
7378    version: 1.0.0
7379    runtime: tool
7380    capabilities:
7381      filesystem:
7382        scope: cache
7383"#,
7384        )
7385        .unwrap();
7386
7387        // Absent is deny: no store name to default, and nothing downstream creates a directory.
7388        let caps = manifest.artifacts[0]
7389            .capabilities
7390            .as_ref()
7391            .expect("filesystem block must parse");
7392        assert_eq!(caps.state, None);
7393    }
7394
7395    /// A declared-but-empty `state:` block is a real grant — it means "give me a store, named
7396    /// after the capsule" — so it must lower to `Some(..)` with no name, distinguishable from
7397    /// the absent block above.
7398    #[test]
7399    fn state_block_declared_empty_grants_the_capsule_named_store() {
7400        let manifest = RuntimeManifest::from_yaml_str(
7401            r#"
7402name: cap
7403version: 0.0.1
7404artifacts:
7405  - name: local/notes-tool
7406    version: 1.0.0
7407    runtime: tool
7408    capabilities:
7409      state: {}
7410"#,
7411        )
7412        .unwrap();
7413
7414        let state = manifest.artifacts[0]
7415            .capabilities
7416            .as_ref()
7417            .and_then(|caps| caps.state.as_ref())
7418            .expect("state block must parse");
7419        assert_eq!(state.store, None);
7420    }
7421
7422    #[test]
7423    fn state_store_name_parses_and_stays_independently_optional() {
7424        let manifest = RuntimeManifest::from_yaml_str(
7425            r#"
7426name: cap
7427version: 0.0.1
7428artifacts:
7429  - name: local/notes-tool
7430    version: 1.0.0
7431    runtime: tool
7432    capabilities:
7433      state:
7434        store: "  shey  "
7435      network:
7436        allow: [https://api.example.com]
7437"#,
7438        )
7439        .unwrap();
7440
7441        let caps = manifest.artifacts[0]
7442            .capabilities
7443            .as_ref()
7444            .expect("capabilities must parse");
7445        // Trimmed, but otherwise verbatim: whether the name is a usable directory segment is
7446        // decided by the runtime (`E-CAP-009`), not here.
7447        assert_eq!(
7448            caps.state.as_ref().and_then(|state| state.store.as_deref()),
7449            Some("shey")
7450        );
7451        // Declaring a store never touches any sibling sub-block.
7452        assert_eq!(
7453            caps.network
7454                .as_ref()
7455                .map(|network| network.allow.as_slice()),
7456            Some(["https://api.example.com".to_string()].as_slice())
7457        );
7458        assert_eq!(caps.filesystem, None);
7459    }
7460
7461    /// An explicitly empty name is kept rather than normalised to "default to the capsule name":
7462    /// the operator wrote a name, and handing them a different store than the one they wrote
7463    /// would be worse than the refusal the runtime raises for it.
7464    #[test]
7465    fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
7466        let manifest = RuntimeManifest::from_yaml_str(
7467            r#"
7468name: cap
7469version: 0.0.1
7470artifacts:
7471  - name: local/notes-tool
7472    version: 1.0.0
7473    runtime: tool
7474    capabilities:
7475      state:
7476        store: ""
7477"#,
7478        )
7479        .unwrap();
7480
7481        assert_eq!(
7482            manifest.artifacts[0]
7483                .capabilities
7484                .as_ref()
7485                .and_then(|caps| caps.state.as_ref())
7486                .and_then(|state| state.store.as_deref()),
7487            Some("")
7488        );
7489    }
7490
7491    #[test]
7492    fn resources_partial_block_leaves_undeclared_fields_none() {
7493        let manifest = RuntimeManifest::from_yaml_str(
7494            r#"
7495name: cap
7496version: 0.0.1
7497capabilities:
7498  resources:
7499    max_open_files: 16
7500"#,
7501        )
7502        .unwrap();
7503
7504        let resources = manifest
7505            .capabilities
7506            .and_then(|caps| caps.resources)
7507            .expect("resources block must parse");
7508        assert_eq!(resources.max_open_files, Some(16));
7509        assert_eq!(resources.max_processes, None);
7510        assert_eq!(resources.workdir_max_bytes, None);
7511    }
7512
7513    /// Every field rejects `0` at parse time, naming its own path. A zero ceiling is never what
7514    /// an author means, and accepting one would turn a typo into a subprocess that cannot fork,
7515    /// open a file, or run for a single CPU-second — discovered at runtime instead of at parse.
7516    #[test]
7517    fn resources_zero_is_rejected_on_every_field() {
7518        for field in [
7519            "max_processes",
7520            "max_open_files",
7521            "max_file_size_bytes",
7522            "cpu_seconds",
7523            "memory_bytes",
7524            "cgroup_memory_bytes",
7525            "cgroup_pids_max",
7526            "cgroup_cpu_percent",
7527            "cgroup_io_bytes_per_sec",
7528            "workdir_max_bytes",
7529        ] {
7530            let yaml =
7531                format!("name: cap\nversion: 0.0.1\ncapabilities:\n  resources:\n    {field}: 0\n");
7532            let error =
7533                RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
7534
7535            match error {
7536                RuntimeManifestError::InvalidCapabilities {
7537                    field: reported,
7538                    message,
7539                } => {
7540                    assert_eq!(reported, format!("capabilities.resources.{field}"));
7541                    assert_eq!(message, "must be greater than zero");
7542                }
7543                other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
7544            }
7545        }
7546    }
7547
7548    #[test]
7549    fn artifact_runtime_defaults_to_tool() {
7550        let manifest = RuntimeManifest::from_yaml_str(
7551            r#"
7552name: cap
7553version: 0.0.1
7554artifacts:
7555  - name: local/echo
7556    version: 1.2.3
7557"#,
7558        )
7559        .unwrap();
7560
7561        assert_eq!(manifest.artifacts.len(), 1);
7562        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7563    }
7564
7565    #[test]
7566    fn artifact_runtime_tool_explicit() {
7567        let manifest = RuntimeManifest::from_yaml_str(
7568            r#"
7569name: cap
7570version: 0.0.1
7571artifacts:
7572  - name: local/echo
7573    version: 1.2.3
7574    runtime: tool
7575"#,
7576        )
7577        .unwrap();
7578
7579        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7580    }
7581
7582    #[test]
7583    fn artifact_runtime_wasm_is_error() {
7584        let err = RuntimeManifest::from_yaml_str(
7585            r#"
7586name: cap
7587version: 0.0.1
7588artifacts:
7589  - name: local/echo
7590    version: 1.2.3
7591    runtime: wasm
7592"#,
7593        )
7594        .unwrap_err();
7595
7596        let msg = err.to_string();
7597        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7598        assert!(msg.contains("wasm"), "error was: {msg}");
7599    }
7600
7601    #[test]
7602    fn artifact_runtime_native_is_error() {
7603        let err = RuntimeManifest::from_yaml_str(
7604            r#"
7605name: cap
7606version: 0.0.1
7607artifacts:
7608  - name: local/echo
7609    version: 1.2.3
7610    runtime: native
7611"#,
7612        )
7613        .unwrap_err();
7614
7615        let msg = err.to_string();
7616        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7617        assert!(msg.contains("native"), "error was: {msg}");
7618    }
7619
7620    #[test]
7621    fn invalid_artifact_reports_index() {
7622        let err = RuntimeManifest::from_yaml_str(
7623            r#"
7624name: cap
7625version: 0.0.1
7626artifacts:
7627  - name: ""
7628    version: 1.2.3
7629"#,
7630        )
7631        .unwrap_err();
7632
7633        let msg = err.to_string();
7634        assert!(msg.contains("index 0"));
7635        assert!(msg.contains("missing required field 'name'"));
7636    }
7637
7638    #[test]
7639    fn capabilities_parse_when_all_fields_present() {
7640        let manifest = RuntimeManifest::from_yaml_str(
7641            r#"
7642name: cap
7643version: 0.0.1
7644artifacts:
7645  - name: local/echo
7646    version: 1.2.3
7647capabilities:
7648  network:
7649    allow:
7650      - https://api.anthropic.com
7651      - http://127.0.0.1:8080
7652  filesystem:
7653    scope: ./sandbox
7654"#,
7655        )
7656        .unwrap();
7657
7658        let capabilities = manifest.capabilities.expect("capabilities should exist");
7659        assert_eq!(
7660            capabilities.network.unwrap().allow,
7661            vec![
7662                "https://api.anthropic.com".to_string(),
7663                "http://127.0.0.1:8080".to_string()
7664            ]
7665        );
7666        assert_eq!(
7667            capabilities.filesystem.unwrap().scope,
7668            Some("./sandbox".to_string())
7669        );
7670    }
7671
7672    #[test]
7673    fn env_capabilities_parse_allowlist() {
7674        let manifest = RuntimeManifest::from_yaml_str(
7675            r#"
7676name: cap
7677version: 0.0.1
7678artifacts:
7679  - name: local/echo
7680    version: 1.2.3
7681capabilities:
7682  env:
7683    allow:
7684      - MY_APP_REGION
7685      - GITHUB_TOKEN
7686"#,
7687        )
7688        .unwrap();
7689
7690        let capabilities = manifest.capabilities.expect("capabilities should exist");
7691        assert_eq!(
7692            capabilities.env.unwrap().allow,
7693            vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
7694        );
7695    }
7696
7697    #[test]
7698    fn env_capabilities_absent_when_block_omitted() {
7699        let manifest = RuntimeManifest::from_yaml_str(
7700            r#"
7701name: cap
7702version: 0.0.1
7703artifacts:
7704  - name: local/echo
7705    version: 1.2.3
7706capabilities:
7707  network:
7708    allow:
7709      - https://api.anthropic.com
7710"#,
7711        )
7712        .unwrap();
7713
7714        assert!(manifest.capabilities.unwrap().env.is_none());
7715    }
7716
7717    /// Unlike `capabilities.shell.allow`, an empty `env.allow` is a legitimate no-op rather
7718    /// than a manifest error.
7719    #[test]
7720    fn env_capabilities_allow_empty_list_is_accepted() {
7721        let manifest = RuntimeManifest::from_yaml_str(
7722            r#"
7723name: cap
7724version: 0.0.1
7725artifacts:
7726  - name: local/echo
7727    version: 1.2.3
7728capabilities:
7729  env: {}
7730"#,
7731        )
7732        .unwrap();
7733
7734        assert_eq!(
7735            manifest.capabilities.unwrap().env,
7736            Some(EnvCapabilities { allow: vec![] })
7737        );
7738    }
7739
7740    #[test]
7741    fn missing_capabilities_block_is_backward_compatible() {
7742        let manifest = RuntimeManifest::from_yaml_str(
7743            r#"
7744name: cap
7745version: 0.0.1
7746artifacts:
7747  - name: local/echo
7748    version: 1.2.3
7749"#,
7750        )
7751        .unwrap();
7752
7753        assert!(manifest.capabilities.is_none());
7754    }
7755
7756    #[test]
7757    fn network_only_capabilities_parse() {
7758        let manifest = RuntimeManifest::from_yaml_str(
7759            r#"
7760name: cap
7761version: 0.0.1
7762artifacts:
7763  - name: local/echo
7764    version: 1.2.3
7765capabilities:
7766  network:
7767    allow:
7768      - https://allowed.example.com
7769"#,
7770        )
7771        .unwrap();
7772
7773        let capabilities = manifest.capabilities.unwrap();
7774        assert_eq!(
7775            capabilities.network.unwrap().allow,
7776            vec!["https://allowed.example.com".to_string()]
7777        );
7778        assert!(capabilities.filesystem.is_none());
7779    }
7780
7781    #[test]
7782    fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
7783        let manifest = RuntimeManifest::from_yaml_str(
7784            r#"
7785name: cap
7786version: 0.0.1
7787artifacts:
7788  - name: local/echo
7789    version: 1.2.3
7790capabilities:
7791  network: {}
7792"#,
7793        )
7794        .unwrap();
7795
7796        let capabilities = manifest.capabilities.unwrap();
7797        assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
7798    }
7799
7800    /// `unix_sockets` is optional and denies by default. A capsule that never heard of the key
7801    /// must not silently get `AF_UNIX` sockets — that is the whole `/var/run/docker.sock` escape.
7802    #[test]
7803    fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
7804        let manifest = RuntimeManifest::from_yaml_str(
7805            r#"
7806name: cap
7807version: 0.0.1
7808artifacts:
7809  - name: local/echo
7810    version: 1.2.3
7811capabilities:
7812  network:
7813    allow:
7814      - https://api.anthropic.com
7815"#,
7816        )
7817        .unwrap();
7818
7819        let network = manifest.capabilities.unwrap().network.unwrap();
7820        assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
7821        assert!(!network.unix_sockets);
7822    }
7823
7824    #[test]
7825    fn network_unix_sockets_parses_explicit_false() {
7826        let manifest = RuntimeManifest::from_yaml_str(
7827            r#"
7828name: cap
7829version: 0.0.1
7830artifacts:
7831  - name: local/echo
7832    version: 1.2.3
7833capabilities:
7834  network:
7835    unix_sockets: false
7836"#,
7837        )
7838        .unwrap();
7839
7840        assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
7841    }
7842
7843    #[test]
7844    fn network_unix_sockets_parses_explicit_true() {
7845        let manifest = RuntimeManifest::from_yaml_str(
7846            r#"
7847name: cap
7848version: 0.0.1
7849artifacts:
7850  - name: local/echo
7851    version: 1.2.3
7852capabilities:
7853  network:
7854    unix_sockets: true
7855"#,
7856        )
7857        .unwrap();
7858
7859        let network = manifest.capabilities.unwrap().network.unwrap();
7860        assert!(network.unix_sockets);
7861        // The opt-in is orthogonal to the IP allowlist — declaring it does not imply any host.
7862        assert_eq!(network.allow, Vec::<String>::new());
7863    }
7864
7865    /// `read_only` is normalised on exactly the terms `scope` is — trimmed, with an entry left
7866    /// empty by that trim dropped rather than carried as a rule covering the whole workdir — and
7867    /// path shape is deliberately not judged here.
7868    #[test]
7869    fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
7870        let manifest = RuntimeManifest::from_yaml_str(
7871            r#"
7872name: cap
7873version: 0.0.1
7874capabilities:
7875  filesystem:
7876    read_only:
7877      - tests
7878      - "  bench/fixtures  "
7879      - ""
7880      - "   "
7881"#,
7882        )
7883        .unwrap();
7884
7885        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7886        assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
7887    }
7888
7889    /// An absent key and an empty list are one declaration: nothing is protected.
7890    #[test]
7891    fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
7892        let manifest = RuntimeManifest::from_yaml_str(
7893            r#"
7894name: cap
7895version: 0.0.1
7896capabilities:
7897  filesystem:
7898    scope: workdir
7899"#,
7900        )
7901        .unwrap();
7902
7903        assert!(manifest
7904            .capabilities
7905            .unwrap()
7906            .filesystem
7907            .unwrap()
7908            .read_only
7909            .is_empty());
7910    }
7911
7912    /// Path shape is the runtime's refusal, not the parser's: an absolute or escaping entry parses
7913    /// here exactly as an absolute `scope` does, and fails at launch with `E-CAP-012`.
7914    #[test]
7915    fn filesystem_read_only_does_not_judge_path_shape() {
7916        let manifest = RuntimeManifest::from_yaml_str(
7917            r#"
7918name: cap
7919version: 0.0.1
7920capabilities:
7921  filesystem:
7922    read_only:
7923      - /etc
7924      - ../outside
7925"#,
7926        )
7927        .expect("shape is judged by the runtime, not the parser");
7928
7929        assert_eq!(
7930            manifest.capabilities.unwrap().filesystem.unwrap().read_only,
7931            vec!["/etc", "../outside"]
7932        );
7933    }
7934
7935    /// The default every pre-existing manifest gets: a `filesystem:` block that has never heard of
7936    /// `workdir_exec` must not silently keep the workdir's `Execute` right, because that right is
7937    /// exactly what makes `capabilities.shell.allow` bypassable from inside the workdir.
7938    #[test]
7939    fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
7940        let manifest = RuntimeManifest::from_yaml_str(
7941            r#"
7942name: cap
7943version: 0.0.1
7944artifacts:
7945  - name: local/echo
7946    version: 1.2.3
7947capabilities:
7948  filesystem:
7949    scope: workdir
7950"#,
7951        )
7952        .unwrap();
7953
7954        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7955        assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
7956        assert!(!filesystem.workdir_exec);
7957    }
7958
7959    /// The other absent case: no `filesystem:` block at all. There is nothing to default here —
7960    /// the whole sub-block stays `None` — but a capsule with no filesystem declaration must still
7961    /// read as "workdir exec denied" downstream, which `capability_policy_from_runtime_manifest`
7962    /// covers with its own test.
7963    #[test]
7964    fn filesystem_block_stays_absent_when_undeclared() {
7965        let manifest = RuntimeManifest::from_yaml_str(
7966            r#"
7967name: cap
7968version: 0.0.1
7969artifacts:
7970  - name: local/echo
7971    version: 1.2.3
7972capabilities:
7973  network:
7974    allow:
7975      - https://api.anthropic.com
7976"#,
7977        )
7978        .unwrap();
7979
7980        assert!(manifest.capabilities.unwrap().filesystem.is_none());
7981    }
7982
7983    #[test]
7984    fn filesystem_workdir_exec_parses_explicit_false() {
7985        let manifest = RuntimeManifest::from_yaml_str(
7986            r#"
7987name: cap
7988version: 0.0.1
7989artifacts:
7990  - name: local/echo
7991    version: 1.2.3
7992capabilities:
7993  filesystem:
7994    workdir_exec: false
7995"#,
7996        )
7997        .unwrap();
7998
7999        assert!(
8000            !manifest
8001                .capabilities
8002                .unwrap()
8003                .filesystem
8004                .unwrap()
8005                .workdir_exec
8006        );
8007    }
8008
8009    #[test]
8010    fn filesystem_workdir_exec_parses_explicit_true() {
8011        let manifest = RuntimeManifest::from_yaml_str(
8012            r#"
8013name: cap
8014version: 0.0.1
8015artifacts:
8016  - name: local/echo
8017    version: 1.2.3
8018capabilities:
8019  filesystem:
8020    workdir_exec: true
8021"#,
8022        )
8023        .unwrap();
8024
8025        let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
8026        assert!(filesystem.workdir_exec);
8027        // Orthogonal to `scope`, exactly as `unix_sockets` is orthogonal to `network.allow`:
8028        // declaring one says nothing about the other.
8029        assert_eq!(filesystem.scope, None);
8030    }
8031
8032    #[test]
8033    fn network_internal_port_parses() {
8034        let manifest = RuntimeManifest::from_yaml_str(
8035            r#"
8036name: worker
8037version: 0.1.0
8038artifacts: []
8039
8040network:
8041  internal_port: 58172
8042"#,
8043        )
8044        .unwrap();
8045
8046        assert_eq!(
8047            manifest.network.unwrap().internal_port,
8048            Some(58172),
8049            "network.internal_port should parse to Some(58172)"
8050        );
8051    }
8052
8053    #[test]
8054    fn network_internal_port_absent_when_omitted() {
8055        let manifest = RuntimeManifest::from_yaml_str(
8056            r#"
8057name: worker
8058version: 0.1.0
8059artifacts: []
8060"#,
8061        )
8062        .unwrap();
8063
8064        assert!(manifest.network.is_none());
8065    }
8066
8067    #[test]
8068    fn shell_capabilities_parse_with_allow_and_env_fields() {
8069        let manifest = RuntimeManifest::from_yaml_str(
8070            r#"
8071name: cap
8072version: 0.0.1
8073artifacts: []
8074capabilities:
8075  shell:
8076    allow:
8077      - bash
8078      - git
8079    strip_env:
8080      - "*_TOKEN"
8081      - SECRET_KEY
8082    baseline_env:
8083      - SSH_AUTH_SOCK
8084"#,
8085        )
8086        .unwrap();
8087
8088        let shell = manifest
8089            .capabilities
8090            .unwrap()
8091            .shell
8092            .expect("shell capabilities should be present");
8093        assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
8094        assert_eq!(
8095            shell.strip_env,
8096            Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
8097        );
8098        assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
8099    }
8100
8101    #[test]
8102    fn shell_capabilities_require_non_empty_allowlist() {
8103        let err = RuntimeManifest::from_yaml_str(
8104            r#"
8105name: cap
8106version: 0.0.1
8107artifacts: []
8108capabilities:
8109  shell:
8110    allow: []
8111"#,
8112        )
8113        .unwrap_err();
8114
8115        let msg = err.to_string();
8116        assert!(msg.contains("capabilities.shell.allow"));
8117        assert!(msg.contains("at least one"));
8118    }
8119
8120    fn shell_allow_error(entry: &str) -> String {
8121        let yaml = format!(
8122            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  shell:\n    allow: [bash, {}]\n",
8123            serde_json::to_string(entry).unwrap()
8124        );
8125        RuntimeManifest::from_yaml_str(&yaml)
8126            .expect_err(&format!("{entry:?} must be refused"))
8127            .to_string()
8128    }
8129
8130    #[test]
8131    fn shell_allow_refuses_an_entry_that_is_not_a_bare_name() {
8132        for (entry, reason) in [
8133            ("/tmp/uv-tool/bin/uv", "is a path"),
8134            ("bin/uv", "is a path"),
8135            ("..", "names a directory"),
8136            (".", "names a directory"),
8137            ("", "is empty"),
8138            ("a b", "contains whitespace"),
8139            ("uv\t", "contains whitespace"),
8140            ("uv\0", "contains a NUL byte"),
8141        ] {
8142            let message = shell_allow_error(entry);
8143            assert!(
8144                message.contains("'capabilities.shell.allow[1]'"),
8145                "{entry:?}: {message}"
8146            );
8147            assert!(
8148                message.contains(&format!(
8149                    "'{}' {reason}; an entry is a bare binary name",
8150                    entry.escape_debug()
8151                )),
8152                "{entry:?}: {message}"
8153            );
8154        }
8155    }
8156
8157    #[test]
8158    fn shell_allow_accepts_bare_names() {
8159        let manifest = RuntimeManifest::from_yaml_str(
8160            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  shell:\n    allow: [bash, python3.12, git-lfs, \"c++\"]\n",
8161        )
8162        .unwrap();
8163        assert_eq!(
8164            manifest.capabilities.unwrap().shell.unwrap().allow,
8165            ["bash", "python3.12", "git-lfs", "c++"]
8166        );
8167    }
8168
8169    #[test]
8170    fn interpreter_runtime_parses_accepted_shape() {
8171        let manifest = RuntimeManifest::from_yaml_str(
8172            r#"
8173name: cap
8174version: 0.0.1
8175artifacts: []
8176capabilities:
8177  shell:
8178    allow:
8179      - python3
8180    interpreter_runtime:
8181      - binary: python3
8182        dirs:
8183          - path: /usr/lib/python3.11
8184            list_dir: true
8185          - path: /usr/lib/python3.11/lib-dynload
8186            list_dir: false
8187"#,
8188        )
8189        .unwrap();
8190
8191        let shell = manifest.capabilities.unwrap().shell.unwrap();
8192        assert_eq!(
8193            shell.interpreter_runtime,
8194            vec![InterpreterRuntimeGrant {
8195                binary: "python3".to_string(),
8196                dirs: vec![
8197                    InterpreterRuntimeDir {
8198                        path: "/usr/lib/python3.11".to_string(),
8199                        list_dir: true,
8200                    },
8201                    InterpreterRuntimeDir {
8202                        path: "/usr/lib/python3.11/lib-dynload".to_string(),
8203                        list_dir: false,
8204                    },
8205                ],
8206            }]
8207        );
8208    }
8209
8210    #[test]
8211    fn interpreter_runtime_defaults_to_empty_when_absent() {
8212        let manifest = RuntimeManifest::from_yaml_str(
8213            r#"
8214name: cap
8215version: 0.0.1
8216artifacts: []
8217capabilities:
8218  shell:
8219    allow:
8220      - python3
8221"#,
8222        )
8223        .unwrap();
8224
8225        let shell = manifest.capabilities.unwrap().shell.unwrap();
8226        assert!(shell.interpreter_runtime.is_empty());
8227    }
8228
8229    /// Shared helper: parse a manifest whose sole `interpreter_runtime` grant is `grant_yaml`
8230    /// (indented to sit under `interpreter_runtime:`), returning the error message.
8231    fn interpreter_runtime_reject(grant_yaml: &str) -> String {
8232        let yaml = format!(
8233            r#"
8234name: cap
8235version: 0.0.1
8236artifacts: []
8237capabilities:
8238  shell:
8239    allow:
8240      - python3
8241    interpreter_runtime:
8242{grant_yaml}
8243"#,
8244        );
8245        RuntimeManifest::from_yaml_str(&yaml)
8246            .expect_err("grant should be rejected")
8247            .to_string()
8248    }
8249
8250    #[test]
8251    fn interpreter_runtime_rejects_binary_not_in_allow() {
8252        let msg = interpreter_runtime_reject(
8253            "      - binary: ruby\n        dirs:\n          - path: /usr/lib/ruby\n            list_dir: true\n",
8254        );
8255        assert!(
8256            msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
8257            "{msg}"
8258        );
8259        assert!(msg.contains("ruby"), "{msg}");
8260        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
8261    }
8262
8263    #[test]
8264    fn interpreter_runtime_rejects_relative_path() {
8265        let msg = interpreter_runtime_reject(
8266            "      - binary: python3\n        dirs:\n          - path: usr/lib/python3.11\n            list_dir: true\n",
8267        );
8268        assert!(
8269            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
8270            "{msg}"
8271        );
8272        assert!(msg.contains("absolute"), "{msg}");
8273    }
8274
8275    // ── capabilities.plan (capsule-wide) ─────────────────────────────────────
8276
8277    /// A capsule manifest whose capsule-wide capabilities carry `plan: <block>`.
8278    fn manifest_with_plan(block: &str) -> String {
8279        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n{block}")
8280    }
8281
8282    #[test]
8283    fn plan_submit_true_grants_the_capsule() {
8284        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n"))
8285            .expect("a plan block parses");
8286        assert_eq!(
8287            manifest.capabilities.unwrap().plan,
8288            Some(PlanCapabilities { submit: true })
8289        );
8290    }
8291
8292    /// `submit: false` and an absent `plan:` block both leave the capsule ungranted; the
8293    /// difference between them is only whether the operator wrote the denial down.
8294    #[test]
8295    fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
8296        let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: false\n"))
8297            .expect("an explicit denial parses");
8298        assert_eq!(
8299            explicit.capabilities.unwrap().plan,
8300            Some(PlanCapabilities { submit: false })
8301        );
8302
8303        let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
8304            .expect("no capabilities block parses");
8305        assert!(absent.capabilities.is_none());
8306    }
8307
8308    /// The capability is never inferred: a `plan:` block that omits `submit:` is a parse error
8309    /// naming the key, exactly as `task_io:` without `read:` is.
8310    #[test]
8311    fn plan_without_submit_is_rejected() {
8312        let err = RuntimeManifest::from_yaml_str(&manifest_with_plan("    {}\n"))
8313            .expect_err("an empty plan block is refused");
8314        let msg = err.to_string();
8315        assert!(msg.contains("capabilities.plan.submit"), "{msg}");
8316        assert!(msg.contains("never"), "{msg}");
8317    }
8318
8319    /// An unknown key inside the block is reported by path, like every other capability block.
8320    #[test]
8321    fn an_unknown_plan_key_is_reported_by_path() {
8322        let manifest =
8323            RuntimeManifest::from_yaml_str(&manifest_with_plan("    submit: true\n    depth: 3\n"))
8324                .expect("an unknown key is a warning, not a parse error");
8325        assert!(
8326            manifest
8327                .unknown_keys
8328                .iter()
8329                .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
8330            "{:?}",
8331            manifest.unknown_keys
8332        );
8333    }
8334
8335    // ── capabilities.install (capsule-wide) ──────────────────────────────────
8336
8337    /// A capsule manifest whose capsule-wide capabilities carry `install: <block>`.
8338    fn manifest_with_install(block: &str) -> String {
8339        format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  install:\n{block}")
8340    }
8341
8342    #[test]
8343    fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
8344        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8345            "    skill: [ana/code-review, \"ana/style-*\"]\n    tool: [\"local/b-*\", local/a, \"murmur/*\", \"*\"]\n",
8346        ))
8347        .expect("an install block parses");
8348        assert_eq!(
8349            manifest.capabilities.unwrap().install,
8350            Some(InstallCapabilities {
8351                skill: vec!["ana/code-review".into(), "ana/style-*".into()],
8352                tool: vec![
8353                    "local/b-*".into(),
8354                    "local/a".into(),
8355                    "murmur/*".into(),
8356                    "*".into()
8357                ],
8358                from: InstallFrom::Stores,
8359            })
8360        );
8361    }
8362
8363    /// Either list alone is a grant; the other defaults to empty, which admits nothing of its
8364    /// kind.
8365    #[test]
8366    fn capabilities_install_accepts_either_list_alone() {
8367        let skill_only =
8368            RuntimeManifest::from_yaml_str(&manifest_with_install("    skill: [local/a]\n"))
8369                .expect("skill alone parses");
8370        assert_eq!(
8371            skill_only.capabilities.unwrap().install,
8372            Some(InstallCapabilities {
8373                skill: vec!["local/a".into()],
8374                tool: vec![],
8375                from: InstallFrom::Stores,
8376            })
8377        );
8378        let tool_only =
8379            RuntimeManifest::from_yaml_str(&manifest_with_install("    tool: [\"local/b-*\"]\n"))
8380                .expect("tool alone parses");
8381        assert_eq!(
8382            tool_only.capabilities.unwrap().install,
8383            Some(InstallCapabilities {
8384                skill: vec![],
8385                tool: vec!["local/b-*".into()],
8386                from: InstallFrom::Stores,
8387            })
8388        );
8389    }
8390
8391    #[test]
8392    fn capabilities_install_absent_is_none() {
8393        let manifest = RuntimeManifest::from_yaml_str(
8394            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  plan:\n    submit: true\n",
8395        )
8396        .expect("a capabilities block without install parses");
8397        assert_eq!(manifest.capabilities.unwrap().install, None);
8398    }
8399
8400    fn install_from(list: &str) -> Result<InstallFrom, RuntimeManifestError> {
8401        RuntimeManifest::from_yaml_str(&manifest_with_install(&format!(
8402            "    tool: [\"murmur/tool-*\"]\n    from: {list}\n"
8403        )))
8404        .map(|manifest| manifest.capabilities.unwrap().install.unwrap().from)
8405    }
8406
8407    #[test]
8408    fn install_from_absent_is_stores() {
8409        let manifest =
8410            RuntimeManifest::from_yaml_str(&manifest_with_install("    tool: [local/a]\n"))
8411                .unwrap();
8412        assert_eq!(
8413            manifest.capabilities.unwrap().install.unwrap().from,
8414            InstallFrom::Stores
8415        );
8416    }
8417
8418    #[test]
8419    fn install_from_accepts_stores_alone_and_with_sources_in_either_order() {
8420        assert_eq!(install_from("[stores]").unwrap(), InstallFrom::Stores);
8421        assert_eq!(
8422            install_from("[stores, sources]").unwrap(),
8423            InstallFrom::StoresAndSources
8424        );
8425        assert_eq!(
8426            install_from("[sources, stores]").unwrap(),
8427            InstallFrom::StoresAndSources
8428        );
8429        assert_eq!(InstallFrom::StoresAndSources.as_list(), "stores, sources");
8430        assert!(InstallFrom::StoresAndSources.sources());
8431        assert!(!InstallFrom::Stores.sources());
8432    }
8433
8434    #[test]
8435    fn install_from_refuses_empty_sourceless_duplicate_and_unknown_lists() {
8436        for (list, why) in [
8437            ("[]", "is empty"),
8438            ("[sources]", "does not name stores"),
8439            ("[stores, stores]", "names \"stores\" twice"),
8440            ("[github]", "names \"github\", which is not a place"),
8441        ] {
8442            match install_from(list) {
8443                Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8444                    assert_eq!(field, "capabilities.install.from", "{list}");
8445                    assert!(message.contains(why), "{list}: {message}");
8446                    assert!(
8447                        message.contains("expected [stores] or [stores, sources]"),
8448                        "{list}: {message}"
8449                    );
8450                }
8451                other => panic!("{list}: expected InvalidCapabilities, got {other:?}"),
8452            }
8453        }
8454    }
8455
8456    #[test]
8457    fn install_from_is_no_unknown_key() {
8458        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8459            "    tool: [local/a]\n    from: [stores]\n",
8460        ))
8461        .unwrap();
8462        assert!(
8463            manifest
8464                .unknown_keys
8465                .iter()
8466                .all(|key| key.block_path != "capabilities.install"),
8467            "{:?}",
8468            manifest.unknown_keys
8469        );
8470    }
8471
8472    #[test]
8473    fn install_from_capabilities_install_is_refused_under_transport_process() {
8474        let yaml = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  install:\n    \
8475                    tool: [\"murmur/tool-*\"]\ninference:\n  transport: process\n  \
8476                    command: claude\n  model: claude-haiku-4-5-20251001\n  driver:\n    \
8477                    artifact: murmur/driver-claude-code\n";
8478        match RuntimeManifest::from_yaml_str(yaml) {
8479            Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8480                assert_eq!(field, "capabilities.install");
8481                assert_eq!(
8482                    message,
8483                    "is not valid with transport: process; the harness owns its tool list, so \
8484                     a pulled artifact could never reach it"
8485                );
8486            }
8487            other => panic!("expected InvalidCapabilities, got {other:?}"),
8488        }
8489    }
8490
8491    /// A block that names nothing is refused rather than read as "grant nothing": omitting the
8492    /// block is how a capsule grants nothing.
8493    #[test]
8494    fn capabilities_install_that_names_nothing_is_refused() {
8495        for block in [
8496            "    {}\n",
8497            "    skill: []\n",
8498            "    skill: []\n    tool: []\n",
8499            "    driver: [x]\n",
8500        ] {
8501            let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
8502                .expect_err("an install block naming nothing is refused");
8503            match &err {
8504                RuntimeManifestError::InvalidCapabilities { field, message } => {
8505                    assert_eq!(field, "capabilities.install", "{block:?}");
8506                    assert!(message.contains("at least one"), "{message}");
8507                    assert!(message.contains("omit the block"), "{message}");
8508                }
8509                other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
8510            }
8511        }
8512    }
8513
8514    #[test]
8515    fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
8516        for entry in [
8517            "a*b", "*x", "**", "Foo", "-a", "a/b/c", " ", "*/x", "a/*x", "a/x*y", "A/x",
8518        ] {
8519            for key in ["skill", "tool"] {
8520                let block = format!("    {key}: [{entry:?}]\n");
8521                let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8522                    .expect_err("a bad entry is refused");
8523                match &err {
8524                    RuntimeManifestError::InvalidArtifactName {
8525                        field,
8526                        message,
8527                        hint,
8528                    } => {
8529                        assert_eq!(field, &format!("capabilities.install.{key}[0]"));
8530                        assert!(message.contains(&format!("{entry:?}")), "{message}");
8531                        assert!(
8532                            message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
8533                            "{message}"
8534                        );
8535                        assert_eq!(hint, &None, "{entry:?}");
8536                    }
8537                    other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8538                }
8539            }
8540        }
8541    }
8542
8543    /// An entry with no scope is refused at its index with the hint naming its scoped forms.
8544    #[test]
8545    fn capabilities_install_refuses_an_unscoped_entry_with_the_scoped_hint() {
8546        for (entry, scoped) in [
8547            ("tool-*", "local/tool-*"),
8548            ("mur*", "local/mur*"),
8549            ("tool-x", "local/tool-x"),
8550            ("murmur-tool-editor", "murmur/tool-editor"),
8551        ] {
8552            let block = format!("    tool: [\"local/ok\", {entry:?}]\n");
8553            let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8554                .expect_err("an unscoped entry is refused");
8555            match &err {
8556                RuntimeManifestError::InvalidArtifactName {
8557                    field,
8558                    message,
8559                    hint,
8560                } => {
8561                    assert_eq!(field, "capabilities.install.tool[1]");
8562                    assert_eq!(
8563                        message,
8564                        &format!(
8565                            "entry {entry:?} is invalid ('{entry}' names no scope); expected {}",
8566                            crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
8567                        )
8568                    );
8569                    let hint = hint.as_deref().expect("an unscoped entry carries a hint");
8570                    assert!(hint.contains(scoped), "{entry:?}: {hint}");
8571                }
8572                other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8573            }
8574            assert!(
8575                err.to_string().contains("'capabilities.install.tool[1]'"),
8576                "{err}"
8577            );
8578        }
8579    }
8580
8581    /// The grant is held by the session, so a per-artifact block is refused on every role.
8582    #[test]
8583    fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
8584        for runtime in ["tool", "hook", "driver", "skill"] {
8585            let yaml = format!(
8586                "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/a\n    version: 0.1.0\n    \
8587                 runtime: {runtime}\n    capabilities:\n      install:\n        skill: [x]\n"
8588            );
8589            let err = RuntimeManifest::from_yaml_str(&yaml)
8590                .expect_err("a per-artifact install block is refused");
8591            match &err {
8592                RuntimeManifestError::InvalidArtifact { index, message } => {
8593                    assert_eq!(*index, 0);
8594                    assert!(message.contains("capabilities.install"), "{message}");
8595                    assert!(message.contains("capsule-wide"), "{message}");
8596                }
8597                other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
8598            }
8599        }
8600    }
8601
8602    /// An unknown key inside the block is reported by path and grants nothing.
8603    #[test]
8604    fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
8605        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8606            "    skill: [local/a]\n    driver: [x]\n",
8607        ))
8608        .expect("an unknown key beside a real entry is a warning, not a parse error");
8609        assert!(
8610            manifest
8611                .unknown_keys
8612                .iter()
8613                .any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
8614            "{:?}",
8615            manifest.unknown_keys
8616        );
8617        assert_eq!(
8618            manifest.capabilities.unwrap().install,
8619            Some(InstallCapabilities {
8620                skill: vec!["local/a".into()],
8621                tool: vec![],
8622                from: InstallFrom::Stores,
8623            })
8624        );
8625    }
8626
8627    // ── capabilities.task_io (per-hook, honored only on runtime: hook) ────────
8628
8629    /// A capsule manifest with one artifact entry carrying `capabilities: task_io: <block>`.
8630    fn manifest_with_task_io(runtime: &str, block: &str) -> String {
8631        format!(
8632            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/gate\n    version: 1.2.3\n    \
8633             runtime: {runtime}\n    capabilities:\n      task_io:\n{block}"
8634        )
8635    }
8636
8637    #[test]
8638    fn task_io_read_true_grants_the_hook() {
8639        let manifest =
8640            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: true\n"))
8641                .expect("task_io on a hook entry parses");
8642        assert_eq!(
8643            manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
8644            Some(TaskIoCapabilities { read: true })
8645        );
8646    }
8647
8648    /// `read: false` and an absent `task_io:` block both leave the hook ungranted; the
8649    /// difference between them is only whether the operator wrote the denial down.
8650    #[test]
8651    fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
8652        let explicit =
8653            RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        read: false\n"))
8654                .expect("an explicit denial parses");
8655        assert_eq!(
8656            explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
8657            Some(TaskIoCapabilities { read: false })
8658        );
8659
8660        let absent = RuntimeManifest::from_yaml_str(
8661            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/gate\n    version: 1.2.3\n    \
8662             runtime: hook\n",
8663        )
8664        .expect("no capabilities block parses");
8665        assert!(absent.artifacts[0].capabilities.is_none());
8666    }
8667
8668    /// The capability is never inferred: a `task_io:` block that omits `read:` is a parse
8669    /// error naming the key, following `interpreter_runtime[].dirs[].list_dir`.
8670    #[test]
8671    fn task_io_without_read_is_rejected() {
8672        let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", "        {}\n"))
8673            .unwrap_err();
8674        let msg = err.to_string();
8675        assert!(
8676            msg.contains("capabilities.task_io.read"),
8677            "error was: {msg}"
8678        );
8679        assert!(msg.contains("explicitly"), "error was: {msg}");
8680    }
8681
8682    /// Nothing outside a hook can be handed the import, so declaring the key there is a parse
8683    /// error rather than a silently inert grant.
8684    #[test]
8685    fn task_io_outside_a_hook_entry_is_rejected() {
8686        for runtime in ["tool", "driver", "skill"] {
8687            let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
8688                runtime,
8689                "        read: true\n",
8690            ))
8691            .unwrap_err();
8692            let msg = err.to_string();
8693            assert!(
8694                msg.contains("capabilities") && msg.contains("runtime: hook"),
8695                "{runtime} entry: error was: {msg}"
8696            );
8697        }
8698    }
8699
8700    // ── capabilities.filesystem.records (per-hook, honored only on runtime: hook) ──
8701
8702    /// A capsule manifest with one artifact entry carrying `capabilities: filesystem: <block>`.
8703    fn manifest_with_filesystem(runtime: &str, block: &str) -> String {
8704        format!(
8705            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/thing\n    version: 0.1.0\n    \
8706             runtime: {runtime}\n    capabilities:\n      filesystem:\n{block}"
8707        )
8708    }
8709
8710    /// `records: true` on a hook entry grants it; absent, it is `false`.
8711    #[test]
8712    fn filesystem_records_on_a_hook_entry_parses() {
8713        let manifest = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8714            "hook",
8715            "        records: true\n",
8716        ))
8717        .expect("records on a hook entry parses");
8718        let filesystem = manifest.artifacts[0]
8719            .capabilities
8720            .as_ref()
8721            .unwrap()
8722            .filesystem
8723            .as_ref()
8724            .unwrap();
8725        assert!(filesystem.records);
8726        assert_eq!(filesystem.scope, None);
8727
8728        let scoped =
8729            RuntimeManifest::from_yaml_str(&manifest_with_filesystem("hook", "        scope: .\n"))
8730                .unwrap();
8731        let filesystem = scoped.artifacts[0]
8732            .capabilities
8733            .as_ref()
8734            .unwrap()
8735            .filesystem
8736            .as_ref()
8737            .unwrap();
8738        assert!(!filesystem.records);
8739        assert!(scoped.unknown_keys.is_empty(), "{:?}", scoped.unknown_keys);
8740    }
8741
8742    /// Beside a `scope`, on any other role and in the capsule-wide block, `records: true` is
8743    /// refused, naming the field.
8744    #[test]
8745    fn filesystem_records_is_refused_beside_a_scope_off_a_hook_and_capsule_wide() {
8746        let beside = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8747            "hook",
8748            "        records: true\n        scope: .\n",
8749        ))
8750        .unwrap_err()
8751        .to_string();
8752        assert!(
8753            beside.contains("capabilities.filesystem.records"),
8754            "{beside}"
8755        );
8756        assert!(beside.contains("capabilities.filesystem.scope"), "{beside}");
8757        for runtime in ["tool", "driver"] {
8758            let off_hook = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8759                runtime,
8760                "        records: true\n",
8761            ))
8762            .unwrap_err()
8763            .to_string();
8764            assert!(
8765                off_hook.contains("capabilities.filesystem.records"),
8766                "{off_hook}"
8767            );
8768            assert!(off_hook.contains("runtime: hook"), "{off_hook}");
8769        }
8770        let capsule_wide = RuntimeManifest::from_yaml_str(
8771            "name: cap\nversion: 0.0.1\ncapabilities:\n  filesystem:\n    records: true\n",
8772        )
8773        .unwrap_err()
8774        .to_string();
8775        assert!(
8776            capsule_wide.contains("capabilities.filesystem.records"),
8777            "{capsule_wide}"
8778        );
8779    }
8780
8781    /// The capsule-wide block reaches capsule, tool and driver components, none of which can
8782    /// receive the import — so it is rejected there too, naming the key.
8783    #[test]
8784    fn task_io_in_the_capsule_wide_block_is_rejected() {
8785        let err = RuntimeManifest::from_yaml_str(
8786            "name: cap\nversion: 0.0.1\ncapabilities:\n  task_io:\n    read: true\n",
8787        )
8788        .unwrap_err();
8789        let msg = err.to_string();
8790        assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
8791        assert!(msg.contains("runtime: hook"), "error was: {msg}");
8792    }
8793
8794    // ── capabilities.conversation (per-hook, honored only on runtime: hook) ───
8795
8796    /// A capsule manifest with one artifact entry carrying `capabilities: conversation: <block>`.
8797    fn manifest_with_conversation(runtime: &str, block: &str) -> String {
8798        format!(
8799            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/memory\n    version: 1.2.3\n    \
8800             runtime: {runtime}\n    capabilities:\n      conversation:\n{block}"
8801        )
8802    }
8803
8804    #[test]
8805    fn conversation_read_lowers_onto_the_hook_entry() {
8806        for (declared, expected) in [("true", true), ("false", false)] {
8807            let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8808                "hook",
8809                &format!("        read: {declared}\n"),
8810            ))
8811            .expect("conversation on a hook entry parses");
8812            assert_eq!(
8813                manifest.artifacts[0]
8814                    .capabilities
8815                    .as_ref()
8816                    .unwrap()
8817                    .conversation,
8818                Some(ConversationCapabilities { read: expected })
8819            );
8820        }
8821    }
8822
8823    /// The capability is never inferred: a block that omits `read:` is a parse error naming the
8824    /// key, exactly as `capabilities.task_io.read` is.
8825    #[test]
8826    fn conversation_without_read_is_rejected() {
8827        let err =
8828            RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", "        {}\n"))
8829                .unwrap_err();
8830        let msg = err.to_string();
8831        assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
8832        assert!(msg.contains("explicitly"), "was: {msg}");
8833    }
8834
8835    /// Only a hook's world can import the interface, so declaring the grant on any other role is
8836    /// a parse error rather than a silently inert block.
8837    #[test]
8838    fn conversation_outside_a_hook_entry_is_rejected() {
8839        for runtime in ["tool", "driver", "skill"] {
8840            let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8841                runtime,
8842                "        read: true\n",
8843            ))
8844            .unwrap_err();
8845            let msg = err.to_string();
8846            assert!(
8847                msg.contains("capabilities") && msg.contains("runtime: hook"),
8848                "{runtime} entry: error was: {msg}"
8849            );
8850        }
8851    }
8852
8853    /// Unlike `task_io`, the capsule-wide block parses: it is inert, and the runtime says so once
8854    /// as `W-SEC-016` rather than refusing a manifest an operator can fix at leisure.
8855    #[test]
8856    fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
8857        let manifest = RuntimeManifest::from_yaml_str(
8858            "name: cap\nversion: 0.0.1\ncapabilities:\n  conversation:\n    read: true\n",
8859        )
8860        .expect("a capsule-wide block is accepted");
8861        assert_eq!(
8862            manifest.capabilities.unwrap().conversation,
8863            Some(ConversationCapabilities { read: true })
8864        );
8865    }
8866
8867    // ── context.record / context.record_store ────────────────────────────────
8868
8869    /// The record is on unless the manifest says otherwise, and `record_store` defaults to
8870    /// nothing — the runtime substitutes the capsule name.
8871    #[test]
8872    fn a_context_block_records_by_default() {
8873        let context = context_of("context:\n  max_tokens: 1000\n").unwrap();
8874        assert!(context.record);
8875        assert_eq!(context.record_store, None);
8876    }
8877
8878    #[test]
8879    fn record_off_and_on_are_the_two_accepted_values() {
8880        assert!(!context_of("context:\n  record: off\n").unwrap().record);
8881        assert!(context_of("context:\n  record: on\n").unwrap().record);
8882
8883        let err = RuntimeManifest::from_yaml_str(
8884            "name: cap\nversion: 0.0.1\ncontext:\n  record: sometimes\n",
8885        )
8886        .unwrap_err();
8887        let msg = err.to_string();
8888        assert!(msg.contains("context.record"), "was: {msg}");
8889        assert!(msg.contains("sometimes"), "was: {msg}");
8890    }
8891
8892    /// `record_store` is a name the runtime turns into a directory, so it is kept verbatim apart
8893    /// from surrounding whitespace; its *shape* is refused at launch, where the path is built.
8894    #[test]
8895    fn record_store_is_kept_verbatim_and_trimmed() {
8896        assert_eq!(
8897            context_of("context:\n  record_store: '  shey  '\n")
8898                .unwrap()
8899                .record_store,
8900            Some("shey".to_string())
8901        );
8902        assert_eq!(
8903            context_of("context:\n  record: off\n  record_store: shey\n")
8904                .unwrap()
8905                .record_store,
8906            Some("shey".to_string()),
8907            "a store declared beside record: off is kept and simply never used"
8908        );
8909    }
8910
8911    #[test]
8912    fn interpreter_runtime_rejects_missing_list_dir() {
8913        let msg = interpreter_runtime_reject(
8914            "      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.11\n",
8915        );
8916        assert!(
8917            msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
8918            "{msg}"
8919        );
8920        assert!(msg.contains("explicitly"), "{msg}");
8921    }
8922
8923    #[test]
8924    fn interpreter_runtime_rejects_empty_dirs() {
8925        let msg = interpreter_runtime_reject("      - binary: python3\n        dirs: []\n");
8926        assert!(
8927            msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
8928            "{msg}"
8929        );
8930        assert!(msg.contains("at least one"), "{msg}");
8931    }
8932
8933    #[test]
8934    fn staged_runtime_parses_accepted_shape() {
8935        let manifest = RuntimeManifest::from_yaml_str(
8936            r#"
8937name: cap
8938version: 0.0.1
8939artifacts: []
8940capabilities:
8941  containment: sealed
8942  shell:
8943    allow:
8944      - python3
8945      - bash
8946    staged_runtime:
8947      - binary: python3
8948        source_path: /opt/testbed/conda/envs/django__django
8949        pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
8950"#,
8951        )
8952        .unwrap();
8953
8954        let shell = manifest.capabilities.unwrap().shell.unwrap();
8955        assert_eq!(
8956            shell.staged_runtime,
8957            vec![StagedRuntimeGrant {
8958                binary: "python3".to_string(),
8959                source_path: "/opt/testbed/conda/envs/django__django".to_string(),
8960                pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
8961            }]
8962        );
8963        // The two mechanisms are alternatives, so an accepted `staged_runtime` grant leaves the
8964        // interpreter_runtime list untouched rather than populating it as a side effect.
8965        assert!(shell.interpreter_runtime.is_empty());
8966    }
8967
8968    #[test]
8969    fn staged_runtime_defaults_to_empty_when_absent() {
8970        let manifest = RuntimeManifest::from_yaml_str(
8971            r#"
8972name: cap
8973version: 0.0.1
8974artifacts: []
8975capabilities:
8976  shell:
8977    allow:
8978      - python3
8979"#,
8980        )
8981        .unwrap();
8982
8983        let shell = manifest.capabilities.unwrap().shell.unwrap();
8984        assert!(shell.staged_runtime.is_empty());
8985    }
8986
8987    /// Shared helper: parse a manifest whose sole `staged_runtime` grant is `grant_yaml` (indented
8988    /// to sit under `staged_runtime:`), returning the error message. `extra` is spliced into the
8989    /// same `shell` block, which is how the mutual-exclusion case adds an `interpreter_runtime`.
8990    fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
8991        let yaml = format!(
8992            r#"
8993name: cap
8994version: 0.0.1
8995artifacts: []
8996capabilities:
8997  shell:
8998    allow:
8999      - python3
9000{extra}    staged_runtime:
9001{grant_yaml}
9002"#,
9003        );
9004        RuntimeManifest::from_yaml_str(&yaml)
9005            .expect_err("grant should be rejected")
9006            .to_string()
9007    }
9008
9009    #[test]
9010    fn staged_runtime_rejects_binary_not_in_allow() {
9011        let msg = staged_runtime_reject(
9012            "      - binary: ruby\n        source_path: /opt/ruby\n        pin: ruby-3.2.2\n",
9013            "",
9014        );
9015        assert!(
9016            msg.contains("capabilities.shell.staged_runtime[0].binary"),
9017            "{msg}"
9018        );
9019        assert!(msg.contains("ruby"), "{msg}");
9020        assert!(msg.contains("capabilities.shell.allow"), "{msg}");
9021    }
9022
9023    #[test]
9024    fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
9025        let msg = staged_runtime_reject(
9026            "      - binary: python3\n        source_path: /opt/py\n        pin: cpython-3.9.19\n",
9027            "    interpreter_runtime:\n      - binary: python3\n        dirs:\n          - path: /usr/lib/python3.9\n            list_dir: true\n",
9028        );
9029        assert!(
9030            msg.contains("capabilities.shell.staged_runtime[0].binary"),
9031            "{msg}"
9032        );
9033        assert!(msg.contains("python3"), "{msg}");
9034        assert!(msg.contains("interpreter_runtime"), "{msg}");
9035        assert!(msg.contains("mutually exclusive"), "{msg}");
9036    }
9037
9038    #[test]
9039    fn staged_runtime_rejects_relative_source_path() {
9040        let msg = staged_runtime_reject(
9041            "      - binary: python3\n        source_path: opt/testbed/conda\n        pin: cpython-3.9.19\n",
9042            "",
9043        );
9044        assert!(
9045            msg.contains("capabilities.shell.staged_runtime[0].source_path"),
9046            "{msg}"
9047        );
9048        assert!(msg.contains("absolute"), "{msg}");
9049    }
9050
9051    #[test]
9052    fn staged_runtime_rejects_missing_or_empty_pin() {
9053        // Omitted entirely, and present-but-blank: both are "unpinned", and the field exists
9054        // precisely so that state is unrepresentable.
9055        for grant in [
9056            "      - binary: python3\n        source_path: /opt/testbed/conda\n",
9057            "      - binary: python3\n        source_path: /opt/testbed/conda\n        pin: \"   \"\n",
9058        ] {
9059            let msg = staged_runtime_reject(grant, "");
9060            assert!(
9061                msg.contains("capabilities.shell.staged_runtime[0].pin"),
9062                "{msg}"
9063            );
9064            assert!(msg.contains("never inferred"), "{msg}");
9065        }
9066    }
9067
9068    #[test]
9069    fn parses_inference_block() {
9070        let manifest = RuntimeManifest::from_yaml_str(
9071            r#"
9072name: cap
9073version: 0.0.1
9074artifacts:
9075  - name: murmur/driver-anthropic
9076    version: 0.1.0
9077    runtime: driver
9078    gateway:
9079      endpoint: http://127.0.0.1:8080
9080      api_key: literal-key
9081inference:
9082  transport: http
9083  model: test-model
9084  driver:
9085    artifact: murmur/driver-anthropic
9086    config:
9087      temperature: 0.2
9088      headers:
9089        x-test: true
9090"#,
9091        )
9092        .unwrap();
9093
9094        assert_eq!(
9095            manifest.artifacts[0].gateway,
9096            Some(ArtifactGateway {
9097                endpoint: "http://127.0.0.1:8080".to_string(),
9098                api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
9099                keyless: false,
9100            })
9101        );
9102        let inference = manifest.inference.expect("inference should exist");
9103        assert_eq!(inference.transport, "http");
9104        assert_eq!(inference.model, "test-model");
9105        let driver = inference.driver.as_ref().expect("driver should be present");
9106        assert_eq!(driver.artifact, "murmur/driver-anthropic");
9107        assert_eq!(
9108            driver.config,
9109            Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
9110        );
9111        assert!(inference.system_prompt.is_none());
9112        assert!(inference.system_prompt_file.is_none());
9113    }
9114
9115    /// A capsule whose one tool entry declares `gateway:` with `block` as its body, indented
9116    /// under the key.
9117    fn tool_gateway_manifest(block: &str) -> String {
9118        format!(
9119            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/web-search\n    version: 0.1.0\n    runtime: tool\n    gateway:{block}\n"
9120        )
9121    }
9122
9123    fn gateway_endpoint_error(endpoint: &str) -> String {
9124        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
9125            "\n      endpoint: \"{endpoint}\""
9126        )))
9127        .unwrap_err()
9128        .to_string()
9129    }
9130
9131    #[test]
9132    fn gateway_endpoint_https_is_accepted() {
9133        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9134            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
9135        ))
9136        .unwrap();
9137        assert_eq!(
9138            manifest.artifacts[0].gateway,
9139            Some(ArtifactGateway {
9140                endpoint: "https://api.tavily.com".to_string(),
9141                api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
9142                keyless: false,
9143            })
9144        );
9145    }
9146
9147    #[test]
9148    fn gateway_endpoint_http_localhost_is_accepted() {
9149        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9150            "\n      endpoint: http://localhost:11434\n      keyless: true",
9151        ))
9152        .unwrap();
9153        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
9154        assert_eq!(gateway.endpoint, "http://localhost:11434");
9155        assert_eq!(gateway.api_key, None);
9156        assert!(gateway.keyless);
9157    }
9158
9159    #[test]
9160    fn gateway_endpoint_http_loopback_ip_is_accepted() {
9161        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9162            "\n      endpoint: http://127.0.0.1:8080\n      api_key: ${LOCAL_KEY}",
9163        ))
9164        .unwrap();
9165        let gateway = manifest.artifacts[0].gateway.clone().unwrap();
9166        assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
9167        assert_eq!(
9168            gateway.api_key,
9169            Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
9170        );
9171    }
9172
9173    #[test]
9174    fn gateway_endpoint_without_scheme_is_refused() {
9175        let msg = gateway_endpoint_error("api.tavily.com");
9176        assert!(msg.contains("gateway.endpoint"), "{msg}");
9177        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9178        assert!(msg.contains("does not parse as a URL"), "{msg}");
9179    }
9180
9181    #[test]
9182    fn gateway_endpoint_ftp_scheme_is_refused() {
9183        let msg = gateway_endpoint_error("ftp://example.com");
9184        assert!(msg.contains("gateway.endpoint"), "{msg}");
9185        assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
9186    }
9187
9188    #[test]
9189    fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
9190        let msg = gateway_endpoint_error("http://api.attacker.example.com");
9191        assert!(msg.contains("gateway.endpoint"), "{msg}");
9192        assert!(msg.contains("api.attacker.example.com"), "{msg}");
9193        assert!(msg.contains("non-loopback"), "{msg}");
9194    }
9195
9196    #[test]
9197    fn gateway_endpoint_with_a_query_is_refused() {
9198        let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
9199        assert!(msg.contains("gateway.endpoint"), "{msg}");
9200        assert!(msg.contains("carries a query"), "{msg}");
9201    }
9202
9203    #[test]
9204    fn gateway_endpoint_with_a_fragment_is_refused() {
9205        let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
9206        assert!(msg.contains("gateway.endpoint"), "{msg}");
9207        assert!(msg.contains("carries a fragment"), "{msg}");
9208    }
9209
9210    #[test]
9211    fn gateway_endpoint_with_userinfo_is_refused() {
9212        let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
9213        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9214        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
9215        assert!(msg.contains("host 'api.example.com'"), "{msg}");
9216        assert!(msg.contains("gateway.api_key"), "{msg}");
9217        assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
9218        assert!(
9219            !msg.contains("sk-in-url"),
9220            "a password in the URL is never echoed: {msg}"
9221        );
9222    }
9223
9224    #[test]
9225    fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
9226        let msg = gateway_endpoint_error("https://user:sk-in-url@");
9227        assert!(msg.contains("does not parse as a URL"), "{msg}");
9228        assert!(
9229            !msg.contains("sk-in-url"),
9230            "a password in the URL is never echoed: {msg}"
9231        );
9232    }
9233
9234    #[test]
9235    fn gateway_endpoint_with_a_masquerading_host_is_refused() {
9236        let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
9237        assert!(msg.contains("carries userinfo before '@'"), "{msg}");
9238        assert!(
9239            msg.contains("addresses host '127.0.0.1'"),
9240            "the message names the host the URL really addresses: {msg}"
9241        );
9242    }
9243
9244    #[test]
9245    fn gateway_endpoint_with_interpolation_is_refused() {
9246        let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
9247        assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9248        assert!(msg.contains("contains '${'"), "{msg}");
9249        assert!(msg.contains("not interpolated"), "{msg}");
9250    }
9251
9252    #[test]
9253    fn gateway_endpoint_http_unspecified_address_is_refused() {
9254        let msg = gateway_endpoint_error("http://0.0.0.0:8080");
9255        assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
9256    }
9257
9258    #[test]
9259    fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
9260        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9261            "\n      endpoint: http://[::1]:8080\n      api_key: ${LOCAL_KEY}",
9262        ))
9263        .unwrap();
9264        assert_eq!(
9265            manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
9266            "http://[::1]:8080"
9267        );
9268    }
9269
9270    /// The error a tool entry whose `gateway:` body is `block` draws, parsed with secrets resolved.
9271    fn gateway_credential_error(block: &str) -> RuntimeManifestError {
9272        RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
9273    }
9274
9275    fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
9276        match &error {
9277            RuntimeManifestError::GatewayWithoutCredential {
9278                index,
9279                name,
9280                endpoint: refused,
9281            } => {
9282                assert_eq!(*index, 0);
9283                assert_eq!(name, "local/web-search");
9284                assert_eq!(refused, endpoint);
9285            }
9286            other => panic!("expected GatewayWithoutCredential, got {other:?}"),
9287        }
9288        let msg = error.to_string();
9289        assert!(
9290            msg.contains(&format!(
9291                "artifact 'local/web-search' declares gateway.endpoint '{endpoint}' but binds no \
9292                 credential"
9293            )),
9294            "{msg}"
9295        );
9296    }
9297
9298    #[test]
9299    fn gateway_credential_absent_is_refused() {
9300        assert_without_credential(
9301            gateway_credential_error("\n      endpoint: https://api.tavily.com"),
9302            "https://api.tavily.com",
9303        );
9304    }
9305
9306    #[test]
9307    fn gateway_credential_blank_is_refused() {
9308        for key in ["\"\"", "\"   \"", "''"] {
9309            assert_without_credential(
9310                gateway_credential_error(&format!(
9311                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
9312                )),
9313                "https://api.tavily.com",
9314            );
9315        }
9316    }
9317
9318    #[test]
9319    fn gateway_credential_null_is_refused() {
9320        for key in ["~", "null", ""] {
9321            assert_without_credential(
9322                gateway_credential_error(&format!(
9323                    "\n      endpoint: https://api.tavily.com\n      api_key: {key}"
9324                )),
9325                "https://api.tavily.com",
9326            );
9327        }
9328    }
9329
9330    #[test]
9331    fn gateway_credential_keyless_false_is_refused() {
9332        assert_without_credential(
9333            gateway_credential_error(
9334                "\n      endpoint: https://api.tavily.com\n      keyless: false",
9335            ),
9336            "https://api.tavily.com",
9337        );
9338    }
9339
9340    #[test]
9341    fn gateway_credential_keyless_is_accepted() {
9342        for (parse, mode) in [
9343            (
9344                RuntimeManifest::from_yaml_str as fn(&str) -> _,
9345                "with secrets",
9346            ),
9347            (
9348                RuntimeManifest::from_yaml_str_without_secrets,
9349                "without secrets",
9350            ),
9351        ] {
9352            let manifest = parse(&tool_gateway_manifest(
9353                "\n      endpoint: https://api.example.com\n      keyless: true",
9354            ))
9355            .unwrap();
9356            assert_eq!(
9357                manifest.artifacts[0].gateway,
9358                Some(ArtifactGateway {
9359                    endpoint: "https://api.example.com".to_string(),
9360                    api_key: None,
9361                    keyless: true,
9362                }),
9363                "{mode}"
9364            );
9365        }
9366    }
9367
9368    #[test]
9369    fn gateway_credential_keyless_not_a_boolean_is_refused() {
9370        for value in ["\"true\"", "yes please", "1"] {
9371            let error = gateway_credential_error(&format!(
9372                "\n      endpoint: https://api.tavily.com\n      keyless: {value}"
9373            ));
9374            assert!(
9375                matches!(
9376                    error,
9377                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
9378                ),
9379                "{value}: {error:?}"
9380            );
9381            assert!(
9382                error
9383                    .to_string()
9384                    .contains("gateway.keyless with a value that is not a boolean"),
9385                "{error}"
9386            );
9387        }
9388    }
9389
9390    #[test]
9391    fn gateway_credential_and_keyless_together_are_refused() {
9392        for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
9393            let error = gateway_credential_error(&format!(
9394                "\n      endpoint: https://api.tavily.com\n      api_key: {key}\n      keyless: true"
9395            ));
9396            assert!(
9397                matches!(
9398                    error,
9399                    RuntimeManifestError::InvalidArtifact { index: 0, .. }
9400                ),
9401                "{error:?}"
9402            );
9403            assert!(
9404                error.to_string().contains(
9405                    "artifact 'local/web-search' declares both gateway.api_key and gateway.keyless: \
9406                     true; a gateway either presents a key or is keyless"
9407                ),
9408                "{error}"
9409            );
9410        }
9411    }
9412
9413    #[test]
9414    fn gateway_credential_absent_is_refused_without_secrets() {
9415        for block in [
9416            "\n      endpoint: https://api.tavily.com",
9417            "\n      endpoint: https://api.tavily.com\n      api_key: \"\"",
9418            "\n      endpoint: https://api.tavily.com\n      keyless: false",
9419        ] {
9420            assert_without_credential(
9421                RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
9422                    .unwrap_err(),
9423                "https://api.tavily.com",
9424            );
9425        }
9426        let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
9427            "\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}",
9428        ))
9429        .unwrap();
9430        let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
9431        assert_eq!(gateway.api_key, None, "the key reference is left unread");
9432        assert!(!gateway.keyless, "a keyed gateway is never keyless");
9433    }
9434
9435    /// The refusal reads what the manifest wrote, never the address: a loopback or private
9436    /// endpoint with no credential is refused exactly as a public one is.
9437    #[test]
9438    fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
9439        for endpoint in [
9440            "http://127.0.0.1:11434",
9441            "http://localhost:11434",
9442            "http://[::1]:11434",
9443            "https://10.0.0.5",
9444            "https://192.168.1.10:8443",
9445        ] {
9446            assert_without_credential(
9447                gateway_credential_error(&format!("\n      endpoint: {endpoint}")),
9448                endpoint,
9449            );
9450        }
9451    }
9452
9453    #[test]
9454    fn gateway_endpoint_missing_from_the_block_is_refused() {
9455        for block in [
9456            "\n      api_key: ${TAVILY_API_KEY}",
9457            "",
9458            " {}",
9459            "\n      endpoint: \"  \"",
9460        ] {
9461            let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
9462                .unwrap_err()
9463                .to_string();
9464            assert!(
9465                msg.contains("without 'gateway.endpoint'"),
9466                "{block:?}: {msg}"
9467            );
9468            assert!(
9469                msg.contains("artifact 'local/web-search'"),
9470                "{block:?}: {msg}"
9471            );
9472        }
9473    }
9474
9475    #[test]
9476    fn gateway_block_unknown_key_is_reported() {
9477        let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9478            "\n      endpoint: https://api.tavily.com\n      keyless: true\n      api_kee: x",
9479        ))
9480        .unwrap();
9481        let unknown = manifest.unknown_keys;
9482        assert_eq!(unknown.len(), 1, "{unknown:?}");
9483        assert_eq!(unknown[0].key, "api_kee");
9484        assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
9485    }
9486
9487    #[test]
9488    fn gateway_on_skill_entry_is_refused() {
9489        let msg = RuntimeManifest::from_yaml_str(
9490            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/notes\n    version: 0.1.0\n    runtime: skill\n    gateway:\n      endpoint: https://api.tavily.com\n      api_key: test-key\n",
9491        )
9492        .unwrap_err()
9493        .to_string();
9494        assert!(
9495            msg.contains("artifact 'local/notes' declares 'gateway:'"),
9496            "{msg}"
9497        );
9498        assert!(msg.contains("runtime: skill"), "{msg}");
9499    }
9500
9501    #[test]
9502    fn gateway_on_hook_entry_is_accepted() {
9503        let manifest = RuntimeManifest::from_yaml_str(
9504            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/grafana\n    version: 0.1.0\n    runtime: hook\n    gateway:\n      endpoint: https://grafana.example.com/otlp\n      api_key: glc_literal\n",
9505        )
9506        .unwrap();
9507        assert!(manifest.artifacts[0].gateway.is_some());
9508    }
9509
9510    /// An `http` driver capsule. `driver_entry` is the YAML of the driver's artifact entry, or
9511    /// empty for none.
9512    fn http_driver_manifest(artifacts: &str) -> String {
9513        format!(
9514            "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n  transport: http\n  model: test-model\n  driver:\n    artifact: murmur/driver-anthropic\n"
9515        )
9516    }
9517
9518    #[test]
9519    fn http_driver_entry_without_gateway_endpoint_is_refused() {
9520        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9521            "\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver",
9522        ))
9523        .unwrap_err()
9524        .to_string();
9525        assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9526        assert!(msg.contains("gateway.endpoint"), "{msg}");
9527    }
9528
9529    #[test]
9530    fn http_driver_absent_from_artifacts_is_refused() {
9531        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
9532            .unwrap_err()
9533            .to_string();
9534        assert!(msg.contains("inference.driver.artifact"), "{msg}");
9535        assert!(msg.contains("not declared in artifacts:"), "{msg}");
9536    }
9537
9538    #[test]
9539    fn gateway_on_unconfigured_driver_entry_is_refused() {
9540        let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9541            "\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",
9542        ))
9543        .unwrap_err()
9544        .to_string();
9545        assert!(msg.contains("'murmur/driver-openai'"), "{msg}");
9546        assert!(
9547            msg.contains("accepted only on the entry inference.driver.artifact names"),
9548            "{msg}"
9549        );
9550
9551        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";
9552        let msg = RuntimeManifest::from_yaml_str(no_inference)
9553            .unwrap_err()
9554            .to_string();
9555        assert!(msg.contains("not the configured inference driver"), "{msg}");
9556    }
9557
9558    #[test]
9559    fn gateway_on_driver_entry_under_process_transport_is_refused() {
9560        let msg = RuntimeManifest::from_yaml_str(
9561            "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",
9562        )
9563        .unwrap_err()
9564        .to_string();
9565        assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9566        assert!(msg.contains("transport: process"), "{msg}");
9567    }
9568
9569    #[test]
9570    fn inference_api_key_is_refused_naming_gateway_api_key() {
9571        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";
9572        for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
9573            let manifest = http_driver_manifest(entry)
9574                .replace("  model:", &format!("  api_key: {value}\n  model:"));
9575            let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
9576            let msg = err.to_string();
9577            assert!(msg.contains("'inference.api_key'"), "{msg}");
9578            assert!(msg.contains("was removed"), "{msg}");
9579            assert!(
9580                msg.contains(
9581                    "set gateway.api_key on the artifacts: entry 'murmur/driver-anthropic'"
9582                ),
9583                "{msg}"
9584            );
9585            assert!(!msg.contains("sk-literal"), "{msg}");
9586        }
9587
9588        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  api_key: ${KEY}\n";
9589        let msg = RuntimeManifest::from_yaml_str(process)
9590            .unwrap_err()
9591            .to_string();
9592        assert!(msg.contains("'inference.api_key'"), "{msg}");
9593        assert!(
9594            msg.contains("on the artifacts: entry inference.driver.artifact names"),
9595            "{msg}"
9596        );
9597        assert!(
9598            RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
9599            "the refusal does not depend on resolving secrets"
9600        );
9601    }
9602
9603    #[test]
9604    fn inference_endpoint_is_refused_naming_gateway_endpoint() {
9605        let manifest = http_driver_manifest(
9606            "\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",
9607        )
9608        .replace("  model:", "  endpoint: https://api.anthropic.com\n  model:");
9609        let msg = RuntimeManifest::from_yaml_str(&manifest)
9610            .unwrap_err()
9611            .to_string();
9612        assert!(msg.contains("'inference.endpoint'"), "{msg}");
9613        assert!(
9614            msg.contains("set gateway.endpoint on the artifacts: entry 'murmur/driver-anthropic'"),
9615            "{msg}"
9616        );
9617
9618        let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  endpoint: http://localhost:8080\n";
9619        let msg = RuntimeManifest::from_yaml_str(process)
9620            .unwrap_err()
9621            .to_string();
9622        assert!(msg.contains("'inference.endpoint'"), "{msg}");
9623        assert!(msg.contains("was removed"), "{msg}");
9624    }
9625
9626    #[test]
9627    fn without_secrets_leaves_gateway_api_key_unread() {
9628        let manifest = tool_gateway_manifest(
9629            "\n      endpoint: https://api.tavily.com\n      api_key: tvly-literal",
9630        );
9631        let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
9632        assert_eq!(
9633            resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
9634            Some(ApiKeyReference::Literal("tvly-literal".to_string()))
9635        );
9636        let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
9637        let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
9638        assert_eq!(gateway.endpoint, "https://api.tavily.com");
9639        assert_eq!(gateway.api_key, None);
9640    }
9641
9642    #[test]
9643    fn referenced_env_variables_reads_gateway_api_key() {
9644        let manifest = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/web-search\n    version: 0.1.0\n    gateway:\n      endpoint: https://api.tavily.com\n      api_key: ${TAVILY_API_KEY}\n  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.anthropic.com\n      api_key: ${ANTHROPIC_API_KEY}\n  - name: local/literal\n    version: 0.1.0\n    gateway:\n      endpoint: https://example.com\n      api_key: not-a-reference\ninference:\n  model: m\n  driver:\n    artifact: murmur/driver-anthropic\n";
9645        let referenced = referenced_env_variables(manifest);
9646        assert_eq!(
9647            referenced,
9648            vec![
9649                ReferencedEnvVariable {
9650                    field: "artifacts.murmur/driver-anthropic.gateway.api_key".to_string(),
9651                    variable: "ANTHROPIC_API_KEY".to_string(),
9652                },
9653                ReferencedEnvVariable {
9654                    field: "artifacts.local/web-search.gateway.api_key".to_string(),
9655                    variable: "TAVILY_API_KEY".to_string(),
9656                },
9657            ]
9658        );
9659        assert!(referenced
9660            .iter()
9661            .all(ReferencedEnvVariable::is_gateway_credential));
9662    }
9663
9664    #[test]
9665    fn parses_inline_system_prompt() {
9666        let manifest = RuntimeManifest::from_yaml_str(
9667            r#"
9668name: cap
9669version: 0.0.1
9670artifacts:
9671  - name: murmur/driver-anthropic
9672    version: 0.1.0
9673    runtime: driver
9674    gateway:
9675      endpoint: http://127.0.0.1:8080
9676      keyless: true
9677inference:
9678  model: test-model
9679  system_prompt: "Always begin with CONFIRMED:"
9680  driver:
9681    artifact: murmur/driver-anthropic
9682"#,
9683        )
9684        .unwrap();
9685
9686        let inference = manifest.inference.unwrap();
9687        assert_eq!(
9688            inference.system_prompt,
9689            Some("Always begin with CONFIRMED:".to_string())
9690        );
9691        assert!(inference.system_prompt_file.is_none());
9692    }
9693
9694    #[test]
9695    fn parses_compaction_system_prompt() {
9696        let manifest = RuntimeManifest::from_yaml_str(
9697            r#"
9698name: cap
9699version: 0.0.1
9700artifacts:
9701  - name: murmur/driver-anthropic
9702    version: 0.1.0
9703    runtime: driver
9704    gateway:
9705      endpoint: http://127.0.0.1:8080
9706      keyless: true
9707inference:
9708  model: test-model
9709  compaction:
9710    model: compaction-model
9711    system_prompt: "task = X, currently editing Y, already tried Z."
9712  driver:
9713    artifact: murmur/driver-anthropic
9714"#,
9715        )
9716        .unwrap();
9717
9718        let compaction = manifest.inference.unwrap().compaction.unwrap();
9719        assert_eq!(
9720            compaction.system_prompt,
9721            Some("task = X, currently editing Y, already tried Z.".to_string())
9722        );
9723        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9724    }
9725
9726    /// The two override fields resolve independently: `model` set, `system_prompt`
9727    /// absent leaves the latter `None` with no default string substituted.
9728    #[test]
9729    fn compaction_system_prompt_absent_stays_none() {
9730        let manifest = RuntimeManifest::from_yaml_str(
9731            r#"
9732name: cap
9733version: 0.0.1
9734artifacts:
9735  - name: murmur/driver-anthropic
9736    version: 0.1.0
9737    runtime: driver
9738    gateway:
9739      endpoint: http://127.0.0.1:8080
9740      keyless: true
9741inference:
9742  model: test-model
9743  compaction:
9744    model: compaction-model
9745  driver:
9746    artifact: murmur/driver-anthropic
9747"#,
9748        )
9749        .unwrap();
9750
9751        let compaction = manifest.inference.unwrap().compaction.unwrap();
9752        assert!(compaction.system_prompt.is_none());
9753        assert!(compaction.system_prompt_file.is_none());
9754        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9755    }
9756
9757    /// `system_prompt_file` parses as the second, independent prompt source: the path is
9758    /// carried through verbatim (resolution against the manifest dir happens in the
9759    /// runtime), the inline field stays `None`, and `model` is unaffected.
9760    #[test]
9761    fn parses_compaction_system_prompt_file() {
9762        let manifest = RuntimeManifest::from_yaml_str(
9763            r#"
9764name: cap
9765version: 0.0.1
9766artifacts:
9767  - name: murmur/driver-anthropic
9768    version: 0.1.0
9769    runtime: driver
9770    gateway:
9771      endpoint: http://127.0.0.1:8080
9772      keyless: true
9773inference:
9774  model: test-model
9775  compaction:
9776    model: compaction-model
9777    system_prompt_file: "compaction-instructions.md"
9778  driver:
9779    artifact: murmur/driver-anthropic
9780"#,
9781        )
9782        .unwrap();
9783
9784        let compaction = manifest.inference.unwrap().compaction.unwrap();
9785        assert_eq!(
9786            compaction.system_prompt_file,
9787            Some("compaction-instructions.md".to_string())
9788        );
9789        assert!(compaction.system_prompt.is_none());
9790        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9791    }
9792
9793    /// `system_prompt_file` alone, with no `model`, still parses — the two fields do not
9794    /// depend on each other in either direction.
9795    #[test]
9796    fn compaction_system_prompt_file_independent_of_model() {
9797        let manifest = RuntimeManifest::from_yaml_str(
9798            r#"
9799name: cap
9800version: 0.0.1
9801artifacts:
9802  - name: murmur/driver-anthropic
9803    version: 0.1.0
9804    runtime: driver
9805    gateway:
9806      endpoint: http://127.0.0.1:8080
9807      keyless: true
9808inference:
9809  model: test-model
9810  compaction:
9811    threshold: 0.5
9812    system_prompt_file: "compaction-instructions.md"
9813  driver:
9814    artifact: murmur/driver-anthropic
9815"#,
9816        )
9817        .unwrap();
9818
9819        let compaction = manifest.inference.unwrap().compaction.unwrap();
9820        assert_eq!(
9821            compaction.system_prompt_file,
9822            Some("compaction-instructions.md".to_string())
9823        );
9824        assert!(compaction.model.is_none());
9825        assert_eq!(compaction.threshold, Some(0.5));
9826    }
9827
9828    /// `dump_summaries` is a plain optional boolean: present-true, present-false and absent
9829    /// are three distinguishable states, so the runtime can tell "explicitly off" from
9830    /// "never configured" even though both resolve to the same behavior.
9831    #[test]
9832    fn parses_compaction_dump_summaries() {
9833        let parse = |line: &str| {
9834            RuntimeManifest::from_yaml_str(&format!(
9835                r#"
9836name: cap
9837version: 0.0.1
9838artifacts:
9839  - name: murmur/driver-anthropic
9840    version: 0.1.0
9841    runtime: driver
9842    gateway:
9843      endpoint: http://127.0.0.1:8080
9844      keyless: true
9845inference:
9846  model: test-model
9847  compaction:
9848    model: compaction-model
9849{line}  driver:
9850    artifact: murmur/driver-anthropic
9851"#
9852            ))
9853            .unwrap()
9854            .inference
9855            .unwrap()
9856            .compaction
9857            .unwrap()
9858        };
9859
9860        assert_eq!(
9861            parse("    dump_summaries: true\n").dump_summaries,
9862            Some(true)
9863        );
9864        assert_eq!(
9865            parse("    dump_summaries: false\n").dump_summaries,
9866            Some(false)
9867        );
9868        assert_eq!(parse("").dump_summaries, None);
9869    }
9870
9871    /// `dump_summaries` is orthogonal to every other compaction field: it neither trips the
9872    /// prompt-source exclusivity check nor the threshold range check, and does not disturb
9873    /// the values those fields parse to.
9874    #[test]
9875    fn compaction_dump_summaries_does_not_interact_with_other_fields() {
9876        let compaction = RuntimeManifest::from_yaml_str(
9877            r#"
9878name: cap
9879version: 0.0.1
9880artifacts:
9881  - name: murmur/driver-anthropic
9882    version: 0.1.0
9883    runtime: driver
9884    gateway:
9885      endpoint: http://127.0.0.1:8080
9886      keyless: true
9887inference:
9888  model: test-model
9889  compaction:
9890    threshold: 0.5
9891    model: compaction-model
9892    system_prompt_file: "compaction-instructions.md"
9893    dump_summaries: true
9894  driver:
9895    artifact: murmur/driver-anthropic
9896"#,
9897        )
9898        .unwrap()
9899        .inference
9900        .unwrap()
9901        .compaction
9902        .unwrap();
9903
9904        assert_eq!(compaction.dump_summaries, Some(true));
9905        assert_eq!(compaction.threshold, Some(0.5));
9906        assert_eq!(compaction.model, Some("compaction-model".to_string()));
9907        assert_eq!(
9908            compaction.system_prompt_file,
9909            Some("compaction-instructions.md".to_string())
9910        );
9911        assert!(compaction.system_prompt.is_none());
9912
9913        // Still mutually exclusive with dump_summaries in the mix...
9914        let err = RuntimeManifest::from_yaml_str(
9915            r#"
9916name: cap
9917version: 0.0.1
9918artifacts:
9919  - name: murmur/driver-anthropic
9920    version: 0.1.0
9921    runtime: driver
9922    gateway:
9923      endpoint: http://127.0.0.1:8080
9924      keyless: true
9925inference:
9926  model: test-model
9927  compaction:
9928    dump_summaries: true
9929    system_prompt: "inline"
9930    system_prompt_file: "compaction-instructions.md"
9931  driver:
9932    artifact: murmur/driver-anthropic
9933"#,
9934        )
9935        .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
9936        assert!(matches!(
9937            err,
9938            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9939                if field == "inference.compaction.system_prompt"
9940        ));
9941
9942        // ...and the threshold range is still enforced.
9943        let err = RuntimeManifest::from_yaml_str(
9944            r#"
9945name: cap
9946version: 0.0.1
9947artifacts:
9948  - name: murmur/driver-anthropic
9949    version: 0.1.0
9950    runtime: driver
9951    gateway:
9952      endpoint: http://127.0.0.1:8080
9953      keyless: true
9954inference:
9955  model: test-model
9956  compaction:
9957    dump_summaries: true
9958    threshold: 1.5
9959  driver:
9960    artifact: murmur/driver-anthropic
9961"#,
9962        )
9963        .expect_err("dump_summaries must not suppress the threshold range check");
9964        assert!(matches!(
9965            err,
9966            RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9967                if field == "inference.compaction.threshold"
9968        ));
9969    }
9970
9971    fn compaction_call_manifest(lines: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
9972        RuntimeManifest::from_yaml_str(&format!(
9973            r#"
9974name: cap
9975version: 0.0.1
9976artifacts:
9977  - name: murmur/driver-anthropic
9978    version: 0.1.0
9979    runtime: driver
9980    gateway:
9981      endpoint: http://127.0.0.1:8080
9982      keyless: true
9983inference:
9984  model: test-model
9985  driver:
9986    artifact: murmur/driver-anthropic
9987    config:
9988      reasoning_effort: max
9989  compaction:
9990{lines}"#
9991        ))
9992    }
9993
9994    #[test]
9995    fn parses_compaction_max_tokens_and_driver_config() {
9996        let manifest = compaction_call_manifest(
9997            "    max_tokens: 8000\n    driver_config:\n      reasoning_effort: low\n      extra: [1, 2]\n",
9998        )
9999        .unwrap();
10000        assert!(
10001            manifest.unknown_keys.is_empty(),
10002            "neither key is unknown: {:?}",
10003            manifest.unknown_keys
10004        );
10005        let compaction = manifest.inference.unwrap().compaction.unwrap();
10006        assert_eq!(compaction.max_tokens, Some(8000));
10007        assert_eq!(
10008            compaction.driver_config.as_deref(),
10009            Some(r#"{"extra":[1,2],"reasoning_effort":"low"}"#)
10010        );
10011
10012        let compaction = compaction_call_manifest("    threshold: 0.5\n")
10013            .unwrap()
10014            .inference
10015            .unwrap()
10016            .compaction
10017            .unwrap();
10018        assert_eq!(compaction.max_tokens, None);
10019        assert_eq!(compaction.driver_config, None);
10020
10021        // An empty mapping is an overlay that changes nothing, not an absent one.
10022        let compaction = compaction_call_manifest("    driver_config: {}\n")
10023            .unwrap()
10024            .inference
10025            .unwrap()
10026            .compaction
10027            .unwrap();
10028        assert_eq!(compaction.driver_config.as_deref(), Some("{}"));
10029    }
10030
10031    #[test]
10032    fn compaction_max_tokens_zero_is_refused() {
10033        match compaction_call_manifest("    max_tokens: 0\n").unwrap_err() {
10034            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10035                assert_eq!(field, "inference.compaction.max_tokens");
10036                assert_eq!(message, "must be greater than 0");
10037            }
10038            other => panic!("unexpected error: {other:?}"),
10039        }
10040    }
10041
10042    #[test]
10043    fn compaction_driver_config_must_be_a_mapping() {
10044        for value in ["low", "[reasoning_effort]", "3"] {
10045            match compaction_call_manifest(&format!("    driver_config: {value}\n")).unwrap_err() {
10046                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10047                    assert_eq!(field, "inference.compaction.driver_config");
10048                    assert_eq!(message, "expected mapping/object");
10049                }
10050                other => panic!("unexpected error for {value}: {other:?}"),
10051            }
10052        }
10053    }
10054
10055    /// The harness runs its own compaction under `transport: process`, so neither key would
10056    /// shape any call there.
10057    #[test]
10058    fn process_transport_rejects_compaction_call_keys() {
10059        for (key, value) in [
10060            ("max_tokens", "8000"),
10061            ("driver_config", "{reasoning_effort: low}"),
10062        ] {
10063            let err = RuntimeManifest::from_yaml_str(&format!(
10064                "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-claude-code\n    \
10065                 version: 0.1.0\n    runtime: driver\ninference:\n  transport: process\n  \
10066                 model: claude-haiku-4-5-20251001\n  driver:\n    artifact: murmur/driver-claude-code\n  \
10067                 compaction:\n    {key}: {value}\n"
10068            ))
10069            .unwrap_err();
10070            match err {
10071                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10072                    assert_eq!(field, format!("inference.compaction.{key}"));
10073                    assert_eq!(message, "is not valid with transport: process");
10074                }
10075                other => panic!("unexpected error for {key}: {other:?}"),
10076            }
10077        }
10078    }
10079
10080    /// Setting both compaction prompt sources is a parse error naming the compaction
10081    /// fields — distinguishable from the top-level three-way `inference.system_prompt`
10082    /// exclusivity error, and with neither value silently preferred.
10083    #[test]
10084    fn compaction_prompt_sources_are_mutually_exclusive() {
10085        let err = RuntimeManifest::from_yaml_str(
10086            r#"
10087name: cap
10088version: 0.0.1
10089artifacts:
10090  - name: murmur/driver-anthropic
10091    version: 0.1.0
10092    runtime: driver
10093    gateway:
10094      endpoint: http://127.0.0.1:8080
10095      keyless: true
10096inference:
10097  model: test-model
10098  compaction:
10099    system_prompt: "inline"
10100    system_prompt_file: "compaction-instructions.md"
10101  driver:
10102    artifact: murmur/driver-anthropic
10103"#,
10104        )
10105        .expect_err("both compaction prompt sources set must fail");
10106
10107        match err {
10108            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10109                assert_eq!(field, "inference.compaction.system_prompt");
10110                assert_eq!(
10111                    message,
10112                    "at most one of inference.compaction.system_prompt, \
10113                     inference.compaction.system_prompt_file may be set"
10114                );
10115            }
10116            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
10117        }
10118    }
10119
10120    /// The compaction-level exclusivity check is separate from the top-level one: an
10121    /// inline top-level `system_prompt` alongside a compaction `system_prompt_file` is a
10122    /// perfectly legal manifest.
10123    #[test]
10124    fn top_level_and_compaction_prompt_sources_do_not_collide() {
10125        let manifest = RuntimeManifest::from_yaml_str(
10126            r#"
10127name: cap
10128version: 0.0.1
10129artifacts:
10130  - name: murmur/driver-anthropic
10131    version: 0.1.0
10132    runtime: driver
10133    gateway:
10134      endpoint: http://127.0.0.1:8080
10135      keyless: true
10136inference:
10137  model: test-model
10138  system_prompt: "be helpful"
10139  compaction:
10140    system_prompt_file: "compaction-instructions.md"
10141  driver:
10142    artifact: murmur/driver-anthropic
10143"#,
10144        )
10145        .unwrap();
10146
10147        let inference = manifest.inference.unwrap();
10148        assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
10149        assert_eq!(
10150            inference.compaction.unwrap().system_prompt_file,
10151            Some("compaction-instructions.md".to_string())
10152        );
10153    }
10154
10155    #[test]
10156    fn parses_system_prompt_file() {
10157        let manifest = RuntimeManifest::from_yaml_str(
10158            r#"
10159name: cap
10160version: 0.0.1
10161artifacts:
10162  - name: murmur/driver-anthropic
10163    version: 0.1.0
10164    runtime: driver
10165    gateway:
10166      endpoint: http://127.0.0.1:8080
10167      keyless: true
10168inference:
10169  model: test-model
10170  system_prompt_file: "conventions.md"
10171  driver:
10172    artifact: murmur/driver-anthropic
10173"#,
10174        )
10175        .unwrap();
10176
10177        let inference = manifest.inference.unwrap();
10178        assert_eq!(
10179            inference.system_prompt_file,
10180            Some("conventions.md".to_string())
10181        );
10182        assert!(inference.system_prompt.is_none());
10183    }
10184
10185    #[test]
10186    fn rejects_both_inline_and_file_system_prompt() {
10187        let err = RuntimeManifest::from_yaml_str(
10188            r#"
10189name: cap
10190version: 0.0.1
10191artifacts:
10192  - name: murmur/driver-anthropic
10193    version: 0.1.0
10194    runtime: driver
10195    gateway:
10196      endpoint: http://127.0.0.1:8080
10197      keyless: true
10198inference:
10199  model: test-model
10200  system_prompt: "inline"
10201  system_prompt_file: "conventions.md"
10202  driver:
10203    artifact: murmur/driver-anthropic
10204"#,
10205        )
10206        .unwrap_err();
10207
10208        let msg = err.to_string();
10209        assert!(msg.contains("inference.system_prompt"));
10210        assert!(msg.contains("system_prompt_file"));
10211    }
10212
10213    fn instruction_files_manifest(transport: &str, entries: &str) -> String {
10214        let (artifact, inference) = if transport == "process" {
10215            (
10216                "  - name: murmur/driver-claude-code\n    version: 0.1.0\n    runtime: driver\n",
10217                "  transport: process\n  driver:\n    artifact: murmur/driver-claude-code\n",
10218            )
10219        } else {
10220            (
10221                "  - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: driver\n    \
10222                 gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\n",
10223                "  model: test-model\n  driver:\n    artifact: murmur/driver-anthropic\n",
10224            )
10225        };
10226        format!(
10227            "name: cap\nversion: 0.0.1\nartifacts:\n{artifact}inference:\n{inference}  \
10228             instruction_files: {entries}\n"
10229        )
10230    }
10231
10232    #[test]
10233    fn instruction_files_absent_is_none() {
10234        let manifest = RuntimeManifest::from_yaml_str(
10235            r#"
10236name: cap
10237version: 0.0.1
10238artifacts:
10239  - name: murmur/driver-anthropic
10240    version: 0.1.0
10241    runtime: driver
10242    gateway:
10243      endpoint: http://127.0.0.1:8080
10244      keyless: true
10245inference:
10246  model: test-model
10247  driver:
10248    artifact: murmur/driver-anthropic
10249"#,
10250        )
10251        .unwrap();
10252        assert_eq!(manifest.inference.unwrap().instruction_files, None);
10253    }
10254
10255    /// Entries are kept as written and in manifest order, `.` components included, on both
10256    /// transports, and the key draws no unknown-key report.
10257    #[test]
10258    fn instruction_files_are_kept_verbatim_in_order_on_both_transports() {
10259        for transport in ["http", "process"] {
10260            let manifest = RuntimeManifest::from_yaml_str(&instruction_files_manifest(
10261                transport,
10262                "[CLAUDE.md, ./AGENTS.md, docs/agents/RULES.md]",
10263            ))
10264            .unwrap();
10265            assert!(
10266                manifest.unknown_keys.is_empty(),
10267                "{transport}: {:?}",
10268                manifest.unknown_keys
10269            );
10270            assert_eq!(
10271                manifest.inference.unwrap().instruction_files,
10272                Some(vec![
10273                    "CLAUDE.md".to_string(),
10274                    "./AGENTS.md".to_string(),
10275                    "docs/agents/RULES.md".to_string(),
10276                ]),
10277                "{transport}"
10278            );
10279        }
10280    }
10281
10282    #[test]
10283    fn instruction_files_empty_list_is_some_empty() {
10284        let manifest =
10285            RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", "[]")).unwrap();
10286        assert_eq!(manifest.inference.unwrap().instruction_files, Some(vec![]));
10287    }
10288
10289    /// An entry that is blank, absolute or climbs out with `..` is refused on the indexed field,
10290    /// with the entry quoted, before anything is staged.
10291    #[test]
10292    fn instruction_files_refuse_entries_that_leave_the_work_folder() {
10293        for (entries, index, entry, problem) in [
10294            ("[/etc/passwd]", 0, "/etc/passwd", "is absolute"),
10295            (
10296                "[AGENTS.md, ../AGENTS.md]",
10297                1,
10298                "../AGENTS.md",
10299                "'..' component",
10300            ),
10301            ("[a/../../b]", 0, "a/../../b", "'..' component"),
10302            ("[\"\"]", 0, "", "is empty"),
10303            ("[AGENTS.md, \"  \"]", 1, "  ", "is empty"),
10304        ] {
10305            let err = RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", entries))
10306                .unwrap_err();
10307            let RuntimeManifestError::InvalidInferenceConfig { field, message } = &err else {
10308                panic!("{entries}: expected InvalidInferenceConfig, got {err:?}");
10309            };
10310            assert_eq!(field, &format!("inference.instruction_files[{index}]"));
10311            assert!(message.contains(&format!("'{entry}'")), "{message}");
10312            assert!(message.contains(problem), "{message}");
10313            assert!(
10314                message.contains("must be a relative path inside the work folder"),
10315                "{message}"
10316            );
10317        }
10318    }
10319
10320    /// A `${NAME}` is kept as the name it is, whether or not this process holds the variable: the
10321    /// parse never reads the environment.
10322    #[test]
10323    fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
10324        let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
10325        let yaml = format!(
10326            "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"
10327        );
10328
10329        assert_eq!(
10330            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
10331                .gateway
10332                .clone()
10333                .unwrap()
10334                .api_key,
10335            Some(ApiKeyReference::Environment(key.to_string()))
10336        );
10337        assert_eq!(
10338            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
10339                .unwrap()
10340                .artifacts[0]
10341                .gateway
10342                .clone()
10343                .unwrap()
10344                .api_key,
10345            None
10346        );
10347    }
10348
10349    #[test]
10350    fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
10351        let literal = ["sk-", "ant-", "debug"].concat();
10352        let debug = format!(
10353            "{:?} {:?}",
10354            ApiKeyReference::Environment("SOME_KEY".to_string()),
10355            ApiKeyReference::Literal(literal.clone())
10356        );
10357        assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
10358        assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
10359        assert!(!debug.contains(&literal), "{debug}");
10360    }
10361
10362    #[test]
10363    fn parse_without_secrets_drops_a_literal_api_key() {
10364        // Key assembled at runtime so the source never contains a
10365        // credential-shaped literal that secret scanners could flag.
10366        let literal = ["sk-", "ant-", "literal"].concat();
10367        let yaml = format!(
10368            "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"
10369        );
10370
10371        assert_eq!(
10372            RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
10373                .gateway
10374                .clone()
10375                .unwrap()
10376                .api_key,
10377            Some(ApiKeyReference::Literal(literal))
10378        );
10379        assert_eq!(
10380            RuntimeManifest::from_yaml_str_without_secrets(&yaml)
10381                .unwrap()
10382                .artifacts[0]
10383                .gateway
10384                .clone()
10385                .unwrap()
10386                .api_key,
10387            None
10388        );
10389    }
10390
10391    /// The non-resolving parse is narrower only in what it resolves, never in what it validates:
10392    /// a manifest roost accepts is a manifest `mur run` would also accept.
10393    #[test]
10394    fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
10395        let process_with_key = format!(
10396            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  model: test-model\n  api_key: {}\n",
10397            ["sk-", "ant-", "secret"].concat()
10398        );
10399        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";
10400
10401        for yaml in [process_with_key.as_str(), http_without_model] {
10402            let resolving = RuntimeManifest::from_yaml_str(yaml)
10403                .unwrap_err()
10404                .to_string();
10405            let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
10406                .unwrap_err()
10407                .to_string();
10408            assert_eq!(resolving, without_secrets);
10409        }
10410    }
10411
10412    /// The reference scan answers from the manifest text alone: it reports a reference, ignores
10413    /// a literal, and has nothing to say about a manifest that carries no `inference` block or
10414    /// does not parse at all.
10415    #[test]
10416    fn referenced_env_variables_reports_a_reference_and_nothing_else() {
10417        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";
10418        let referenced = referenced_env_variables(referencing);
10419        assert_eq!(referenced.len(), 1);
10420        assert_eq!(
10421            referenced[0].field,
10422            "artifacts.murmur/driver-anthropic.gateway.api_key"
10423        );
10424        assert!(referenced[0].is_gateway_credential());
10425        assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
10426
10427        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";
10428        assert!(referenced_env_variables(literal).is_empty());
10429
10430        let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
10431        assert!(referenced_env_variables(no_inference).is_empty());
10432
10433        let unparseable = "name: broken\nversion: [\n";
10434        assert!(referenced_env_variables(unparseable).is_empty());
10435    }
10436
10437    #[test]
10438    fn inference_requires_driver_artifact() {
10439        let err = RuntimeManifest::from_yaml_str(
10440            r#"
10441name: cap
10442version: 0.0.1
10443artifacts: []
10444inference:
10445  transport: http
10446  endpoint: http://127.0.0.1:8080
10447  model: test-model
10448"#,
10449        )
10450        .unwrap_err();
10451
10452        assert!(err.to_string().contains("inference.driver"));
10453    }
10454
10455    #[test]
10456    fn test_runtime_driver_deserializes() {
10457        let manifest = RuntimeManifest::from_yaml_str(
10458            r#"
10459name: cap
10460version: 0.0.1
10461artifacts:
10462  - name: local/my-provider
10463    version: 1.0.0
10464    runtime: driver
10465"#,
10466        )
10467        .unwrap();
10468
10469        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
10470    }
10471
10472    #[test]
10473    fn test_runtime_unknown_is_error() {
10474        let err = RuntimeManifest::from_yaml_str(
10475            r#"
10476name: cap
10477version: 0.0.1
10478artifacts:
10479  - name: local/foo
10480    version: 1.0.0
10481    runtime: unknown_type
10482"#,
10483        )
10484        .unwrap_err();
10485
10486        let msg = err.to_string();
10487        assert!(msg.contains("runtime"), "error should mention 'runtime'");
10488        assert!(
10489            msg.contains("unknown_type"),
10490            "error should identify the bad value"
10491        );
10492    }
10493
10494    #[test]
10495    fn test_runtime_all_variants_roundtrip() {
10496        for variant in [
10497            ArtifactRuntime::Tool,
10498            ArtifactRuntime::Driver,
10499            ArtifactRuntime::Hook,
10500            ArtifactRuntime::Skill,
10501        ] {
10502            let json = serde_json::to_string(&variant).unwrap();
10503            let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
10504            assert_eq!(variant, back);
10505        }
10506    }
10507
10508    #[test]
10509    fn skill_source_without_version_defaults_to_local() {
10510        let manifest = RuntimeManifest::from_yaml_str(
10511            r#"
10512name: cap
10513version: 0.0.1
10514artifacts:
10515  - name: my-skill
10516    source: ./skills/my-skill/skill.md
10517    runtime: skill
10518"#,
10519        )
10520        .unwrap();
10521
10522        assert_eq!(manifest.artifacts.len(), 1);
10523        let artifact = &manifest.artifacts[0];
10524        assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
10525        assert_eq!(
10526            artifact.source.as_deref(),
10527            Some("./skills/my-skill/skill.md")
10528        );
10529        assert_eq!(artifact.version, "local");
10530    }
10531
10532    #[test]
10533    fn skill_source_directory_path_is_accepted() {
10534        let manifest = RuntimeManifest::from_yaml_str(
10535            r#"
10536name: cap
10537version: 0.0.1
10538artifacts:
10539  - name: my-skill
10540    source: ./skills/my-skill/
10541    runtime: skill
10542"#,
10543        )
10544        .unwrap();
10545
10546        assert_eq!(
10547            manifest.artifacts[0].source.as_deref(),
10548            Some("./skills/my-skill/")
10549        );
10550        assert_eq!(manifest.artifacts[0].version, "local");
10551    }
10552
10553    #[test]
10554    fn skill_source_with_explicit_version_substitutes_local() {
10555        // Both set is allowed; version is ignored and "local" is substituted.
10556        let manifest = RuntimeManifest::from_yaml_str(
10557            r#"
10558name: cap
10559version: 0.0.1
10560artifacts:
10561  - name: my-skill
10562    source: ./skills/my-skill/skill.md
10563    version: 9.9.9
10564    runtime: skill
10565"#,
10566        )
10567        .unwrap();
10568
10569        assert_eq!(manifest.artifacts[0].version, "local");
10570    }
10571
10572    #[test]
10573    fn local_source_true_allows_source_on_non_skill_runtime() {
10574        for runtime in ["tool", "driver", "hook"] {
10575            let yaml = format!(
10576                r#"
10577name: cap
10578version: 0.0.1
10579artifacts:
10580  - name: my-thing
10581    source: ./local/path
10582    local_source: true
10583    runtime: {runtime}
10584"#
10585            );
10586            let manifest = RuntimeManifest::from_yaml_str(&yaml)
10587                .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
10588            let artifact = &manifest.artifacts[0];
10589            assert!(artifact.local_source, "runtime {runtime}");
10590            assert_eq!(artifact.source.as_deref(), Some("./local/path"));
10591            assert_eq!(artifact.version, "local", "runtime {runtime}");
10592        }
10593    }
10594
10595    #[test]
10596    fn local_source_false_on_skill_with_source_is_error() {
10597        // The gate reads the declared field, not the role: an explicit `local_source: false`
10598        // overrides the skill default and rejects `source:`.
10599        let err = RuntimeManifest::from_yaml_str(
10600            r#"
10601name: cap
10602version: 0.0.1
10603artifacts:
10604  - name: my-skill
10605    source: ./skills/my-skill
10606    local_source: false
10607    runtime: skill
10608"#,
10609        )
10610        .unwrap_err();
10611        let msg = err.to_string();
10612        assert!(msg.contains("'local_source: true'"), "error was: {msg}");
10613        assert!(msg.contains("my-skill"), "error was: {msg}");
10614    }
10615
10616    #[test]
10617    fn local_source_defaults_from_role_when_absent() {
10618        let manifest = RuntimeManifest::from_yaml_str(
10619            r#"
10620name: cap
10621version: 0.0.1
10622artifacts:
10623  - name: my-skill
10624    source: ./skills/my-skill
10625    runtime: skill
10626  - name: local/my-tool
10627    version: 1.0.0
10628    runtime: tool
10629"#,
10630        )
10631        .unwrap();
10632        assert!(manifest.artifacts[0].local_source, "skill defaults to true");
10633        assert!(
10634            !manifest.artifacts[1].local_source,
10635            "tool defaults to false"
10636        );
10637    }
10638
10639    #[test]
10640    fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
10641        let manifest = RuntimeManifest::from_yaml_str(
10642            r#"
10643name: cap
10644version: 0.0.1
10645artifacts:
10646  - name: local/my-tool
10647    version: 1.0.0
10648    runtime: tool
10649    prompt_payload: true
10650  - name: murmur/driver-anthropic
10651    version: 0.1.0
10652    runtime: driver
10653    gateway:
10654      endpoint: http://127.0.0.1:8080
10655      keyless: true
10656inference:
10657  model: test-model
10658  system_prompt_artifact: local/my-tool
10659  driver:
10660    artifact: murmur/driver-anthropic
10661"#,
10662        )
10663        .unwrap();
10664        assert!(manifest.artifacts[0].prompt_payload);
10665    }
10666
10667    #[test]
10668    fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
10669        let err = RuntimeManifest::from_yaml_str(
10670            r#"
10671name: cap
10672version: 0.0.1
10673artifacts:
10674  - name: local/my-skill
10675    version: 1.0.0
10676    runtime: skill
10677    prompt_payload: false
10678  - name: murmur/driver-anthropic
10679    version: 0.1.0
10680    runtime: driver
10681    gateway:
10682      endpoint: http://127.0.0.1:8080
10683      keyless: true
10684inference:
10685  model: test-model
10686  system_prompt_artifact: local/my-skill
10687  driver:
10688    artifact: murmur/driver-anthropic
10689"#,
10690        )
10691        .unwrap_err();
10692        let msg = err.to_string();
10693        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
10694    }
10695
10696    #[test]
10697    fn prompt_payload_defaults_from_role_when_absent() {
10698        let manifest = RuntimeManifest::from_yaml_str(
10699            r#"
10700name: cap
10701version: 0.0.1
10702artifacts:
10703  - name: local/my-skill
10704    version: 1.0.0
10705    runtime: skill
10706  - name: local/my-tool
10707    version: 1.0.0
10708    runtime: tool
10709"#,
10710        )
10711        .unwrap();
10712        assert!(
10713            manifest.artifacts[0].prompt_payload,
10714            "skill defaults to true"
10715        );
10716        assert!(
10717            !manifest.artifacts[1].prompt_payload,
10718            "tool defaults to false"
10719        );
10720    }
10721
10722    #[test]
10723    fn source_on_non_skill_runtime_is_error() {
10724        for runtime in ["tool", "driver", "hook"] {
10725            let yaml = format!(
10726                r#"
10727name: cap
10728version: 0.0.1
10729artifacts:
10730  - name: my-thing
10731    source: ./local/path
10732    runtime: {runtime}
10733"#
10734            );
10735            let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
10736            let msg = err.to_string();
10737            assert!(
10738                msg.contains("'local_source: true'"),
10739                "runtime {runtime}: error was: {msg}"
10740            );
10741            assert!(
10742                msg.contains("my-thing"),
10743                "runtime {runtime}: error was: {msg}"
10744            );
10745        }
10746    }
10747
10748    #[test]
10749    fn registry_skill_still_requires_version() {
10750        let err = RuntimeManifest::from_yaml_str(
10751            r#"
10752name: cap
10753version: 0.0.1
10754artifacts:
10755  - name: local/my-skill
10756    runtime: skill
10757"#,
10758        )
10759        .unwrap_err();
10760
10761        let msg = err.to_string();
10762        assert!(
10763            msg.contains("missing required field 'version'"),
10764            "error was: {msg}"
10765        );
10766    }
10767
10768    #[test]
10769    fn rejects_unknown_inference_transport() {
10770        let err = RuntimeManifest::from_yaml_str(
10771            r#"
10772name: cap
10773version: 0.0.1
10774artifacts: []
10775inference:
10776  transport: grpc
10777  model: test-model
10778  driver:
10779    artifact: murmur/driver-anthropic
10780"#,
10781        )
10782        .unwrap_err();
10783
10784        let msg = err.to_string();
10785        assert!(msg.contains("inference.transport"), "error was: {msg}");
10786        assert!(msg.contains("unknown value"), "error was: {msg}");
10787        assert!(msg.contains("grpc"), "error was: {msg}");
10788    }
10789
10790    /// The driver is what knows a harness, so `command:` alone selects nothing: a manifest that
10791    /// names only a command is refused and told which field to add.
10792    #[test]
10793    fn process_transport_with_command_and_no_driver_is_refused() {
10794        let err = RuntimeManifest::from_yaml_str(
10795            r#"
10796name: cap
10797version: 0.0.1
10798artifacts: []
10799inference:
10800  transport: process
10801  command: some-cli
10802  model: some-model
10803  max_turns: 20
10804"#,
10805        )
10806        .unwrap_err();
10807
10808        let msg = err.to_string();
10809        assert!(
10810            msg.contains("inference.driver.artifact"),
10811            "error was: {msg}"
10812        );
10813        assert!(msg.contains("inference.command"), "error was: {msg}");
10814        assert!(
10815            msg.contains("murmur/driver-claude-code"),
10816            "error was: {msg}"
10817        );
10818    }
10819
10820    #[test]
10821    fn process_transport_with_neither_driver_nor_command_is_refused() {
10822        let err = RuntimeManifest::from_yaml_str(
10823            r#"
10824name: cap
10825version: 0.0.1
10826artifacts: []
10827inference:
10828  transport: process
10829  model: some-model
10830"#,
10831        )
10832        .unwrap_err();
10833
10834        let msg = err.to_string();
10835        assert!(
10836            msg.contains("inference.driver.artifact"),
10837            "error was: {msg}"
10838        );
10839        assert!(
10840            msg.contains("murmur/driver-claude-code"),
10841            "error was: {msg}"
10842        );
10843    }
10844
10845    #[test]
10846    fn process_transport_allows_absent_model() {
10847        // model is optional for transport: process — an absent model leaves the choice to the
10848        // harness, which has an account default of its own.
10849        let manifest =
10850            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
10851                .expect("process transport should parse without a model");
10852        let inference = manifest.inference.expect("inference present");
10853        assert_eq!(inference.transport, "process");
10854        assert!(inference.command.is_none());
10855        assert_eq!(
10856            inference.model, "",
10857            "absent model resolves to empty (harness default)"
10858        );
10859    }
10860
10861    fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
10862        format!(
10863            "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n  transport: process\n  \
10864             driver:\n    artifact: local/fixture-process-driver\n{inference_extra}"
10865        )
10866    }
10867
10868    const PROCESS_DRIVER_ENTRY: &str =
10869        "  - name: local/fixture-process-driver\n    version: 0.1.0\n    runtime: driver";
10870
10871    #[test]
10872    fn process_transport_accepts_driver() {
10873        let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10874            PROCESS_DRIVER_ENTRY,
10875            "    config:\n      profile: work\n  command: /usr/local/bin/fixture-cli\n",
10876        ))
10877        .unwrap();
10878
10879        let inference = manifest.inference.expect("inference should exist");
10880        assert_eq!(inference.transport, "process");
10881        let driver = inference.driver.expect("driver should be parsed");
10882        assert_eq!(driver.artifact, "local/fixture-process-driver");
10883        assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
10884        assert_eq!(
10885            inference.command.as_deref(),
10886            Some("/usr/local/bin/fixture-cli")
10887        );
10888    }
10889
10890    #[test]
10891    fn process_transport_accepts_driver_without_command() {
10892        let manifest =
10893            RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
10894                .unwrap();
10895
10896        let inference = manifest.inference.expect("inference should exist");
10897        assert_eq!(
10898            inference.driver.map(|d| d.artifact).as_deref(),
10899            Some("local/fixture-process-driver")
10900        );
10901        assert!(inference.command.is_none());
10902        assert_eq!(inference.model, "");
10903    }
10904
10905    #[test]
10906    fn process_driver_entry_must_be_declared() {
10907        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10908            "  - name: local/other\n    version: 0.1.0\n    runtime: tool",
10909            "",
10910        ))
10911        .unwrap_err()
10912        .to_string();
10913        assert!(msg.contains("inference.driver.artifact"), "{msg}");
10914        assert!(
10915            msg.contains("artifact 'local/fixture-process-driver' is not declared in artifacts:"),
10916            "{msg}"
10917        );
10918    }
10919
10920    #[test]
10921    fn process_driver_entry_must_be_runtime_driver() {
10922        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10923            "  - name: local/fixture-process-driver\n    version: 0.1.0\n    runtime: tool",
10924            "",
10925        ))
10926        .unwrap_err()
10927        .to_string();
10928        assert!(
10929            msg.contains("must be declared with runtime: driver"),
10930            "{msg}"
10931        );
10932    }
10933
10934    #[test]
10935    fn process_driver_entry_refuses_capabilities() {
10936        let entry =
10937            format!("{PROCESS_DRIVER_ENTRY}\n    capabilities:\n      env:\n        allow: [HOME]");
10938        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
10939            .unwrap_err()
10940            .to_string();
10941        assert!(msg.contains("granted nothing"), "{msg}");
10942        assert!(msg.contains("inference.driver.config"), "{msg}");
10943    }
10944
10945    #[test]
10946    fn process_driver_entry_refuses_config() {
10947        let entry = format!("{PROCESS_DRIVER_ENTRY}\n    config:\n      profile: work");
10948        let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
10949            .unwrap_err()
10950            .to_string();
10951        assert!(msg.contains("granted nothing"), "{msg}");
10952        assert!(msg.contains("remove capabilities:/config:"), "{msg}");
10953    }
10954
10955    #[test]
10956    fn process_transport_rejects_endpoint() {
10957        let err = RuntimeManifest::from_yaml_str(
10958            r#"
10959name: cap
10960version: 0.0.1
10961artifacts: []
10962inference:
10963  transport: process
10964  command: claude
10965  model: claude-haiku-4-5-20251001
10966  endpoint: http://localhost:8080
10967"#,
10968        )
10969        .unwrap_err();
10970
10971        let msg = err.to_string();
10972        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
10973        assert!(msg.contains("was removed"), "error was: {msg}");
10974    }
10975
10976    #[test]
10977    fn process_transport_rejects_api_key() {
10978        // Key assembled at runtime so the source never contains a
10979        // credential-shaped literal that secret scanners could flag.
10980        let key = ["sk-", "ant-", "secret"].concat();
10981        let err = RuntimeManifest::from_yaml_str(&format!(
10982            r#"
10983name: cap
10984version: 0.0.1
10985artifacts: []
10986inference:
10987  transport: process
10988  command: claude
10989  model: claude-haiku-4-5-20251001
10990  api_key: {key}
10991"#
10992        ))
10993        .unwrap_err();
10994
10995        let msg = err.to_string();
10996        assert!(msg.contains("inference.api_key"), "error was: {msg}");
10997        assert!(msg.contains("was removed"), "error was: {msg}");
10998        assert!(!msg.contains(&key), "error was: {msg}");
10999    }
11000
11001    #[test]
11002    fn process_transport_rejects_max_tokens() {
11003        // Rejected even at a perfectly valid value: the CLI subprocess path never builds a
11004        // driver payload, so accepting it would leave it silently inert.
11005        let err = RuntimeManifest::from_yaml_str(
11006            r#"
11007name: cap
11008version: 0.0.1
11009artifacts: []
11010inference:
11011  transport: process
11012  command: claude
11013  model: claude-haiku-4-5-20251001
11014  max_tokens: 4096
11015"#,
11016        )
11017        .unwrap_err();
11018
11019        let msg = err.to_string();
11020        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
11021        assert!(
11022            msg.contains("not valid with transport: process"),
11023            "error was: {msg}"
11024        );
11025    }
11026
11027    /// The harness's own reported counts are what this transport meters, so the ceiling parses
11028    /// here as it does under `http`. A driver that reports no counts is refused at load, not
11029    /// here: the manifest cannot see which driver it names.
11030    #[test]
11031    fn process_transport_carries_max_session_tokens() {
11032        let manifest = RuntimeManifest::from_yaml_str(
11033            r#"
11034name: cap
11035version: 0.0.1
11036artifacts:
11037  - name: murmur/driver-claude-code
11038    version: 0.1.0
11039    runtime: driver
11040inference:
11041  transport: process
11042  driver:
11043    artifact: murmur/driver-claude-code
11044  model: claude-haiku-4-5-20251001
11045  max_session_tokens: 200000
11046"#,
11047        )
11048        .unwrap();
11049
11050        assert_eq!(
11051            manifest.inference.unwrap().max_session_tokens,
11052            Some(200_000)
11053        );
11054    }
11055
11056    #[test]
11057    fn process_transport_rejects_a_zero_max_session_tokens() {
11058        let err = RuntimeManifest::from_yaml_str(
11059            r#"
11060name: cap
11061version: 0.0.1
11062artifacts:
11063  - name: murmur/driver-claude-code
11064    version: 0.1.0
11065    runtime: driver
11066inference:
11067  transport: process
11068  driver:
11069    artifact: murmur/driver-claude-code
11070  model: claude-haiku-4-5-20251001
11071  max_session_tokens: 0
11072"#,
11073        )
11074        .unwrap_err();
11075
11076        let msg = err.to_string();
11077        assert!(
11078            msg.contains("inference.max_session_tokens"),
11079            "error was: {msg}"
11080        );
11081        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
11082    }
11083
11084    #[test]
11085    fn inference_max_session_tokens_round_trips() {
11086        let manifest = RuntimeManifest::from_yaml_str(
11087            r#"
11088name: cap
11089version: 0.0.1
11090artifacts:
11091  - name: murmur/driver-anthropic
11092    version: 0.1.0
11093    runtime: driver
11094    gateway:
11095      endpoint: https://api.anthropic.com
11096      api_key: test-key
11097inference:
11098  transport: http
11099  model: claude-opus-4-5
11100  max_session_tokens: 5000000000
11101  driver:
11102    artifact: murmur/driver-anthropic
11103"#,
11104        )
11105        .unwrap();
11106
11107        assert_eq!(
11108            manifest.inference.unwrap().max_session_tokens,
11109            Some(5_000_000_000)
11110        );
11111    }
11112
11113    #[test]
11114    fn inference_max_session_tokens_absent_is_none() {
11115        let manifest = RuntimeManifest::from_yaml_str(
11116            r#"
11117name: cap
11118version: 0.0.1
11119artifacts:
11120  - name: murmur/driver-anthropic
11121    version: 0.1.0
11122    runtime: driver
11123    gateway:
11124      endpoint: https://api.anthropic.com
11125      api_key: test-key
11126inference:
11127  transport: http
11128  model: claude-opus-4-5
11129  driver:
11130    artifact: murmur/driver-anthropic
11131"#,
11132        )
11133        .unwrap();
11134
11135        assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
11136    }
11137
11138    #[test]
11139    fn inference_max_session_tokens_zero_is_rejected() {
11140        let err = RuntimeManifest::from_yaml_str(
11141            r#"
11142name: cap
11143version: 0.0.1
11144artifacts:
11145  - name: murmur/driver-anthropic
11146    version: 0.1.0
11147    runtime: driver
11148    gateway:
11149      endpoint: https://api.anthropic.com
11150      api_key: test-key
11151inference:
11152  transport: http
11153  model: claude-opus-4-5
11154  max_session_tokens: 0
11155  driver:
11156    artifact: murmur/driver-anthropic
11157"#,
11158        )
11159        .unwrap_err();
11160
11161        let msg = err.to_string();
11162        assert!(
11163            msg.contains("inference.max_session_tokens"),
11164            "error was: {msg}"
11165        );
11166        assert!(msg.contains("greater than 0"), "error was: {msg}");
11167    }
11168
11169    /// An http capsule manifest with `extra` appended to its `inference:` block.
11170    fn http_manifest_with_inference(extra: &str) -> String {
11171        format!(
11172            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    \
11173             version: 0.1.0\n    runtime: driver\n    gateway:\n      \
11174             endpoint: https://api.anthropic.com\n      api_key: test-key\n\
11175             inference:\n  transport: http\n  model: claude-opus-4-5\n  driver:\n    \
11176             artifact: murmur/driver-anthropic\n{extra}"
11177        )
11178    }
11179
11180    #[test]
11181    fn inference_tool_refresh_absent_is_compaction() {
11182        let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
11183        assert_eq!(
11184            manifest.inference.unwrap().tool_refresh,
11185            ToolRefresh::Compaction
11186        );
11187        assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
11188    }
11189
11190    #[test]
11191    fn inference_tool_refresh_parses_both_values() {
11192        for (value, expected) in [
11193            ("compaction", ToolRefresh::Compaction),
11194            ("immediate", ToolRefresh::Immediate),
11195        ] {
11196            let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
11197                "  tool_refresh: {value}\n"
11198            )))
11199            .unwrap();
11200            assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
11201            assert_eq!(expected.wire_name(), value);
11202            assert!(
11203                manifest.unknown_keys.is_empty(),
11204                "tool_refresh is a known key: {:?}",
11205                manifest.unknown_keys
11206            );
11207        }
11208    }
11209
11210    #[test]
11211    fn inference_tool_refresh_rejects_an_unknown_value() {
11212        let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
11213            "  tool_refresh: sometimes\n",
11214        ))
11215        .unwrap_err();
11216        match err {
11217            RuntimeManifestError::InvalidInferenceConfig { field, message } => {
11218                assert_eq!(field, "inference.tool_refresh");
11219                assert_eq!(
11220                    message,
11221                    "must be one of: compaction, immediate; got 'sometimes'"
11222                );
11223            }
11224            other => panic!("unexpected error: {other:?}"),
11225        }
11226    }
11227
11228    /// Refused whatever its value: the harness owns its tool list under `transport: process`,
11229    /// so the key would be silently inert there.
11230    #[test]
11231    fn process_transport_rejects_tool_refresh() {
11232        for value in ["compaction", "immediate"] {
11233            let err = RuntimeManifest::from_yaml_str(&format!(
11234                "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  \
11235                 command: claude\n  model: claude-haiku-4-5-20251001\n  tool_refresh: {value}\n"
11236            ))
11237            .unwrap_err();
11238            match err {
11239                RuntimeManifestError::InvalidInferenceConfig { field, message } => {
11240                    assert_eq!(field, "inference.tool_refresh");
11241                    assert_eq!(message, "is not valid with transport: process");
11242                }
11243                other => panic!("unexpected error for {value}: {other:?}"),
11244            }
11245        }
11246    }
11247
11248    #[test]
11249    fn inference_max_tokens_round_trips() {
11250        let manifest = RuntimeManifest::from_yaml_str(
11251            r#"
11252name: cap
11253version: 0.0.1
11254artifacts:
11255  - name: murmur/driver-anthropic
11256    version: 0.1.0
11257    runtime: driver
11258    gateway:
11259      endpoint: https://api.anthropic.com
11260      api_key: test-key
11261inference:
11262  transport: http
11263  model: claude-opus-4-5
11264  max_tokens: 4096
11265  driver:
11266    artifact: murmur/driver-anthropic
11267"#,
11268        )
11269        .unwrap();
11270
11271        let inference = manifest.inference.unwrap();
11272        assert_eq!(inference.max_tokens, Some(4096));
11273    }
11274
11275    #[test]
11276    fn inference_max_tokens_absent_is_none() {
11277        // Absent means "the manifest didn't set it" all the way down; the runtime resolves each
11278        // driver choice's cap from its driver's `models:` table, not here.
11279        let manifest = RuntimeManifest::from_yaml_str(
11280            r#"
11281name: cap
11282version: 0.0.1
11283artifacts:
11284  - name: murmur/driver-anthropic
11285    version: 0.1.0
11286    runtime: driver
11287    gateway:
11288      endpoint: https://api.anthropic.com
11289      api_key: test-key
11290inference:
11291  transport: http
11292  model: claude-opus-4-5
11293  driver:
11294    artifact: murmur/driver-anthropic
11295"#,
11296        )
11297        .unwrap();
11298
11299        assert_eq!(manifest.inference.unwrap().max_tokens, None);
11300    }
11301
11302    #[test]
11303    fn inference_max_tokens_zero_is_rejected() {
11304        let err = RuntimeManifest::from_yaml_str(
11305            r#"
11306name: cap
11307version: 0.0.1
11308artifacts:
11309  - name: murmur/driver-anthropic
11310    version: 0.1.0
11311    runtime: driver
11312    gateway:
11313      endpoint: https://api.anthropic.com
11314      api_key: test-key
11315inference:
11316  transport: http
11317  model: claude-opus-4-5
11318  max_tokens: 0
11319  driver:
11320    artifact: murmur/driver-anthropic
11321"#,
11322        )
11323        .unwrap_err();
11324
11325        let msg = err.to_string();
11326        assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
11327        assert!(msg.contains("must be greater than 0"), "error was: {msg}");
11328    }
11329
11330    #[test]
11331    fn inference_max_tokens_large_value_is_accepted() {
11332        // Manifest validation stays advisory: an over-large cap is the provider's to reject
11333        // at request time, not ours to clamp or block at load time.
11334        let manifest = RuntimeManifest::from_yaml_str(
11335            r#"
11336name: cap
11337version: 0.0.1
11338artifacts:
11339  - name: murmur/driver-anthropic
11340    version: 0.1.0
11341    runtime: driver
11342    gateway:
11343      endpoint: https://api.anthropic.com
11344      api_key: test-key
11345inference:
11346  transport: http
11347  model: claude-opus-4-5
11348  max_tokens: 999999
11349  driver:
11350    artifact: murmur/driver-anthropic
11351"#,
11352        )
11353        .unwrap();
11354
11355        assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
11356    }
11357
11358    #[test]
11359    fn inference_and_context_max_tokens_are_independent() {
11360        // Two unrelated concepts that happen to share a leaf name: per-turn output cap vs.
11361        // session compaction budget. Neither may leak into the other.
11362        let manifest = RuntimeManifest::from_yaml_str(
11363            r#"
11364name: cap
11365version: 0.0.1
11366artifacts:
11367  - name: murmur/driver-anthropic
11368    version: 0.1.0
11369    runtime: driver
11370    gateway:
11371      endpoint: https://api.anthropic.com
11372      api_key: test-key
11373inference:
11374  transport: http
11375  model: claude-opus-4-5
11376  max_tokens: 4096
11377  driver:
11378    artifact: murmur/driver-anthropic
11379context:
11380  max_tokens: 200000
11381"#,
11382        )
11383        .unwrap();
11384
11385        assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
11386        assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
11387    }
11388
11389    #[test]
11390    fn hook_config_defaults_when_fields_absent() {
11391        let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
11392        assert_eq!(config.binding, HookBinding::All);
11393        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
11394        assert_eq!(config.commit_policy, HookCommitPolicy::None);
11395    }
11396
11397    #[test]
11398    fn hook_config_parses_all_fields() {
11399        let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
11400        let config = parse_hook_config_from_yaml(yaml).unwrap();
11401        assert_eq!(config.binding, HookBinding::OnCompaction);
11402        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
11403        assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
11404    }
11405
11406    #[test]
11407    fn hook_config_parses_on_task_start() {
11408        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
11409        let config = parse_hook_config_from_yaml(yaml).unwrap();
11410        assert_eq!(config.binding, HookBinding::OnTaskStart);
11411    }
11412
11413    #[test]
11414    fn hook_config_parses_on_task_end() {
11415        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
11416        let config = parse_hook_config_from_yaml(yaml).unwrap();
11417        assert_eq!(config.binding, HookBinding::OnTaskEnd);
11418    }
11419
11420    #[test]
11421    fn hook_config_unknown_binding_lists_task_events() {
11422        let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
11423        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11424        assert!(err.contains("on-task-start"), "error was: {err}");
11425        assert!(err.contains("on-task-end"), "error was: {err}");
11426    }
11427
11428    #[test]
11429    fn hook_config_async_with_commit_is_rejected() {
11430        let yaml =
11431            "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
11432        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11433        assert!(err.contains("async-with-commit"), "error was: {err}");
11434    }
11435
11436    #[test]
11437    fn hook_config_on_stage_async_is_rejected() {
11438        let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
11439        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11440        assert!(err.contains("on-stage"), "error was: {err}");
11441        assert!(err.contains("blocking"), "error was: {err}");
11442    }
11443
11444    #[test]
11445    fn hook_config_async_none_is_valid() {
11446        let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
11447        let config = parse_hook_config_from_yaml(yaml).unwrap();
11448        assert_eq!(config.execution_mode, HookExecutionMode::Async);
11449        assert_eq!(config.commit_policy, HookCommitPolicy::None);
11450    }
11451
11452    #[test]
11453    fn hook_config_parses_reopen_task_commit_policy() {
11454        let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
11455        let config = parse_hook_config_from_yaml(yaml).unwrap();
11456        assert_eq!(config.binding, HookBinding::OnTaskEnd);
11457        assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
11458    }
11459
11460    #[test]
11461    fn hook_config_unknown_commit_policy_lists_reopen_task() {
11462        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11463        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11464        assert!(err.contains("reopen-task"), "error was: {err}");
11465    }
11466
11467    #[test]
11468    fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
11469        for binding in ["on-shell", "on-tool-call"] {
11470            let yaml =
11471                format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
11472            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11473            assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
11474        }
11475        assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
11476    }
11477
11478    /// `deny` is the one policy an omitted `binding:` cannot carry: a hook that does not name
11479    /// which of the two gated events it decides on would be asked at both.
11480    #[test]
11481    fn hook_config_deny_without_a_binding_is_rejected() {
11482        let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
11483        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11484        assert!(
11485            err.contains("requires an explicit binding"),
11486            "error was: {err}"
11487        );
11488        assert!(err.contains("on-shell"), "error was: {err}");
11489        assert!(err.contains("on-tool-call"), "error was: {err}");
11490    }
11491
11492    /// Every other binding refuses `deny` through the existing binding-mismatch check.
11493    #[test]
11494    fn hook_config_deny_on_an_ungated_binding_is_rejected() {
11495        let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
11496        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11497        assert!(err.contains("is not valid for binding"), "error was: {err}");
11498        assert!(err.contains("replace-context"), "error was: {err}");
11499    }
11500
11501    /// The unchanged async/commit check covers `deny` for free; this pins that it does.
11502    #[test]
11503    fn hook_config_async_deny_is_rejected() {
11504        let yaml =
11505            "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
11506        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11507        assert!(err.contains("async-with-commit"), "error was: {err}");
11508        assert!(err.contains("deny"), "error was: {err}");
11509    }
11510
11511    #[test]
11512    fn hook_config_unknown_commit_policy_lists_deny() {
11513        let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11514        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11515        assert!(err.contains("deny"), "error was: {err}");
11516    }
11517
11518    /// The existing, unmodified async/commit validation must also reject
11519    /// `commit_policy: reopen-task` combined with `execution_mode: async`.
11520    #[test]
11521    fn hook_config_async_reopen_task_is_rejected() {
11522        let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
11523        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11524        assert!(err.contains("async-with-commit"), "error was: {err}");
11525        assert!(err.contains("reopen-task"), "error was: {err}");
11526    }
11527
11528    // ── binding is the single source of truth for what a hook commits ─────────────
11529
11530    /// The two bindings whose events honor no `hook-output` arm at all cannot declare
11531    /// any non-`none` `commit_policy`. The error names the binding, the declared policy,
11532    /// and `none` as what the binding honors.
11533    #[test]
11534    fn hook_config_non_committing_binding_rejects_any_commit_policy() {
11535        for binding in ["on-session-start", "on-session-end"] {
11536            let yaml = format!(
11537                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
11538            );
11539            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11540            assert!(err.contains(binding), "error was: {err}");
11541            assert!(err.contains("replace-context"), "error was: {err}");
11542            assert!(
11543                err.contains("honors commit_policy 'none'"),
11544                "error was: {err}"
11545            );
11546        }
11547    }
11548
11549    /// A binding that honors one arm rejects every *other* policy, naming the one it does
11550    /// honor so the fix is obvious from the message alone.
11551    #[test]
11552    fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
11553        for (binding, declared, honored) in [
11554            ("on-stage", "reopen-task", "write-manifests"),
11555            ("on-compaction", "write-manifests", "replace-context"),
11556            ("on-task-end", "replace-context", "reopen-task"),
11557            ("on-task-start", "replace-context", "seed-context"),
11558            ("on-shell", "replace-context", "deny"),
11559            ("on-tool-call", "replace-context", "deny"),
11560        ] {
11561            let yaml = format!(
11562                "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
11563            );
11564            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11565            assert!(err.contains(binding), "error was: {err}");
11566            assert!(
11567                err.contains(&format!("commit_policy '{declared}' is not valid")),
11568                "error was: {err}"
11569            );
11570            assert!(
11571                err.contains(&format!("honors commit_policy '{honored}'")),
11572                "error was: {err}"
11573            );
11574        }
11575    }
11576
11577    /// `on-inference` honors the `artifact` arm, which has no `commit_policy` spelling —
11578    /// so *no* non-`none` policy is ever valid for it, including `write-manifests`.
11579    #[test]
11580    fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
11581        for declared in ["replace-context", "write-manifests", "reopen-task"] {
11582            let yaml = format!(
11583                "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
11584            );
11585            let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11586            assert!(err.contains("on-inference"), "error was: {err}");
11587            assert!(err.contains(declared), "error was: {err}");
11588            assert!(
11589                err.contains("honors commit_policy 'none'"),
11590                "error was: {err}"
11591            );
11592            assert!(err.contains("artifact"), "error was: {err}");
11593        }
11594    }
11595
11596    /// A binding declaring exactly the policy it honors is accepted, for every
11597    /// representable pair.
11598    #[test]
11599    fn hook_config_matching_binding_and_commit_policy_is_valid() {
11600        for (binding, policy, expected) in [
11601            (
11602                "on-stage",
11603                "write-manifests",
11604                HookCommitPolicy::WriteManifests,
11605            ),
11606            (
11607                "on-compaction",
11608                "replace-context",
11609                HookCommitPolicy::ReplaceContext,
11610            ),
11611            ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
11612            ("on-shell", "deny", HookCommitPolicy::Deny),
11613            ("on-tool-call", "deny", HookCommitPolicy::Deny),
11614        ] {
11615            let yaml =
11616                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
11617            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11618            assert_eq!(config.commit_policy, expected);
11619        }
11620    }
11621
11622    /// Every binding may declare `commit_policy: none` — including the ones that honor
11623    /// an arm, which simply means the hook opts out of committing.
11624    #[test]
11625    fn hook_config_commit_policy_none_is_valid_for_every_binding() {
11626        for binding in [
11627            "on-stage",
11628            "on-session-start",
11629            "on-task-start",
11630            "on-inference",
11631            "on-tool-call",
11632            "on-shell",
11633            "on-compaction",
11634            "on-task-end",
11635            "on-session-end",
11636        ] {
11637            let yaml =
11638                format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
11639            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11640            assert_eq!(config.commit_policy, HookCommitPolicy::None);
11641        }
11642    }
11643
11644    /// Resolved ambiguity: an omitted `binding:` is `HookBinding::All`, which is dispatched
11645    /// to every event — including every one that honors an arm — so every `commit_policy` is
11646    /// accepted for it. Deliberate: no narrowing for `All`. `deny` is the one exception,
11647    /// covered by [`hook_config_deny_without_a_binding_is_rejected`].
11648    #[test]
11649    fn hook_config_omitted_binding_accepts_every_commit_policy() {
11650        for (policy, expected) in [
11651            ("replace-context", HookCommitPolicy::ReplaceContext),
11652            ("write-manifests", HookCommitPolicy::WriteManifests),
11653            ("reopen-task", HookCommitPolicy::ReopenTask),
11654            ("none", HookCommitPolicy::None),
11655        ] {
11656            let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
11657            let config = parse_hook_config_from_yaml(&yaml).unwrap();
11658            assert_eq!(config.binding, HookBinding::All);
11659            assert_eq!(config.commit_policy, expected);
11660        }
11661    }
11662
11663    /// Every hook artifact shipped in `default-artifacts` today must keep parsing after the
11664    /// binding/commit_policy check exists — this change is non-breaking for all eight. The
11665    /// field combinations are reproduced literally rather than read from that repo, so this
11666    /// suite stays self-contained.
11667    #[test]
11668    fn hook_config_shipped_default_artifact_manifests_still_parse() {
11669        let shipped: [(&str, &str, HookConfig); 8] = [
11670            (
11671                "murmur-hook-compact",
11672                "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
11673                HookConfig {
11674                    binding: HookBinding::OnCompaction,
11675                    execution_mode: HookExecutionMode::Blocking,
11676                    commit_policy: HookCommitPolicy::ReplaceContext,
11677                },
11678            ),
11679            (
11680                "murmur-hook-shell-desc",
11681                "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
11682                HookConfig {
11683                    binding: HookBinding::OnStage,
11684                    execution_mode: HookExecutionMode::Blocking,
11685                    commit_policy: HookCommitPolicy::WriteManifests,
11686                },
11687            ),
11688            (
11689                // No `binding:` line in the shipped manifest — `All`, not `on-task-end`.
11690                "murmur-hook-regression-verifier",
11691                "execution_mode: blocking\ncommit_policy: reopen-task\n",
11692                HookConfig {
11693                    binding: HookBinding::All,
11694                    execution_mode: HookExecutionMode::Blocking,
11695                    commit_policy: HookCommitPolicy::ReopenTask,
11696                },
11697            ),
11698            (
11699                "murmur-hook-memory",
11700                "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
11701                HookConfig {
11702                    binding: HookBinding::OnTaskStart,
11703                    execution_mode: HookExecutionMode::Blocking,
11704                    commit_policy: HookCommitPolicy::SeedContext,
11705                },
11706            ),
11707            (
11708                "murmur-hook-debug",
11709                "execution_mode: async\ncommit_policy: none\n",
11710                HookConfig {
11711                    binding: HookBinding::All,
11712                    execution_mode: HookExecutionMode::Async,
11713                    commit_policy: HookCommitPolicy::None,
11714                },
11715            ),
11716            (
11717                "murmur-hook-diff-summary",
11718                "execution_mode: blocking\ncommit_policy: none\n",
11719                HookConfig {
11720                    binding: HookBinding::All,
11721                    execution_mode: HookExecutionMode::Blocking,
11722                    commit_policy: HookCommitPolicy::None,
11723                },
11724            ),
11725            ("murmur-hook-eval", "", HookConfig::default()),
11726            ("murmur-hook-grafana", "", HookConfig::default()),
11727        ];
11728
11729        for (name, fields, expected) in shipped {
11730            let yaml = format!("name: {name}\nruntime: hook\n{fields}");
11731            let config = parse_hook_config_from_yaml(&yaml)
11732                .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
11733            assert_eq!(config, expected, "shipped hook {name}");
11734        }
11735    }
11736
11737    /// The manifest-side honored-policy table agrees with the manifest spellings the
11738    /// parser accepts; `capsule-runtime` separately cross-checks it against its own
11739    /// `HONORED_OUTPUT_ARM` dispatch table.
11740    #[test]
11741    fn commit_policy_for_binding_matches_the_declarable_pairs() {
11742        assert_eq!(
11743            commit_policy_for_binding(&HookBinding::OnStage),
11744            Some(HookCommitPolicy::WriteManifests)
11745        );
11746        assert_eq!(
11747            commit_policy_for_binding(&HookBinding::OnCompaction),
11748            Some(HookCommitPolicy::ReplaceContext)
11749        );
11750        assert_eq!(
11751            commit_policy_for_binding(&HookBinding::OnTaskEnd),
11752            Some(HookCommitPolicy::ReopenTask)
11753        );
11754        assert_eq!(
11755            commit_policy_for_binding(&HookBinding::OnTaskStart),
11756            Some(HookCommitPolicy::SeedContext)
11757        );
11758        assert_eq!(
11759            commit_policy_for_binding(&HookBinding::OnToolCall),
11760            Some(HookCommitPolicy::Deny)
11761        );
11762        assert_eq!(
11763            commit_policy_for_binding(&HookBinding::OnShell),
11764            Some(HookCommitPolicy::Deny)
11765        );
11766        // `on-inference` honors `artifact`, which has no `commit_policy` spelling; the
11767        // other two honor nothing. `All` is unconstrained by design, apart from the `deny`
11768        // carve-out the parser applies.
11769        for binding in [
11770            HookBinding::OnSessionStart,
11771            HookBinding::OnInference,
11772            HookBinding::OnSessionEnd,
11773            HookBinding::All,
11774        ] {
11775            assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
11776        }
11777    }
11778
11779    /// The `on-task-start` pairing is declarable end to end: the parser accepts it, and
11780    /// refuses the same policy on any other binding by name.
11781    #[test]
11782    fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
11783        let config = parse_hook_config_from_yaml(
11784            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
11785             commit_policy: seed-context\n",
11786        )
11787        .expect("on-task-start + seed-context is the declarable pairing");
11788        assert_eq!(config.binding, HookBinding::OnTaskStart);
11789        assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
11790
11791        let error = parse_hook_config_from_yaml(
11792            "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
11793             commit_policy: seed-context\n",
11794        )
11795        .expect_err("on-compaction cannot commit a seed");
11796        assert!(error.contains("on-compaction"), "{error}");
11797        assert!(error.contains("seed-context"), "{error}");
11798    }
11799
11800    /// The unknown-value diagnostic lists every accepted spelling, so an operator who
11801    /// mistypes one reads the whole set rather than guessing.
11802    #[test]
11803    fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
11804        let error = parse_hook_config_from_yaml(
11805            "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
11806        )
11807        .expect_err("a misspelled policy is rejected");
11808        assert!(error.contains("seed-context"), "{error}");
11809    }
11810
11811    // ── context.seed_budget / context.seed_overflow_margin ───────────────────
11812
11813    fn context_of(yaml_block: &str) -> Option<ContextConfig> {
11814        RuntimeManifest::from_yaml_str(&format!(
11815            "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
11816        ))
11817        .expect("the manifest under test parses")
11818        .context
11819    }
11820
11821    #[test]
11822    fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
11823        let context = context_of(
11824            "context:\n  max_tokens: 200000\n  seed_budget: 0.10\n  seed_overflow_margin: 0.10\n",
11825        )
11826        .expect("a declared context block parses to Some");
11827        assert_eq!(context.max_tokens, Some(200_000));
11828        assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
11829        assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
11830    }
11831
11832    #[test]
11833    fn seed_budget_and_overflow_margin_default_when_omitted() {
11834        let context = context_of("context:\n  max_tokens: 200000\n")
11835            .expect("a declared context block parses to Some");
11836        assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
11837        assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
11838    }
11839
11840    /// No `context:` block at all stays `None` — the two fractions default *within* a
11841    /// declared block, and never conjure one that the operator did not write.
11842    #[test]
11843    fn seed_budget_defaults_do_not_create_a_context_block() {
11844        assert_eq!(context_of(""), None);
11845    }
11846
11847    #[test]
11848    fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
11849        for (block, field) in [
11850            ("context:\n  seed_budget: 1.5\n", "context.seed_budget"),
11851            ("context:\n  seed_budget: -0.1\n", "context.seed_budget"),
11852            (
11853                "context:\n  seed_overflow_margin: 2.0\n",
11854                "context.seed_overflow_margin",
11855            ),
11856        ] {
11857            let error = RuntimeManifest::from_yaml_str(&format!(
11858                "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
11859            ))
11860            .expect_err("an out-of-range fraction is rejected");
11861            match error {
11862                RuntimeManifestError::InvalidInferenceConfig {
11863                    field: got,
11864                    ref message,
11865                } => {
11866                    assert_eq!(got, field);
11867                    assert!(message.contains("0.0 and 1.0"), "{message}");
11868                }
11869                other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
11870            }
11871        }
11872    }
11873
11874    #[test]
11875    fn parse_tool_implementation_defaults_to_wasm() {
11876        let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
11877        assert_eq!(impl_, ArtifactImplementation::Wasm);
11878    }
11879
11880    #[test]
11881    fn parse_tool_implementation_native() {
11882        let impl_ = parse_tool_implementation_from_yaml(
11883            "name: my-tool\nruntime: tool\nimplementation: native\n",
11884        );
11885        assert_eq!(impl_, ArtifactImplementation::Native);
11886    }
11887
11888    #[test]
11889    fn deploy_block_mur_version_parses() {
11890        let manifest = RuntimeManifest::from_yaml_str(
11891            "name: cap\nversion: 0.1.0\nartifacts: []\ndeploy:\n  mur_version: \"0.4.5\"\n",
11892        )
11893        .unwrap();
11894        assert_eq!(manifest.deploy_mur_version(), Some("0.4.5"));
11895        assert_eq!(
11896            manifest.deploy,
11897            Some(DeployConfig {
11898                mur_version: Some("0.4.5".to_string())
11899            })
11900        );
11901    }
11902
11903    #[test]
11904    fn deploy_block_absent_is_none() {
11905        let manifest =
11906            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
11907        assert_eq!(manifest.deploy, None);
11908        assert_eq!(manifest.deploy_mur_version(), None);
11909    }
11910
11911    /// A blank pin and an empty block both mean "install the deploying mur's own version".
11912    #[test]
11913    fn deploy_block_blank_mur_version_is_none() {
11914        for deploy in [
11915            "deploy:\n  mur_version: \"\"\n",
11916            "deploy:\n  mur_version: \"  \"\n",
11917        ] {
11918            let manifest = RuntimeManifest::from_yaml_str(&format!(
11919                "name: cap\nversion: 0.1.0\nartifacts: []\n{deploy}"
11920            ))
11921            .unwrap();
11922            assert_eq!(manifest.deploy_mur_version(), None, "{deploy:?}");
11923        }
11924        let manifest =
11925            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\ndeploy: {}\n").unwrap();
11926        assert_eq!(manifest.deploy, Some(DeployConfig::default()));
11927        assert_eq!(manifest.deploy_mur_version(), None);
11928    }
11929
11930    #[test]
11931    fn deploy_block_misspelled_key_is_reported_under_deploy() {
11932        assert_eq!(
11933            unknown_keys_of("name: cap\nversion: 0.1.0\ndeploy:\n  mur_versoin: \"0.4.5\"\n"),
11934            vec![(
11935                "mur_versoin".to_string(),
11936                "deploy".to_string(),
11937                Some("mur_version".to_string())
11938            )]
11939        );
11940    }
11941
11942    #[test]
11943    fn a_top_level_mur_version_is_refused_naming_deploy_mur_version() {
11944        for pin in [
11945            "mur_version: \"0.4.5\"\n",
11946            "mur_version: 0.4.0\n",
11947            "mur_version: \"\"\n",
11948            "mur_version:\n",
11949        ] {
11950            let error = RuntimeManifest::from_yaml_str(&format!(
11951                "name: cap\nversion: 0.1.0\nartifacts: []\n{pin}"
11952            ))
11953            .expect_err(pin);
11954            assert!(
11955                matches!(error, RuntimeManifestError::TopLevelMurVersion),
11956                "{pin:?}: {error:?}"
11957            );
11958            assert!(error.to_string().contains("deploy.mur_version"), "{error}");
11959        }
11960        assert_eq!(
11961            RuntimeManifestError::TopLevelMurVersion.to_string(),
11962            "murmur.yaml: 'mur_version' is not a top-level key — the mur release a deploy \
11963             installs is pinned as 'deploy.mur_version'"
11964        );
11965    }
11966
11967    #[test]
11968    fn a_top_level_mur_version_is_refused_beside_a_deploy_block() {
11969        let yaml = "name: cap\nversion: 0.1.0\nmur_version: \"0.4.5\"\ndeploy:\n  mur_version: \"0.4.5\"\n";
11970        assert!(matches!(
11971            RuntimeManifest::from_yaml_str(yaml),
11972            Err(RuntimeManifestError::TopLevelMurVersion)
11973        ));
11974        assert!(matches!(
11975            RuntimeManifest::from_yaml_str_without_secrets(yaml),
11976            Err(RuntimeManifestError::TopLevelMurVersion)
11977        ));
11978    }
11979
11980    #[test]
11981    fn inference_max_turns_absent_sets_no_cap() {
11982        let manifest = RuntimeManifest::from_yaml_str(
11983            "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",
11984        ).unwrap();
11985        assert_eq!(manifest.inference.unwrap().max_turns, None);
11986    }
11987
11988    #[test]
11989    fn inference_max_turns_explicit_value() {
11990        let manifest = RuntimeManifest::from_yaml_str(
11991            "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",
11992        ).unwrap();
11993        assert_eq!(manifest.inference.unwrap().max_turns, Some(20));
11994    }
11995
11996    #[test]
11997    fn inference_max_turns_zero_is_rejected() {
11998        let err = RuntimeManifest::from_yaml_str(
11999            "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",
12000        ).unwrap_err();
12001        let msg = err.to_string();
12002        assert!(msg.contains("inference.max_turns"), "error was: {msg}");
12003        assert!(msg.contains("greater than 0"), "error was: {msg}");
12004    }
12005
12006    /// A `lifecycle:` block that says nothing about the budget still permits one reopen.
12007    #[test]
12008    fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
12009        let manifest = RuntimeManifest::from_yaml_str(
12010            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 45\n",
12011        )
12012        .unwrap();
12013        let lifecycle = manifest.lifecycle.unwrap();
12014        assert_eq!(lifecycle.shell_grace_secs, Some(45));
12015        // Every other lifecycle field is untouched by declaring this one.
12016        assert_eq!(
12017            lifecycle.task_acceptance,
12018            LifecycleConfig::default().task_acceptance
12019        );
12020        assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
12021        assert_eq!(
12022            lifecycle.queue_depth,
12023            LifecycleConfig::default().queue_depth
12024        );
12025        assert_eq!(lifecycle.input_timeout_secs, None);
12026        assert_eq!(
12027            lifecycle.conversation_mode,
12028            LifecycleConfig::default().conversation_mode
12029        );
12030        assert_eq!(
12031            lifecycle.max_task_reopens,
12032            LifecycleConfig::default().max_task_reopens
12033        );
12034    }
12035
12036    /// An undeclared grace is no grace at all, whether the `lifecycle:` block is there or not.
12037    #[test]
12038    fn lifecycle_shell_grace_secs_is_unset_when_not_declared() {
12039        let manifest = RuntimeManifest::from_yaml_str(
12040            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12041        )
12042        .unwrap();
12043        let lifecycle = manifest.lifecycle.unwrap();
12044        assert_eq!(lifecycle.shell_grace_secs, None);
12045        assert_eq!(lifecycle.queue_depth, 3);
12046
12047        let without_block =
12048            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12049        assert_eq!(without_block.effective_lifecycle().shell_grace_secs, None);
12050        assert_eq!(LifecycleConfig::default().shell_grace_secs, None);
12051    }
12052
12053    /// The bound a capsule delegates under is declared, and a capsule that declares nothing is
12054    /// still bounded: absent means a ceiling, never "wait forever".
12055    #[test]
12056    fn lifecycle_delegation_deadline_secs_defaults_to_600() {
12057        let without_block =
12058            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12059        assert_eq!(
12060            without_block.effective_lifecycle().delegation_deadline_secs,
12061            600
12062        );
12063
12064        let without_key = RuntimeManifest::from_yaml_str(
12065            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12066        )
12067        .unwrap();
12068        let lifecycle = without_key.lifecycle.unwrap();
12069        assert_eq!(lifecycle.delegation_deadline_secs, 600);
12070        assert_eq!(lifecycle.queue_depth, 3);
12071    }
12072
12073    #[test]
12074    fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
12075        let manifest = RuntimeManifest::from_yaml_str(
12076            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
12077             delegation_deadline_secs: 30\n",
12078        )
12079        .unwrap();
12080        let lifecycle = manifest.lifecycle.unwrap();
12081        assert_eq!(lifecycle.delegation_deadline_secs, 30);
12082        // Every other lifecycle field is untouched by declaring this one.
12083        assert_eq!(
12084            lifecycle.shell_grace_secs,
12085            LifecycleConfig::default().shell_grace_secs
12086        );
12087        assert_eq!(
12088            lifecycle.max_task_reopens,
12089            LifecycleConfig::default().max_task_reopens
12090        );
12091    }
12092
12093    /// Declaring the key produces no unknown-key report, and a misspelling of it still does.
12094    #[test]
12095    fn lifecycle_delegation_deadline_secs_is_a_known_key() {
12096        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
12097        let manifest = RuntimeManifest::from_yaml_str(
12098            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
12099             delegation_deadline_secs: 30\n  delegation_deadline: 30\n",
12100        )
12101        .unwrap();
12102        let reported: Vec<(String, String)> = manifest
12103            .unknown_keys
12104            .iter()
12105            .map(|key| (key.key.clone(), key.block_path.clone()))
12106            .collect();
12107        assert_eq!(
12108            reported,
12109            vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
12110            "the declared key is known and only the misspelt neighbour is reported"
12111        );
12112        assert_eq!(
12113            manifest.lifecycle.unwrap().delegation_deadline_secs,
12114            30,
12115            "the misspelt neighbour does not disturb the declared value"
12116        );
12117    }
12118
12119    /// The deadline notice's margin is optional: absent leaves the runtime's default in force.
12120    #[test]
12121    fn lifecycle_deadline_notice_secs_defaults_to_none() {
12122        let without_block =
12123            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12124        assert_eq!(
12125            without_block.effective_lifecycle().deadline_notice_secs,
12126            None
12127        );
12128        let without_key = RuntimeManifest::from_yaml_str(
12129            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12130        )
12131        .unwrap();
12132        assert_eq!(without_key.lifecycle.unwrap().deadline_notice_secs, None);
12133    }
12134
12135    /// `0` and a positive value are both taken as declared: `0` is "send no notice", not absent.
12136    #[test]
12137    fn lifecycle_deadline_notice_secs_takes_zero_and_positive_values() {
12138        for (declared, expected) in [("0", 0), ("90", 90)] {
12139            let manifest = RuntimeManifest::from_yaml_str(&format!(
12140                "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
12141                 deadline_notice_secs: {declared}\n"
12142            ))
12143            .unwrap();
12144            assert!(manifest.unknown_keys.is_empty());
12145            assert_eq!(
12146                manifest.lifecycle.unwrap().deadline_notice_secs,
12147                Some(expected)
12148            );
12149        }
12150    }
12151
12152    #[test]
12153    fn lifecycle_deadline_notice_secs_refuses_a_negative_or_non_integer_value() {
12154        for declared in ["-5", "1.5", "soon"] {
12155            let refused = RuntimeManifest::from_yaml_str(&format!(
12156                "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  \
12157                 deadline_notice_secs: {declared}\n"
12158            ));
12159            assert!(refused.is_err(), "{declared} is refused: {refused:?}");
12160        }
12161    }
12162
12163    #[test]
12164    fn lifecycle_deadline_notice_secs_is_a_known_key() {
12165        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"deadline_notice_secs"));
12166    }
12167
12168    fn lifecycle_with_keep_working(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
12169        RuntimeManifest::from_yaml_str(&format!(
12170            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  keep_working:\n{block}"
12171        ))
12172    }
12173
12174    fn refused_keep_working_field(block: &str) -> (String, String) {
12175        match lifecycle_with_keep_working(block) {
12176            Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
12177                (field, message)
12178            }
12179            other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
12180        }
12181    }
12182
12183    #[test]
12184    fn lifecycle_keep_working_is_off_when_absent() {
12185        let without_block =
12186            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12187        assert_eq!(without_block.effective_lifecycle().keep_working, None);
12188        let without_key = RuntimeManifest::from_yaml_str(
12189            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12190        )
12191        .unwrap();
12192        assert_eq!(without_key.lifecycle.unwrap().keep_working, None);
12193    }
12194
12195    #[test]
12196    fn lifecycle_keep_working_takes_the_declared_message_and_limit() {
12197        let manifest =
12198            lifecycle_with_keep_working("    message: \"go on\"\n    limit: 3\n").unwrap();
12199        assert!(
12200            manifest.unknown_keys.is_empty(),
12201            "{:?}",
12202            manifest.unknown_keys
12203        );
12204        assert_eq!(
12205            manifest.lifecycle.unwrap().keep_working,
12206            Some(KeepWorking {
12207                message: "go on".into(),
12208                limit: 3
12209            })
12210        );
12211    }
12212
12213    /// The blank check trims; the message the runtime sends is the operator's bytes as written.
12214    #[test]
12215    fn lifecycle_keep_working_keeps_the_message_as_written() {
12216        let manifest =
12217            lifecycle_with_keep_working("    message: \"  go on \\n\"\n    limit: 1\n").unwrap();
12218        assert_eq!(
12219            manifest.lifecycle.unwrap().keep_working.unwrap().message,
12220            "  go on \n"
12221        );
12222    }
12223
12224    #[test]
12225    fn lifecycle_keep_working_refuses_a_missing_or_blank_message() {
12226        for block in [
12227            "    limit: 3\n",
12228            "    message: \"\"\n    limit: 3\n",
12229            "    message: \"  \\n\\t \"\n    limit: 3\n",
12230        ] {
12231            let (field, _) = refused_keep_working_field(block);
12232            assert_eq!(field, "lifecycle.keep_working.message", "{block:?}");
12233        }
12234    }
12235
12236    #[test]
12237    fn lifecycle_keep_working_refuses_a_missing_limit() {
12238        let (field, message) = refused_keep_working_field("    message: go on\n");
12239        assert_eq!(field, "lifecycle.keep_working.limit");
12240        assert_eq!(message, "is required");
12241    }
12242
12243    #[test]
12244    fn lifecycle_keep_working_refuses_a_zero_limit_and_says_how_to_turn_it_off() {
12245        let (field, message) = refused_keep_working_field("    message: go on\n    limit: 0\n");
12246        assert_eq!(field, "lifecycle.keep_working.limit");
12247        assert!(
12248            message.contains("omit lifecycle.keep_working"),
12249            "the refusal says how to turn the option off: {message}"
12250        );
12251    }
12252
12253    #[test]
12254    fn lifecycle_keep_working_reports_an_unknown_key_inside_the_block() {
12255        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"keep_working"));
12256        let manifest =
12257            lifecycle_with_keep_working("    message: go on\n    limit: 2\n    lmit: 3\n").unwrap();
12258        let reported: Vec<(String, String)> = manifest
12259            .unknown_keys
12260            .iter()
12261            .map(|key| (key.key.clone(), key.block_path.clone()))
12262            .collect();
12263        assert_eq!(
12264            reported,
12265            vec![("lmit".to_string(), "lifecycle.keep_working".to_string())]
12266        );
12267    }
12268
12269    fn lifecycle_with_done_check(
12270        allow: &str,
12271        block: &str,
12272    ) -> Result<RuntimeManifest, RuntimeManifestError> {
12273        RuntimeManifest::from_yaml_str(&format!(
12274            "name: cap\nversion: 0.0.1\nartifacts: []\n{allow}lifecycle:\n  done_check:\n{block}"
12275        ))
12276    }
12277
12278    const ALLOW_BASH: &str = "capabilities:\n  shell:\n    allow: [bash]\n";
12279
12280    fn refused_done_check_field(allow: &str, block: &str) -> (String, String) {
12281        match lifecycle_with_done_check(allow, block) {
12282            Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
12283                (field, message)
12284            }
12285            other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
12286        }
12287    }
12288
12289    #[test]
12290    fn lifecycle_done_check_is_off_when_absent() {
12291        let without_block =
12292            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12293        assert_eq!(without_block.effective_lifecycle().done_check, None);
12294        let without_key = RuntimeManifest::from_yaml_str(
12295            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  queue_depth: 3\n",
12296        )
12297        .unwrap();
12298        assert_eq!(without_key.lifecycle.unwrap().done_check, None);
12299    }
12300
12301    #[test]
12302    fn lifecycle_done_check_takes_the_command_and_limit_with_the_default_timeout() {
12303        let manifest = lifecycle_with_done_check(
12304            ALLOW_BASH,
12305            "    command: \"bash -c 'true'\"\n    limit: 2\n",
12306        )
12307        .unwrap();
12308        assert!(
12309            manifest.unknown_keys.is_empty(),
12310            "{:?}",
12311            manifest.unknown_keys
12312        );
12313        let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
12314        assert_eq!(
12315            done_check,
12316            DoneCheck {
12317                command: "bash -c 'true'".into(),
12318                limit: 2,
12319                timeout_secs: 600,
12320            }
12321        );
12322        assert_eq!(done_check.argv(), ["bash", "-c", "true"]);
12323    }
12324
12325    /// `0` observes without reopening; an explicit timeout is kept.
12326    #[test]
12327    fn lifecycle_done_check_accepts_a_zero_limit_and_an_explicit_timeout() {
12328        let manifest = lifecycle_with_done_check(
12329            ALLOW_BASH,
12330            "    command: bash -c 'exit 1'\n    limit: 0\n    timeout_secs: 5\n",
12331        )
12332        .unwrap();
12333        let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
12334        assert_eq!((done_check.limit, done_check.timeout_secs), (0, 5));
12335    }
12336
12337    #[test]
12338    fn lifecycle_done_check_refuses_each_invalid_field() {
12339        for (allow, block, field, message) in [
12340            (ALLOW_BASH, "    limit: 1\n", "command", "is required"),
12341            (
12342                ALLOW_BASH,
12343                "    command: \"  \"\n    limit: 1\n",
12344                "command",
12345                "must not be blank",
12346            ),
12347            (
12348                ALLOW_BASH,
12349                "    command: \"''\"\n    limit: 1\n",
12350                "command",
12351                "must name a program",
12352            ),
12353            (
12354                ALLOW_BASH,
12355                "    command: cargo test\n    limit: 1\n",
12356                "command",
12357                "runs 'cargo', which is not in capabilities.shell.allow",
12358            ),
12359            (
12360                "",
12361                "    command: bash -c true\n    limit: 1\n",
12362                "command",
12363                "runs 'bash', which is not in capabilities.shell.allow",
12364            ),
12365            (
12366                ALLOW_BASH,
12367                "    command: bash -c true\n",
12368                "limit",
12369                "is required",
12370            ),
12371            (
12372                ALLOW_BASH,
12373                "    command: bash -c true\n    limit: 1\n    timeout_secs: 0\n",
12374                "timeout_secs",
12375                "must be at least 1",
12376            ),
12377        ] {
12378            assert_eq!(
12379                refused_done_check_field(allow, block),
12380                (format!("lifecycle.done_check.{field}"), message.to_string()),
12381                "{block:?}"
12382            );
12383        }
12384    }
12385
12386    #[test]
12387    fn lifecycle_done_check_reports_an_unknown_key_inside_the_block() {
12388        assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"done_check"));
12389        let manifest = lifecycle_with_done_check(
12390            ALLOW_BASH,
12391            "    command: bash -c true\n    limit: 2\n    limt: 3\n",
12392        )
12393        .unwrap();
12394        let reported: Vec<(String, String)> = manifest
12395            .unknown_keys
12396            .iter()
12397            .map(|key| (key.key.clone(), key.block_path.clone()))
12398            .collect();
12399        assert_eq!(
12400            reported,
12401            vec![("limt".to_string(), "lifecycle.done_check".to_string())]
12402        );
12403    }
12404
12405    /// `0` is a valid explicit value, not an absent one: it demotes at the first poll after the
12406    /// spawn, so effectively every command detaches.
12407    #[test]
12408    fn lifecycle_shell_grace_secs_zero_is_accepted() {
12409        let manifest = RuntimeManifest::from_yaml_str(
12410            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  shell_grace_secs: 0\n",
12411        )
12412        .unwrap();
12413        let lifecycle = manifest.lifecycle.unwrap();
12414        assert_eq!(lifecycle.shell_grace_secs, Some(0));
12415        assert_eq!(
12416            lifecycle.max_task_reopens,
12417            LifecycleConfig::default().max_task_reopens
12418        );
12419    }
12420
12421    #[test]
12422    fn lifecycle_max_task_reopens_defaults_to_1() {
12423        let manifest = RuntimeManifest::from_yaml_str(
12424            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  after_task: sleep\n",
12425        )
12426        .unwrap();
12427        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
12428    }
12429
12430    /// No `lifecycle:` block at all lands on the same default through `effective_lifecycle`.
12431    #[test]
12432    fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
12433        let manifest =
12434            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12435        assert!(manifest.lifecycle.is_none());
12436        assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
12437    }
12438
12439    #[test]
12440    fn lifecycle_max_task_reopens_explicit_value() {
12441        let manifest = RuntimeManifest::from_yaml_str(
12442            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 3\n",
12443        )
12444        .unwrap();
12445        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
12446    }
12447
12448    /// Unlike `inference.max_turns`, `0` is a valid explicit value — it disables reopening.
12449    #[test]
12450    fn lifecycle_max_task_reopens_zero_is_accepted() {
12451        let manifest = RuntimeManifest::from_yaml_str(
12452            "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n  max_task_reopens: 0\n",
12453        )
12454        .unwrap();
12455        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
12456    }
12457
12458    /// The budget is independent of the inference transport in play.
12459    #[test]
12460    fn lifecycle_max_task_reopens_with_process_transport() {
12461        let manifest = RuntimeManifest::from_yaml_str(
12462            "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/fixture-process-driver\n    version: 0.1.0\n    runtime: driver\ninference:\n  transport: process\n  driver:\n    artifact: local/fixture-process-driver\nlifecycle:\n  max_task_reopens: 2\n",
12463        ).unwrap();
12464        assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
12465    }
12466
12467    #[test]
12468    fn inference_max_task_reopens_is_rejected_under_http_transport() {
12469        let err = RuntimeManifest::from_yaml_str(
12470            "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",
12471        ).unwrap_err();
12472        assert!(
12473            matches!(
12474                &err,
12475                RuntimeManifestError::InvalidInferenceConfig { field, .. }
12476                    if field == "inference.max_task_reopens"
12477            ),
12478            "error was: {err:?}"
12479        );
12480        let msg = err.to_string();
12481        assert!(
12482            msg.contains("lifecycle.max_task_reopens"),
12483            "error was: {msg}"
12484        );
12485    }
12486
12487    #[test]
12488    fn inference_max_task_reopens_is_rejected_under_process_transport() {
12489        let err = RuntimeManifest::from_yaml_str(
12490            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: process\n  command: claude\n  max_task_reopens: 2\n",
12491        ).unwrap_err();
12492        assert!(
12493            matches!(
12494                &err,
12495                RuntimeManifestError::InvalidInferenceConfig { field, .. }
12496                    if field == "inference.max_task_reopens"
12497            ),
12498            "error was: {err:?}"
12499        );
12500        let msg = err.to_string();
12501        assert!(
12502            msg.contains("lifecycle.max_task_reopens"),
12503            "error was: {msg}"
12504        );
12505    }
12506
12507    /// The rejection is unconditional: the new key being present alongside it changes nothing.
12508    #[test]
12509    fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
12510        let err = RuntimeManifest::from_yaml_str(
12511            "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",
12512        ).unwrap_err();
12513        assert!(
12514            matches!(
12515                &err,
12516                RuntimeManifestError::InvalidInferenceConfig { field, .. }
12517                    if field == "inference.max_task_reopens"
12518            ),
12519            "error was: {err:?}"
12520        );
12521    }
12522
12523    #[test]
12524    fn skill_is_llm_visible() {
12525        assert!(ArtifactRuntime::Skill.is_llm_visible());
12526    }
12527
12528    #[test]
12529    fn driver_and_hook_are_not_llm_visible() {
12530        assert!(!ArtifactRuntime::Driver.is_llm_visible());
12531        assert!(!ArtifactRuntime::Hook.is_llm_visible());
12532    }
12533
12534    #[test]
12535    fn parses_system_prompt_artifact() {
12536        let manifest = RuntimeManifest::from_yaml_str(
12537            r#"
12538name: cap
12539version: 0.0.1
12540artifacts:
12541  - name: local/my-skill
12542    version: 1.0.0
12543    runtime: skill
12544  - name: murmur/driver-anthropic
12545    version: 0.1.0
12546    runtime: driver
12547    gateway:
12548      endpoint: http://127.0.0.1:8080
12549      keyless: true
12550inference:
12551  model: test-model
12552  system_prompt_artifact: local/my-skill
12553  driver:
12554    artifact: murmur/driver-anthropic
12555"#,
12556        )
12557        .unwrap();
12558
12559        let inference = manifest.inference.unwrap();
12560        assert_eq!(
12561            inference.system_prompt_artifact,
12562            Some("local/my-skill".to_string())
12563        );
12564        assert!(inference.system_prompt.is_none());
12565        assert!(inference.system_prompt_file.is_none());
12566    }
12567
12568    #[test]
12569    fn rejects_system_prompt_artifact_with_inline_prompt() {
12570        let err = RuntimeManifest::from_yaml_str(
12571            r#"
12572name: cap
12573version: 0.0.1
12574artifacts:
12575  - name: local/my-skill
12576    version: 1.0.0
12577    runtime: skill
12578  - name: murmur/driver-anthropic
12579    version: 0.1.0
12580    runtime: driver
12581    gateway:
12582      endpoint: http://127.0.0.1:8080
12583      keyless: true
12584inference:
12585  model: test-model
12586  system_prompt: "inline"
12587  system_prompt_artifact: local/my-skill
12588  driver:
12589    artifact: murmur/driver-anthropic
12590"#,
12591        )
12592        .unwrap_err();
12593
12594        let msg = err.to_string();
12595        assert!(msg.contains("at most one"), "error was: {msg}");
12596    }
12597
12598    #[test]
12599    fn rejects_system_prompt_artifact_naming_non_skill() {
12600        let err = RuntimeManifest::from_yaml_str(
12601            r#"
12602name: cap
12603version: 0.0.1
12604artifacts:
12605  - name: local/my-tool
12606    version: 1.0.0
12607    runtime: tool
12608  - name: murmur/driver-anthropic
12609    version: 0.1.0
12610    runtime: driver
12611    gateway:
12612      endpoint: http://127.0.0.1:8080
12613      keyless: true
12614inference:
12615  model: test-model
12616  system_prompt_artifact: local/my-tool
12617  driver:
12618    artifact: murmur/driver-anthropic
12619"#,
12620        )
12621        .unwrap_err();
12622
12623        let msg = err.to_string();
12624        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12625        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
12626    }
12627
12628    #[test]
12629    fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
12630        let err = RuntimeManifest::from_yaml_str(
12631            r#"
12632name: cap
12633version: 0.0.1
12634artifacts:
12635  - name: murmur/driver-anthropic
12636    version: 0.1.0
12637    runtime: driver
12638    gateway:
12639      endpoint: http://127.0.0.1:8080
12640      keyless: true
12641inference:
12642  model: test-model
12643  system_prompt_artifact: missing-skill
12644  driver:
12645    artifact: murmur/driver-anthropic
12646"#,
12647        )
12648        .unwrap_err();
12649
12650        let msg = err.to_string();
12651        assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12652        assert!(msg.contains("not declared"), "error was: {msg}");
12653    }
12654
12655    #[test]
12656    fn rejects_all_three_system_prompt_sources() {
12657        let err = RuntimeManifest::from_yaml_str(
12658            r#"
12659name: cap
12660version: 0.0.1
12661artifacts:
12662  - name: local/my-skill
12663    version: 1.0.0
12664    runtime: skill
12665  - name: murmur/driver-anthropic
12666    version: 0.1.0
12667    runtime: driver
12668    gateway:
12669      endpoint: http://127.0.0.1:8080
12670      keyless: true
12671inference:
12672  model: test-model
12673  system_prompt: "inline"
12674  system_prompt_file: "conventions.md"
12675  system_prompt_artifact: local/my-skill
12676  driver:
12677    artifact: murmur/driver-anthropic
12678"#,
12679        )
12680        .unwrap_err();
12681
12682        let msg = err.to_string();
12683        assert!(msg.contains("at most one"), "error was: {msg}");
12684    }
12685
12686    // ── Per-artifact capability grants ───────────────────────────────────────
12687
12688    #[test]
12689    fn hook_artifact_capabilities_parse_network_and_filesystem() {
12690        let manifest = RuntimeManifest::from_yaml_str(
12691            r#"
12692name: cap
12693version: 0.0.1
12694artifacts:
12695  - name: local/telemetry-hook
12696    version: 1.0.0
12697    runtime: hook
12698    capabilities:
12699      network:
12700        allow:
12701          - https://telemetry.example.com
12702      filesystem:
12703        scope: hook-state
12704"#,
12705        )
12706        .unwrap();
12707
12708        let caps = manifest.artifacts[0]
12709            .capabilities
12710            .as_ref()
12711            .expect("hook entry carries a capability grant");
12712        assert_eq!(
12713            caps.network.as_ref().unwrap().allow,
12714            vec!["https://telemetry.example.com".to_string()]
12715        );
12716        assert_eq!(
12717            caps.filesystem.as_ref().unwrap().scope,
12718            Some("hook-state".to_string())
12719        );
12720    }
12721
12722    /// Default-deny: a hook entry with no `capabilities:` key parses to `None`, which the
12723    /// runtime lowers to zero network and zero preopened directories.
12724    #[test]
12725    fn hook_artifact_without_capabilities_is_none() {
12726        let manifest = RuntimeManifest::from_yaml_str(
12727            r#"
12728name: cap
12729version: 0.0.1
12730artifacts:
12731  - name: local/quiet-hook
12732    version: 1.0.0
12733    runtime: hook
12734"#,
12735        )
12736        .unwrap();
12737
12738        assert!(manifest.artifacts[0].capabilities.is_none());
12739    }
12740
12741    /// Tools and drivers execute, so they may carry a grant — the runtime reads it as a
12742    /// narrowing of the capsule-wide ceiling rather than as a default-deny baseline.
12743    #[test]
12744    fn tool_and_driver_artifact_capabilities_parse() {
12745        for role in ["tool", "driver"] {
12746            let yaml = format!(
12747                r#"
12748name: cap
12749version: 0.0.1
12750artifacts:
12751  - name: local/scoped
12752    version: 1.0.0
12753    runtime: {role}
12754    capabilities:
12755      network:
12756        allow:
12757          - https://api.example.com
12758      filesystem:
12759        scope: cache
12760"#
12761            );
12762            let manifest = RuntimeManifest::from_yaml_str(&yaml)
12763                .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
12764
12765            let caps = manifest.artifacts[0]
12766                .capabilities
12767                .as_ref()
12768                .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
12769            assert_eq!(
12770                caps.network.as_ref().unwrap().allow,
12771                vec!["https://api.example.com".to_string()]
12772            );
12773            assert_eq!(
12774                caps.filesystem.as_ref().unwrap().scope,
12775                Some("cache".to_string())
12776            );
12777        }
12778    }
12779
12780    /// A tool/driver entry with no `capabilities:` key parses to `None`, which the runtime
12781    /// lowers to the unchanged capsule ceiling.
12782    #[test]
12783    fn tool_artifact_without_capabilities_is_none() {
12784        let manifest = RuntimeManifest::from_yaml_str(
12785            r#"
12786name: cap
12787version: 0.0.1
12788artifacts:
12789  - name: local/plain-tool
12790    version: 1.0.0
12791    runtime: tool
12792"#,
12793        )
12794        .unwrap();
12795
12796        assert!(manifest.artifacts[0].capabilities.is_none());
12797    }
12798
12799    /// A skill has no execution surface, so a grant on one would be silently unenforced —
12800    /// rejected at parse time, with a message naming the roles that do work.
12801    #[test]
12802    fn skill_artifact_capabilities_are_rejected() {
12803        let yaml = r#"
12804name: cap
12805version: 0.0.1
12806artifacts:
12807  - name: local/sneaky
12808    version: 1.0.0
12809    runtime: skill
12810    capabilities:
12811      network:
12812        allow:
12813          - https://evil.example.com
12814"#;
12815        let err = match RuntimeManifest::from_yaml_str(yaml) {
12816            Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
12817            Err(err) => err,
12818        };
12819        let msg = err.to_string();
12820        assert!(msg.contains("sneaky"), "error was: {msg}");
12821        assert!(
12822            msg.contains(
12823                "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
12824                 entries"
12825            ),
12826            "error was: {msg}"
12827        );
12828        assert!(msg.contains("runtime: skill"), "error was: {msg}");
12829    }
12830
12831    // ── Per-artifact `config:` (operator-authored artifact configuration) ────
12832
12833    /// The block is carried verbatim as YAML. This crate accepts any mapping; the runtime is
12834    /// what lowers it to JSON and refuses a shape that cannot travel that way.
12835    #[test]
12836    fn artifact_config_is_carried_verbatim() {
12837        let manifest = RuntimeManifest::from_yaml_str(
12838            r#"
12839name: cap
12840version: 0.0.1
12841artifacts:
12842  - name: murmur/tool-corpus
12843    version: 0.1.0
12844    runtime: tool
12845    config:
12846      read_recent: { default: 20, max: 100 }
12847"#,
12848        )
12849        .unwrap();
12850
12851        let config = manifest.artifacts[0]
12852            .config
12853            .as_ref()
12854            .expect("a declared block parses");
12855        assert_eq!(
12856            config["read_recent"]["max"],
12857            serde_norway::Value::Number(100.into())
12858        );
12859    }
12860
12861    /// Absent means absent: no key, no variable, and nothing for the runtime to lower.
12862    #[test]
12863    fn an_undeclared_config_is_none() {
12864        let manifest = RuntimeManifest::from_yaml_str(
12865            r#"
12866name: cap
12867version: 0.0.1
12868artifacts:
12869  - name: local/git
12870    version: 1.0.0
12871    runtime: tool
12872"#,
12873        )
12874        .unwrap();
12875
12876        assert!(manifest.artifacts[0].config.is_none());
12877    }
12878
12879    /// `config:` with nothing under it is a written declaration that carries nothing, and must
12880    /// stay distinguishable from the key being absent — the runtime refuses it, and can only do
12881    /// so if the parser does not collapse YAML null into `None`.
12882    #[test]
12883    fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
12884        let manifest = RuntimeManifest::from_yaml_str(
12885            r#"
12886name: cap
12887version: 0.0.1
12888artifacts:
12889  - name: local/git
12890    version: 1.0.0
12891    runtime: tool
12892    config:
12893"#,
12894        )
12895        .unwrap();
12896
12897        assert_eq!(
12898            manifest.artifacts[0].config,
12899            Some(serde_norway::Value::Null),
12900            "an empty block must survive parsing as a declaration, not vanish into None"
12901        );
12902    }
12903
12904    /// A skill runs no component and holds no grant, so nothing would ever deliver a config
12905    /// block to one — rejected at parse time, with a message naming the roles that do.
12906    #[test]
12907    fn skill_artifact_config_is_rejected() {
12908        let yaml = r#"
12909name: cap
12910version: 0.0.1
12911artifacts:
12912  - name: local/sneaky
12913    version: 1.0.0
12914    runtime: skill
12915    config:
12916      who: me
12917"#;
12918        let err = match RuntimeManifest::from_yaml_str(yaml) {
12919            Ok(_) => panic!("config: must be rejected for runtime: skill"),
12920            Err(err) => err,
12921        };
12922        assert!(matches!(
12923            err,
12924            RuntimeManifestError::InvalidArtifact { index: 0, .. }
12925        ));
12926        let msg = err.to_string();
12927        assert!(msg.contains("sneaky"), "error was: {msg}");
12928        assert!(msg.contains("runtime: skill"), "error was: {msg}");
12929        assert!(
12930            msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
12931            "error was: {msg}"
12932        );
12933    }
12934
12935    /// Config is delivered on one artifact's own grant, so a capsule-wide block reaches nothing.
12936    /// Refused rather than silently ignored, and the message points at the artifact entry.
12937    #[test]
12938    fn a_capsule_wide_config_block_is_rejected() {
12939        let yaml = r#"
12940name: cap
12941version: 0.0.1
12942config:
12943  who: capsule
12944artifacts:
12945  - name: local/git
12946    version: 1.0.0
12947    runtime: tool
12948"#;
12949        let err = match RuntimeManifest::from_yaml_str(yaml) {
12950            Ok(_) => panic!("a capsule-wide config block must be rejected"),
12951            Err(err) => err,
12952        };
12953        let msg = err.to_string();
12954        assert!(msg.contains("config"), "error was: {msg}");
12955        assert!(msg.contains("declared per artifact"), "error was: {msg}");
12956    }
12957
12958    // ── Per-artifact `on_overflow:` (async hook queue policy) ────────────────
12959
12960    /// `on_overflow: block` on a hook entry parses to the blocking policy.
12961    #[test]
12962    fn hook_on_overflow_block_parses() {
12963        let manifest = RuntimeManifest::from_yaml_str(
12964            r#"
12965name: cap
12966version: 0.0.1
12967artifacts:
12968  - name: murmur/hook-grafana
12969    version: 1.0.0
12970    runtime: hook
12971    on_overflow: block
12972"#,
12973        )
12974        .unwrap();
12975
12976        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
12977    }
12978
12979    /// Omitting the key means `drop`: an operator who says nothing gets the loop kept moving,
12980    /// not backpressure they never asked for.
12981    #[test]
12982    fn hook_on_overflow_defaults_to_drop() {
12983        let manifest = RuntimeManifest::from_yaml_str(
12984            r#"
12985name: cap
12986version: 0.0.1
12987artifacts:
12988  - name: murmur/hook-grafana
12989    version: 1.0.0
12990    runtime: hook
12991"#,
12992        )
12993        .unwrap();
12994
12995        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
12996        assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
12997    }
12998
12999    /// Only a hook has a job queue, so the key is rejected on every other role rather than
13000    /// silently ignored — the same rule per-artifact `capabilities:` follows on a skill.
13001    #[test]
13002    fn on_overflow_on_a_non_hook_artifact_is_rejected() {
13003        let yaml = r#"
13004name: cap
13005version: 0.0.1
13006artifacts:
13007  - name: local/git
13008    version: 1.0.0
13009    runtime: tool
13010    on_overflow: block
13011"#;
13012        let err = match RuntimeManifest::from_yaml_str(yaml) {
13013            Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
13014            Err(err) => err,
13015        };
13016        assert!(matches!(
13017            err,
13018            RuntimeManifestError::InvalidArtifact { index: 0, .. }
13019        ));
13020        let msg = err.to_string();
13021        assert!(msg.contains("git"), "error was: {msg}");
13022        assert!(
13023            msg.contains("only recognized on 'runtime: hook' entries"),
13024            "error was: {msg}"
13025        );
13026    }
13027
13028    /// An unknown value is a typo, not a policy — rejected with both spellings named.
13029    #[test]
13030    fn unknown_on_overflow_value_is_rejected() {
13031        let yaml = r#"
13032name: cap
13033version: 0.0.1
13034artifacts:
13035  - name: murmur/hook-grafana
13036    version: 1.0.0
13037    runtime: hook
13038    on_overflow: spill
13039"#;
13040        let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
13041        let msg = err.to_string();
13042        assert!(msg.contains("spill"), "error was: {msg}");
13043        assert!(msg.contains("expected: drop, block"), "error was: {msg}");
13044    }
13045
13046    /// The key is legal on a *blocking* hook entry and simply inert: the operator manifest
13047    /// cannot know a hook's execution mode, which lives in the artifact's own manifest.
13048    #[test]
13049    fn on_overflow_is_accepted_on_any_hook_entry() {
13050        let manifest = RuntimeManifest::from_yaml_str(
13051            r#"
13052name: cap
13053version: 0.0.1
13054artifacts:
13055  - name: murmur/hook-eval
13056    version: 1.0.0
13057    runtime: hook
13058    on_overflow: drop
13059    capabilities:
13060      network:
13061        allow:
13062          - https://telemetry.example.com
13063"#,
13064        )
13065        .unwrap();
13066
13067        assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
13068        assert!(manifest.artifacts[0].capabilities.is_some());
13069    }
13070
13071    /// The capsule-wide top-level block is untouched by the per-artifact field: both may be
13072    /// present in one manifest and neither shadows the other.
13073    #[test]
13074    fn capsule_wide_and_hook_capabilities_coexist() {
13075        let manifest = RuntimeManifest::from_yaml_str(
13076            r#"
13077name: cap
13078version: 0.0.1
13079artifacts:
13080  - name: local/telemetry-hook
13081    version: 1.0.0
13082    runtime: hook
13083    capabilities:
13084      network:
13085        allow:
13086          - https://telemetry.example.com
13087capabilities:
13088  network:
13089    allow:
13090      - https://api.anthropic.com
13091"#,
13092        )
13093        .unwrap();
13094
13095        assert_eq!(
13096            manifest.capabilities.unwrap().network.unwrap().allow,
13097            vec!["https://api.anthropic.com".to_string()]
13098        );
13099        assert_eq!(
13100            manifest.artifacts[0]
13101                .capabilities
13102                .as_ref()
13103                .unwrap()
13104                .network
13105                .as_ref()
13106                .unwrap()
13107                .allow,
13108            vec!["https://telemetry.example.com".to_string()]
13109        );
13110    }
13111
13112    /// No self-escalation: the hook artifact's *own* bundled murmur.yaml is parsed only by
13113    /// `parse_hook_config_from_yaml`, which has no capabilities field and no code path that
13114    /// reads one. A self-declared `capabilities:` block there is inert.
13115    #[test]
13116    fn hook_own_manifest_capabilities_are_not_read_into_config() {
13117        let config = parse_hook_config_from_yaml(
13118            r#"
13119name: telemetry-hook
13120version: 1.0.0
13121binding: on-inference
13122execution_mode: blocking
13123capabilities:
13124  network:
13125    allow:
13126      - https://evil.example.com
13127  filesystem:
13128    scope: /
13129"#,
13130        )
13131        .expect("a self-declared capabilities block is ignored, not an error");
13132
13133        assert_eq!(
13134            config,
13135            HookConfig {
13136                binding: HookBinding::OnInference,
13137                execution_mode: HookExecutionMode::Blocking,
13138                commit_policy: HookCommitPolicy::None,
13139            }
13140        );
13141    }
13142
13143    // ── Containment class ────────────────────────────────────────────────────
13144
13145    #[test]
13146    fn containment_classes_order_weakest_to_strongest() {
13147        assert!(ContainmentClass::Unconfined < ContainmentClass::Advisory);
13148        assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
13149        assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
13150        assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
13151        assert_eq!(
13152            ContainmentClass::ALL.iter().copied().max(),
13153            Some(ContainmentClass::Sealed)
13154        );
13155    }
13156
13157    #[test]
13158    fn containment_class_defaults_to_advisory() {
13159        assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
13160    }
13161
13162    #[test]
13163    fn containment_class_round_trips_through_string() {
13164        for class in ContainmentClass::ALL {
13165            assert_eq!(
13166                class.to_string().parse::<ContainmentClass>(),
13167                Ok(class),
13168                "{class} must survive Display -> FromStr"
13169            );
13170        }
13171        assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
13172        assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
13173        assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
13174        assert_eq!(ContainmentClass::Unconfined.as_str(), "unconfined");
13175        assert_eq!(ContainmentClass::Unconfined.to_string(), "unconfined");
13176        assert_eq!(
13177            serde_json::to_string(&ContainmentClass::Unconfined).unwrap(),
13178            "\"unconfined\""
13179        );
13180        assert_eq!(
13181            serde_norway::from_str::<ContainmentClass>("unconfined").unwrap(),
13182            ContainmentClass::Unconfined
13183        );
13184    }
13185
13186    #[test]
13187    fn containment_declarable_classes_exclude_unconfined() {
13188        use ContainmentClass::{Advisory, Scoped, Sealed, Unconfined};
13189        assert_eq!(ContainmentClass::DECLARABLE, [Advisory, Scoped, Sealed]);
13190        assert_eq!(
13191            ContainmentClass::ALL,
13192            [Unconfined, Advisory, Scoped, Sealed]
13193        );
13194        let inputs: Vec<Option<ContainmentClass>> = std::iter::once(None)
13195            .chain(ContainmentClass::DECLARABLE.map(Some))
13196            .collect();
13197        for &workspace in &inputs {
13198            for &manifest in &inputs {
13199                for &cli in &inputs {
13200                    assert_ne!(
13201                        effective_containment_floor(workspace, manifest, cli),
13202                        Unconfined
13203                    );
13204                }
13205            }
13206        }
13207    }
13208
13209    #[test]
13210    fn containment_class_rejects_unknown_names() {
13211        let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
13212        assert_eq!(err.value, "paranoid");
13213        assert_eq!(
13214            err.to_string(),
13215            "must be one of: advisory, scoped, sealed; got 'paranoid'"
13216        );
13217        // Case-sensitive by design: the wire form is lowercase everywhere.
13218        assert!("Sealed".parse::<ContainmentClass>().is_err());
13219    }
13220
13221    #[test]
13222    fn containment_class_serializes_as_its_wire_name() {
13223        assert_eq!(
13224            serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
13225            "\"scoped\""
13226        );
13227        assert_eq!(
13228            serde_norway::from_str::<ContainmentClass>("sealed").unwrap(),
13229            ContainmentClass::Sealed
13230        );
13231    }
13232
13233    #[test]
13234    fn effective_floor_is_advisory_when_nothing_is_declared() {
13235        assert_eq!(
13236            effective_containment_floor(None, None, None),
13237            ContainmentClass::Advisory
13238        );
13239    }
13240
13241    #[test]
13242    fn effective_floor_takes_the_strongest_across_every_presence_combination() {
13243        use ContainmentClass::{Advisory, Scoped, Sealed};
13244
13245        // Exactly one source present: that source wins outright.
13246        assert_eq!(
13247            effective_containment_floor(Some(Scoped), None, None),
13248            Scoped
13249        );
13250        assert_eq!(
13251            effective_containment_floor(None, Some(Scoped), None),
13252            Scoped
13253        );
13254        assert_eq!(
13255            effective_containment_floor(None, None, Some(Scoped)),
13256            Scoped
13257        );
13258        assert_eq!(
13259            effective_containment_floor(Some(Sealed), None, None),
13260            Sealed
13261        );
13262        assert_eq!(
13263            effective_containment_floor(None, Some(Sealed), None),
13264            Sealed
13265        );
13266        assert_eq!(
13267            effective_containment_floor(None, None, Some(Sealed)),
13268            Sealed
13269        );
13270
13271        // Two sources present: the stronger wins regardless of which slot holds it, and a
13272        // weaker source can never pull the stronger one down.
13273        assert_eq!(
13274            effective_containment_floor(Some(Advisory), Some(Sealed), None),
13275            Sealed
13276        );
13277        assert_eq!(
13278            effective_containment_floor(Some(Sealed), Some(Advisory), None),
13279            Sealed
13280        );
13281        assert_eq!(
13282            effective_containment_floor(Some(Advisory), None, Some(Scoped)),
13283            Scoped
13284        );
13285        assert_eq!(
13286            effective_containment_floor(Some(Scoped), None, Some(Advisory)),
13287            Scoped
13288        );
13289        assert_eq!(
13290            effective_containment_floor(None, Some(Advisory), Some(Scoped)),
13291            Scoped
13292        );
13293        assert_eq!(
13294            effective_containment_floor(None, Some(Scoped), Some(Advisory)),
13295            Scoped
13296        );
13297
13298        // All three present: max wins from every slot, and all-equal is a fixed point.
13299        assert_eq!(
13300            effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
13301            Sealed
13302        );
13303        assert_eq!(
13304            effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
13305            Sealed
13306        );
13307        assert_eq!(
13308            effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
13309            Sealed
13310        );
13311        assert_eq!(
13312            effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
13313            Scoped
13314        );
13315        assert_eq!(
13316            effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
13317            Advisory
13318        );
13319    }
13320
13321    #[test]
13322    fn manifest_parses_each_declared_containment_class() {
13323        for class in ContainmentClass::DECLARABLE {
13324            let manifest = RuntimeManifest::from_yaml_str(&format!(
13325                r#"
13326name: cap
13327version: 0.0.1
13328capabilities:
13329  containment: {class}
13330"#
13331            ))
13332            .expect("a valid containment class parses");
13333
13334            assert_eq!(
13335                manifest.capabilities.unwrap().containment,
13336                Some(class),
13337                "capabilities.containment: {class}"
13338            );
13339        }
13340    }
13341
13342    #[test]
13343    fn manifest_without_containment_key_declares_nothing() {
13344        // Absent `capabilities:` block entirely.
13345        let manifest = RuntimeManifest::from_yaml_str(
13346            r#"
13347name: cap
13348version: 0.0.1
13349"#,
13350        )
13351        .unwrap();
13352        assert!(manifest.capabilities.is_none());
13353
13354        // Present `capabilities:` block that simply omits the key: still None, never a
13355        // silently-defaulted Advisory floor stored as if the operator had written it.
13356        let manifest = RuntimeManifest::from_yaml_str(
13357            r#"
13358name: cap
13359version: 0.0.1
13360capabilities:
13361  network:
13362    allow:
13363      - https://api.example.com
13364"#,
13365        )
13366        .unwrap();
13367        assert_eq!(manifest.capabilities.unwrap().containment, None);
13368    }
13369
13370    #[test]
13371    fn manifest_rejects_an_unknown_containment_class() {
13372        let err = RuntimeManifest::from_yaml_str(
13373            r#"
13374name: cap
13375version: 0.0.1
13376capabilities:
13377  containment: paranoid
13378"#,
13379        )
13380        .expect_err("an unknown containment class is rejected at parse time");
13381
13382        match err {
13383            RuntimeManifestError::InvalidCapabilities { field, message } => {
13384                assert_eq!(field, "capabilities.containment");
13385                assert_eq!(
13386                    message,
13387                    "must be one of: advisory, scoped, sealed; got 'paranoid'"
13388                );
13389            }
13390            other => panic!("expected InvalidCapabilities, got {other:?}"),
13391        }
13392    }
13393
13394    #[test]
13395    fn manifest_refuses_an_unconfined_containment_class() {
13396        let err = RuntimeManifest::from_yaml_str(
13397            r#"
13398name: cap
13399version: 0.0.1
13400capabilities:
13401  containment: unconfined
13402"#,
13403        )
13404        .expect_err("a manifest cannot choose the unconfined stance");
13405
13406        match err {
13407            RuntimeManifestError::InvalidCapabilities { field, message } => {
13408                assert_eq!(field, "capabilities.containment");
13409                assert!(message.contains("mur run --unconfined"), "{message}");
13410                assert!(message.contains("~/.murmur/config.yaml"), "{message}");
13411            }
13412            other => panic!("expected InvalidCapabilities, got {other:?}"),
13413        }
13414    }
13415
13416    // ── exports.files ────────────────────────────────────────────────────────
13417
13418    fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
13419        RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
13420    }
13421
13422    fn export_error(block: &str) -> (String, String) {
13423        match exports_manifest(block).expect_err("this exports block must not parse") {
13424            RuntimeManifestError::InvalidExports { field, message } => (field, message),
13425            other => panic!("expected InvalidExports, got {other:?}"),
13426        }
13427    }
13428
13429    fn peer_fetch_error(block: &str) -> (String, String) {
13430        match exports_manifest(block).expect_err("this capabilities block must not parse") {
13431            RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
13432            other => panic!("expected InvalidCapabilities, got {other:?}"),
13433        }
13434    }
13435
13436    // ── exports.peer_files ───────────────────────────────────────────────────
13437
13438    #[test]
13439    fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
13440        assert_eq!(parse_duration_secs("90").unwrap(), 90);
13441        assert_eq!(parse_duration_secs("30s").unwrap(), 30);
13442        assert_eq!(parse_duration_secs("15m").unwrap(), 900);
13443        assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
13444        assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
13445        assert_eq!(parse_duration_secs("  2h  ").unwrap(), 7200);
13446        assert_eq!(parse_duration_secs("0").unwrap(), 0);
13447    }
13448
13449    /// Every spelling that is not the one accepted form, refused rather than guessed at: a
13450    /// manifest meaning 5 seconds and one meaning 5 minutes must not both parse.
13451    #[test]
13452    fn parse_duration_secs_rejects_every_other_spelling() {
13453        for input in [
13454            "",
13455            "  ",
13456            "5 minutes",
13457            "30S",
13458            "1H",
13459            "5min",
13460            "1.5h",
13461            "-1",
13462            "m",
13463            "h",
13464            "1h30m",
13465            "0x10",
13466            "30 s",
13467        ] {
13468            let error = parse_duration_secs(input)
13469                .expect_err(&format!("'{input}' must not parse as a duration"));
13470            assert!(
13471                error.contains(DURATION_ACCEPTED_FORM),
13472                "'{input}' must be refused with the accepted form; got: {error}"
13473            );
13474        }
13475    }
13476
13477    #[test]
13478    fn parse_duration_secs_refuses_to_overflow() {
13479        assert!(parse_duration_secs("18446744073709551615h")
13480            .unwrap_err()
13481            .contains("overflows"));
13482    }
13483
13484    #[test]
13485    fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
13486        let manifest = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
13487            .expect("a minimal peer_files block parses");
13488        let peer = manifest.exports.unwrap().peer_files.unwrap();
13489        assert_eq!(peer.root, "out/");
13490        assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
13491        assert_eq!(peer.max_bytes, 10_485_760);
13492        // Undeclared, not defaulted: the two ephemerality cases answer an absent value
13493        // differently, and substituting here would erase the difference.
13494        assert_eq!(peer.max_ttl_secs, None);
13495        assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
13496        assert_eq!(peer.effective_max_ttl_secs(), 3600);
13497    }
13498
13499    #[test]
13500    fn peer_files_accepts_every_duration_spelling() {
13501        for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
13502            let manifest = exports_manifest(&format!(
13503                "exports:\n  peer_files:\n    root: out/\n    max_ttl: {declared}\n"
13504            ))
13505            .expect("a declared max_ttl parses");
13506            let peer = manifest.exports.unwrap().peer_files.unwrap();
13507            assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
13508            assert_eq!(peer.effective_max_ttl_secs(), expected);
13509        }
13510    }
13511
13512    /// The two export blocks are separate authorisers: declaring one says nothing about the
13513    /// other, and both may be declared side by side over different subtrees.
13514    #[test]
13515    fn the_two_export_blocks_are_independent() {
13516        let both = exports_manifest(
13517            "exports:\n  files:\n    root: out/\n    mode: read-only\n  \
13518             peer_files:\n    root: out/handoff/\n",
13519        )
13520        .expect("both blocks parse together")
13521        .exports
13522        .unwrap();
13523        assert_eq!(both.files.unwrap().root, "out/");
13524        assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
13525
13526        let only_files =
13527            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
13528                .unwrap()
13529                .exports
13530                .unwrap();
13531        assert!(only_files.peer_files.is_none());
13532
13533        let only_peer = exports_manifest("exports:\n  peer_files:\n    root: out/\n")
13534            .unwrap()
13535            .exports
13536            .unwrap();
13537        assert!(only_peer.files.is_none());
13538
13539        let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
13540        assert!(neither.files.is_none() && neither.peer_files.is_none());
13541    }
13542
13543    #[test]
13544    fn peer_files_root_must_be_relative_and_inside_the_workdir() {
13545        for root in ["../out", "/etc", "out/../../etc"] {
13546            let (field, message) =
13547                export_error(&format!("exports:\n  peer_files:\n    root: {root}\n"));
13548            assert_eq!(field, "exports.peer_files.root", "root: {root}");
13549            assert!(
13550                message.contains(EXPORT_ROOT_ACCEPTED_FORM),
13551                "root: {root}; got: {message}"
13552            );
13553        }
13554    }
13555
13556    #[test]
13557    fn peer_files_root_is_required() {
13558        let (field, message) = export_error("exports:\n  peer_files: {}\n");
13559        assert_eq!(field, "exports.peer_files.root");
13560        assert!(message.contains("is required"));
13561        assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
13562    }
13563
13564    #[test]
13565    fn peer_files_max_ttl_states_its_accepted_form() {
13566        for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
13567            let (field, message) = export_error(&format!(
13568                "exports:\n  peer_files:\n    root: out/\n    max_ttl: \"{declared}\"\n"
13569            ));
13570            assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
13571            assert!(
13572                message.contains(DURATION_ACCEPTED_FORM),
13573                "max_ttl: {declared}; got: {message}"
13574            );
13575        }
13576    }
13577
13578    /// A zero ceiling reads like a declared surface and refuses every mint. Better discovered at
13579    /// the line that wrote it than at the first `share-file` call.
13580    #[test]
13581    fn peer_files_max_ttl_rejects_zero() {
13582        let (field, message) =
13583            export_error("exports:\n  peer_files:\n    root: out/\n    max_ttl: 0\n");
13584        assert_eq!(field, "exports.peer_files.max_ttl");
13585        assert!(message.contains("greater than zero"));
13586    }
13587
13588    #[test]
13589    fn peer_files_max_bytes_states_its_accepted_form() {
13590        let (field, message) =
13591            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 10MB\n");
13592        assert_eq!(field, "exports.peer_files.max_bytes");
13593        assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
13594    }
13595
13596    #[test]
13597    fn peer_files_max_bytes_rejects_zero() {
13598        let (field, message) =
13599            export_error("exports:\n  peer_files:\n    root: out/\n    max_bytes: 0\n");
13600        assert_eq!(field, "exports.peer_files.max_bytes");
13601        assert!(message.contains("greater than zero"));
13602    }
13603
13604    // ── exports.peer_tasks ───────────────────────────────────────────────────
13605
13606    /// Absent and `accept: false` are the same posture; only an explicit `true` consents.
13607    #[test]
13608    fn peer_tasks_consents_only_on_an_explicit_true() {
13609        let absent = exports_manifest("artifacts: []\n").expect("no exports block parses");
13610        assert!(absent.exports.is_none());
13611        assert!(!absent.accepts_peer_tasks());
13612
13613        let refused = exports_manifest("exports:\n  peer_tasks:\n    accept: false\n")
13614            .expect("an explicit refusal parses");
13615        assert_eq!(
13616            refused.exports.as_ref().unwrap().peer_tasks,
13617            Some(PeerTasksExport { accept: false })
13618        );
13619        assert!(!refused.accepts_peer_tasks());
13620        assert!(!refused.exports.as_ref().unwrap().accepts_peer_tasks());
13621
13622        let accepted = exports_manifest("exports:\n  peer_tasks:\n    accept: true\n")
13623            .expect("consent parses");
13624        assert_eq!(
13625            accepted.exports.as_ref().unwrap().peer_tasks,
13626            Some(PeerTasksExport { accept: true })
13627        );
13628        assert!(accepted.accepts_peer_tasks());
13629        assert!(accepted.exports.as_ref().unwrap().accepts_peer_tasks());
13630    }
13631
13632    /// An `exports:` block declaring only `peer_tasks` opens neither file plane.
13633    #[test]
13634    fn peer_tasks_alone_declares_no_file_plane() {
13635        let manifest = exports_manifest("exports:\n  peer_tasks:\n    accept: true\n")
13636            .expect("a peer_tasks-only exports block parses");
13637        let exports = manifest.exports.unwrap();
13638        assert_eq!(exports.files, None);
13639        assert_eq!(exports.peer_files, None);
13640    }
13641
13642    /// A file export says nothing about peer tasks.
13643    #[test]
13644    fn a_file_export_does_not_consent_to_peer_tasks() {
13645        let manifest =
13646            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
13647                .expect("a files export parses");
13648        assert_eq!(manifest.exports.as_ref().unwrap().peer_tasks, None);
13649        assert!(!manifest.accepts_peer_tasks());
13650    }
13651
13652    /// Consent is never inferred: a `peer_tasks:` block without `accept:` is refused naming the
13653    /// key, as `capabilities.plan` without `submit:` is.
13654    #[test]
13655    fn peer_tasks_without_accept_is_refused() {
13656        let (field, message) = export_error("exports:\n  peer_tasks: {}\n");
13657        assert_eq!(field, "exports.peer_tasks.accept");
13658        assert_eq!(
13659            message,
13660            "must be set explicitly to true or false — consent to peer tasks is never inferred"
13661        );
13662    }
13663
13664    /// A value of the wrong type is refused at parse, and the refusal names where it is.
13665    #[test]
13666    fn peer_tasks_of_the_wrong_type_is_refused_naming_the_field() {
13667        for (block, field) in [
13668            (
13669                "exports:\n  peer_tasks:\n    accept: \"yes\"\n",
13670                "exports.peer_tasks.accept",
13671            ),
13672            (
13673                "exports:\n  peer_tasks:\n    accept: 1\n",
13674                "exports.peer_tasks.accept",
13675            ),
13676            ("exports:\n  peer_tasks: true\n", "exports.peer_tasks"),
13677            ("exports:\n  peer_tasks: [peer-a]\n", "exports.peer_tasks"),
13678        ] {
13679            let message = exports_manifest(block)
13680                .expect_err(&format!("{block:?} must not parse"))
13681                .to_string();
13682            assert!(message.contains(field), "{block:?}: {message}");
13683        }
13684    }
13685
13686    /// An unknown key under the block is a warning reported by path, not a refusal — and naming
13687    /// a peer there grants nothing.
13688    #[test]
13689    fn an_unknown_peer_tasks_key_is_reported_by_path() {
13690        let manifest =
13691            exports_manifest("exports:\n  peer_tasks:\n    accept: false\n    allow: [peer-a]\n")
13692                .expect("an unknown key is a warning, not a parse error");
13693        assert!(
13694            manifest
13695                .unknown_keys
13696                .iter()
13697                .any(|key| key.block_path == "exports.peer_tasks" && key.key == "allow"),
13698            "{:?}",
13699            manifest.unknown_keys
13700        );
13701        assert!(!manifest.accepts_peer_tasks());
13702    }
13703
13704    // ── capabilities.peer_fetch ──────────────────────────────────────────────
13705
13706    #[test]
13707    fn peer_fetch_allow_carries_its_declared_destinations() {
13708        let manifest = exports_manifest(
13709            "capabilities:\n  peer_fetch:\n    allow:\n      - localhost:41234\n      \
13710             - reporting.internal\n      - https://gateway.example.com\n",
13711        )
13712        .expect("a peer_fetch block parses");
13713        assert_eq!(
13714            manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
13715            vec![
13716                "localhost:41234".to_string(),
13717                "reporting.internal".to_string(),
13718                "https://gateway.example.com".to_string(),
13719            ]
13720        );
13721    }
13722
13723    /// An empty list is a parse error rather than a silent deny: `allow: []` reads as a declared
13724    /// grant and would otherwise refuse every fetch with no line to point at.
13725    #[test]
13726    fn peer_fetch_allow_must_not_be_empty() {
13727        let (field, message) = peer_fetch_error("capabilities:\n  peer_fetch:\n    allow: []\n");
13728        assert_eq!(field, "capabilities.peer_fetch.allow");
13729        assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
13730    }
13731
13732    #[test]
13733    fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
13734        for entry in [
13735            "http://[not a host",
13736            "\"\"",
13737            "localhost:notaport",
13738            "http://example.com/path",
13739            "ftp://example.com",
13740        ] {
13741            let (field, message) = peer_fetch_error(&format!(
13742                "capabilities:\n  peer_fetch:\n    allow:\n      - {entry}\n"
13743            ));
13744            assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
13745            assert!(
13746                message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
13747                "entry: {entry}; got: {message}"
13748            );
13749        }
13750    }
13751
13752    /// `peer_fetch` is a separate authoriser and never an alias: declaring it leaves
13753    /// `network.allow` exactly as the manifest wrote it, and vice versa.
13754    #[test]
13755    fn peer_fetch_does_not_widen_network_allow() {
13756        let capabilities = exports_manifest(
13757            "capabilities:\n  network:\n    allow:\n      - api.example.com\n  \
13758             peer_fetch:\n    allow:\n      - localhost:41234\n",
13759        )
13760        .unwrap()
13761        .capabilities
13762        .unwrap();
13763        assert_eq!(
13764            capabilities.network.unwrap().allow,
13765            vec!["api.example.com".to_string()]
13766        );
13767        assert_eq!(
13768            capabilities.peer_fetch.unwrap().allow,
13769            vec!["localhost:41234".to_string()]
13770        );
13771    }
13772
13773    #[test]
13774    fn an_absent_peer_fetch_block_is_absent() {
13775        assert!(exports_manifest("capabilities: {}\n")
13776            .unwrap()
13777            .capabilities
13778            .unwrap()
13779            .peer_fetch
13780            .is_none());
13781    }
13782
13783    #[test]
13784    fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
13785        assert_eq!(parse_byte_size("0").unwrap(), 0);
13786        assert_eq!(parse_byte_size("4096").unwrap(), 4096);
13787        assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
13788        assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
13789        assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
13790        assert_eq!(parse_byte_size("  10Mi  ").unwrap(), 10 * 1024 * 1024);
13791    }
13792
13793    /// Decimal suffixes are refused rather than guessed at: a manifest meaning 10 000 000 and one
13794    /// meaning 10 485 760 must not both parse.
13795    #[test]
13796    fn parse_byte_size_rejects_every_other_spelling() {
13797        for input in [
13798            "", "  ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
13799            "1Ki1",
13800        ] {
13801            let error = parse_byte_size(input)
13802                .expect_err(&format!("'{input}' must not parse as a byte size"));
13803            assert!(
13804                error.contains(BYTE_SIZE_ACCEPTED_FORM),
13805                "'{input}' must be refused with the accepted form; got: {error}"
13806            );
13807        }
13808    }
13809
13810    #[test]
13811    fn parse_byte_size_refuses_to_overflow() {
13812        assert!(parse_byte_size("18446744073709551615Gi")
13813            .unwrap_err()
13814            .contains("overflows"));
13815    }
13816
13817    #[test]
13818    fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
13819        let manifest =
13820            exports_manifest("exports:\n  files:\n    root: out/\n    mode: read-only\n")
13821                .expect("a minimal exports block parses");
13822        let files = manifest.exports.unwrap().files.unwrap();
13823        assert_eq!(files.root, "out/");
13824        assert_eq!(files.mode, ExportMode::ReadOnly);
13825        assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
13826        assert_eq!(files.max_bytes, 10_485_760);
13827    }
13828
13829    #[test]
13830    fn exports_files_accepts_both_byte_spellings() {
13831        for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
13832            let manifest = exports_manifest(&format!(
13833                "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: {declared}\n"
13834            ))
13835            .expect("a declared max_bytes parses");
13836            assert_eq!(
13837                manifest.exports.unwrap().files.unwrap().max_bytes,
13838                expected,
13839                "max_bytes: {declared}"
13840            );
13841        }
13842    }
13843
13844    /// An `exports:` block with no `files:` is not a resource plane. It parses, and the plane
13845    /// stays undeclared — which is the deny case, not a defaulted-open one.
13846    #[test]
13847    fn an_exports_block_without_files_declares_no_plane() {
13848        let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
13849        assert!(manifest.exports.unwrap().files.is_none());
13850    }
13851
13852    #[test]
13853    fn an_absent_exports_block_is_absent() {
13854        let manifest = exports_manifest("").expect("a manifest without exports parses");
13855        assert!(manifest.exports.is_none());
13856    }
13857
13858    #[test]
13859    fn exports_files_root_must_be_relative_and_inside_the_workdir() {
13860        for root in ["../out", "/etc", "out/../../etc", "''"] {
13861            let (field, message) = export_error(&format!(
13862                "exports:\n  files:\n    root: {root}\n    mode: read-only\n"
13863            ));
13864            assert_eq!(field, "exports.files.root", "root: {root}");
13865            assert!(
13866                message.contains("must be a relative path inside the workdir"),
13867                "root: {root}; got: {message}"
13868            );
13869        }
13870    }
13871
13872    #[test]
13873    fn exports_files_root_is_required() {
13874        let (field, message) = export_error("exports:\n  files:\n    mode: read-only\n");
13875        assert_eq!(field, "exports.files.root");
13876        assert_eq!(
13877            message,
13878            "is required and must be a relative path inside the workdir"
13879        );
13880    }
13881
13882    #[test]
13883    fn exports_files_mode_is_required_and_read_only() {
13884        let (field, message) = export_error("exports:\n  files:\n    root: out/\n");
13885        assert_eq!(field, "exports.files.mode");
13886        assert_eq!(message, "is required and must be 'read-only'");
13887
13888        let (field, message) =
13889            export_error("exports:\n  files:\n    root: out/\n    mode: read-write\n");
13890        assert_eq!(field, "exports.files.mode");
13891        assert_eq!(message, "'read-write' must be 'read-only'");
13892    }
13893
13894    #[test]
13895    fn exports_files_max_bytes_states_its_accepted_form() {
13896        let (field, message) = export_error(
13897            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 10MB\n",
13898        );
13899        assert_eq!(field, "exports.files.max_bytes");
13900        assert_eq!(
13901            message,
13902            "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
13903        );
13904    }
13905
13906    /// A zero ceiling would refuse every read while reading like a declared export, so it is
13907    /// refused where it is written rather than at the first request.
13908    #[test]
13909    fn exports_files_max_bytes_rejects_zero() {
13910        let (field, message) = export_error(
13911            "exports:\n  files:\n    root: out/\n    mode: read-only\n    max_bytes: 0\n",
13912        );
13913        assert_eq!(field, "exports.files.max_bytes");
13914        assert!(message.contains("must be greater than zero"), "{message}");
13915    }
13916
13917    /// A leading `./` is a relative path like any other and is not an escape.
13918    #[test]
13919    fn exports_files_root_accepts_a_current_dir_prefix() {
13920        let manifest =
13921            exports_manifest("exports:\n  files:\n    root: ./out\n    mode: read-only\n")
13922                .expect("./out is a legal root");
13923        assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
13924    }
13925
13926    // ── Unrecognized manifest keys (W-SEC-019) ────────────────────────────────
13927
13928    fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
13929        RuntimeManifest::from_yaml_str(yaml)
13930            .expect("fixture must parse")
13931            .unknown_keys
13932            .into_iter()
13933            .map(|key| (key.key, key.block_path, key.nearest_known))
13934            .collect()
13935    }
13936
13937    /// The typo the whole warning exists for: a hyphen where the key takes an underscore, named
13938    /// with the block that held it and with the key it should have been.
13939    #[test]
13940    fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
13941        assert_eq!(
13942            unknown_keys_of(
13943                "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    read-only:\n      - tests\n"
13944            ),
13945            vec![(
13946                "read-only".to_string(),
13947                "capabilities.filesystem".to_string(),
13948                Some("read_only".to_string())
13949            )]
13950        );
13951    }
13952
13953    /// A manifest exercising every layer the walk descends through — capsule-wide capabilities,
13954    /// a per-artifact block, shell, network and an artifact entry — reports nothing. A warning
13955    /// that fires on correct input is noise, and noise is ignored.
13956    #[test]
13957    fn a_manifest_of_recognized_keys_reports_nothing() {
13958        assert!(unknown_keys_of(
13959            r#"
13960name: cap
13961version: 0.1.0
13962deploy:
13963  mur_version: "0.2.0"
13964artifacts:
13965  - name: local/notes-tool
13966    version: 0.1.0
13967    runtime: tool
13968    capabilities:
13969      filesystem:
13970        scope: notes
13971      network:
13972        allow:
13973          - example.com:443
13974capabilities:
13975  filesystem:
13976    read_only:
13977      - tests
13978      - bench/fixtures
13979    workdir_exec: false
13980  shell:
13981    allow:
13982      - git
13983      - python3
13984  network:
13985    allow:
13986      - example.com:443
13987    unix_sockets: false
13988"#
13989        )
13990        .is_empty());
13991    }
13992
13993    /// A per-artifact capability block reports under its artifact's index, so an operator is not
13994    /// sent to the capsule-wide block for a key they wrote on one entry.
13995    #[test]
13996    fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
13997        assert_eq!(
13998            unknown_keys_of(
13999                "name: cap\nversion: 0.1.0\nartifacts:\n  - name: local/t\n    version: 0.1.0\n    \
14000                 runtime: tool\n    capabilities:\n      shell:\n        allow:\n          - git\n        \
14001                 allwo:\n          - git\n"
14002            ),
14003            vec![(
14004                "allwo".to_string(),
14005                "artifacts[0].capabilities.shell".to_string(),
14006                Some("allow".to_string())
14007            )]
14008        );
14009    }
14010
14011    /// A top-level key with no near neighbour reports an empty path and no suggestion — the two
14012    /// facts the emitter needs to word it as a possibly-newer key rather than a misspelling.
14013    #[test]
14014    fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
14015        assert_eq!(
14016            unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
14017            vec![("quantum_teleport".to_string(), String::new(), None)]
14018        );
14019    }
14020
14021    /// The minimum publishable delegated sub-capsule — `runtime:` plus `execution:` — reports
14022    /// nothing. Every registrant of a delegating capsule must be published, so every one of them
14023    /// carries these keys; warning on them would make the diagnostic unreadable exactly where it
14024    /// is needed most.
14025    #[test]
14026    fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
14027        assert!(unknown_keys_of(
14028            "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
14029        )
14030        .is_empty());
14031    }
14032
14033    /// All four keys `mur build` and `mur publish` read beyond `name:` and `version:`, together.
14034    #[test]
14035    fn every_build_side_key_is_recognized() {
14036        assert!(unknown_keys_of(
14037            "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
14038             wasm\nrequires_files:\n  - tool.wasm\n  - assets/logo.png\n"
14039        )
14040        .is_empty());
14041    }
14042
14043    /// `W-SEC-019` still covers every key no command reads: one sitting beside the four
14044    /// build-side keys reports, and reports alone.
14045    #[test]
14046    fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
14047        assert_eq!(
14048            unknown_keys_of(
14049                "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
14050                 static\nrequires_files:\n  - capsule.wasm\nquantum_teleport: true\n"
14051            ),
14052            vec![("quantum_teleport".to_string(), String::new(), None)]
14053        );
14054    }
14055
14056    /// The four keys are recognized and then dropped: they add no refusal and no run-time meaning,
14057    /// so a manifest carrying them parses to what it parses to without them. A wrong-shaped value
14058    /// is build's business to reject, not the run path's.
14059    #[test]
14060    fn the_build_side_keys_change_nothing_the_run_path_reads() {
14061        let body = "artifacts:\n  - name: local/notes-tool\n    version: 0.1.0\n    runtime: tool\n\
14062                    \x20 - name: murmur/driver-anthropic\n    version: 0.1.0\n    runtime: \
14063                    driver\n    gateway:\n      endpoint: http://127.0.0.1:8080\n      keyless: true\n\
14064                    capabilities:\n  filesystem:\n    read_only:\n      - tests\n\
14065                    inference:\n  transport: http\n  model: test-model\n  driver:\n    \
14066                    artifact: murmur/driver-anthropic\n";
14067        let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
14068            .expect("fixture must parse");
14069        let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
14070            "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n  \
14071             any: shape\nrequires_files: not-a-list\n{body}"
14072        ))
14073        .expect("a build-side key of any shape never refuses a manifest");
14074
14075        assert!(with_build_keys.unknown_keys.is_empty());
14076        assert!(
14077            !plain.artifacts.is_empty()
14078                && plain.capabilities.is_some()
14079                && plain.inference.is_some(),
14080            "the fixture must populate the blocks the comparison below rests on, or it \
14081             compares absence against absence"
14082        );
14083        assert_eq!(with_build_keys.name, plain.name);
14084        assert_eq!(with_build_keys.version, plain.version);
14085        assert_eq!(with_build_keys.artifacts, plain.artifacts);
14086        assert_eq!(with_build_keys.capabilities, plain.capabilities);
14087        assert_eq!(with_build_keys.inference, plain.inference);
14088    }
14089
14090    /// The overflow map captures the key without consuming the block: the rest of it still parses
14091    /// and still applies. A capsule with an unrecognized key launches.
14092    #[test]
14093    fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
14094        let manifest = RuntimeManifest::from_yaml_str(
14095            "name: cap\nversion: 0.1.0\ncapabilities:\n  filesystem:\n    scope: notes\n    \
14096             read-only:\n      - tests\n",
14097        )
14098        .expect("an unrecognized key never refuses a manifest");
14099        assert_eq!(
14100            manifest
14101                .capabilities
14102                .as_ref()
14103                .and_then(|caps| caps.filesystem.as_ref())
14104                .and_then(|fs| fs.scope.as_deref()),
14105            Some("notes")
14106        );
14107        assert_eq!(manifest.unknown_keys.len(), 1);
14108    }
14109
14110    /// Every block on the way down is walked, not just the capability sub-blocks.
14111    #[test]
14112    fn nested_blocks_outside_capabilities_are_walked_too() {
14113        let keys = unknown_keys_of(
14114            "name: cap\nversion: 0.1.0\ncontext:\n  max_tokns: 100\nexports:\n  files:\n    \
14115             root: out\n    mode: read-only\n    max_byts: 10\n",
14116        );
14117        assert_eq!(
14118            keys,
14119            vec![
14120                (
14121                    "max_tokns".to_string(),
14122                    "context".to_string(),
14123                    Some("max_tokens".to_string())
14124                ),
14125                (
14126                    "max_byts".to_string(),
14127                    "exports.files".to_string(),
14128                    Some("max_bytes".to_string())
14129                ),
14130            ]
14131        );
14132    }
14133
14134    /// Source-derived, so a `Raw*` struct added later cannot skip the overflow map, and a field
14135    /// added to one cannot skip its `KNOWN_KEYS`: the suggester would then never propose it.
14136    ///
14137    /// Reads this file's own text rather than a hand-written list for the same reason the
14138    /// `CapabilityPolicy` coverage test in `capsule-runtime` does — a list maintained by hand
14139    /// records what someone remembered, not what the code says.
14140    #[test]
14141    fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
14142        let source = include_str!("runtime_manifest.rs");
14143        let lines: Vec<&str> = source.lines().collect();
14144
14145        let mut checked = 0usize;
14146        for (start, line) in lines.iter().enumerate() {
14147            let Some(name) = line
14148                .strip_prefix("struct Raw")
14149                .and_then(|rest| rest.strip_suffix(" {"))
14150            else {
14151                continue;
14152            };
14153            let name = format!("Raw{name}");
14154            let end = (start + 1..lines.len())
14155                .find(|index| lines[*index] == "}")
14156                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
14157            let body = &lines[start + 1..end];
14158
14159            assert!(
14160                body.contains(&"    #[serde(flatten)]")
14161                    && body.contains(&"    unknown: UnknownKeys,"),
14162                "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
14163                 so a key it does not recognize would be dropped instead of reported"
14164            );
14165
14166            let declared = serde_field_names(body);
14167            let known = known_keys_of(source, &name);
14168            assert_eq!(
14169                declared, known,
14170                "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
14171                 suggester would propose the wrong set for that block"
14172            );
14173            checked += 1;
14174        }
14175        assert!(
14176            checked >= 30,
14177            "expected every Raw* struct to be scanned, found only {checked}"
14178        );
14179    }
14180
14181    /// Source-derived for the same reason its sibling above is: a `Raw*`-typed field added to a
14182    /// block and left unwalked reports none of the keys inside that block, and reports nothing
14183    /// about having done so. The scan is keyed on the *parent's field*, not on the child's type,
14184    /// because one type can sit under several parents — `RawInferenceDriver` is both
14185    /// `inference.driver` and each alternate's `driver` — and a check keyed on the type would call
14186    /// one covered because the other is walked.
14187    #[test]
14188    fn every_raw_struct_descends_into_the_blocks_it_owns() {
14189        let source = include_str!("runtime_manifest.rs");
14190        let lines: Vec<&str> = source.lines().collect();
14191
14192        let mut structs = 0usize;
14193        let mut children = 0usize;
14194        for (start, line) in lines.iter().enumerate() {
14195            let Some(name) = line
14196                .strip_prefix("struct Raw")
14197                .and_then(|rest| rest.strip_suffix(" {"))
14198            else {
14199                continue;
14200            };
14201            let name = format!("Raw{name}");
14202            let end = (start + 1..lines.len())
14203                .find(|index| lines[*index] == "}")
14204                .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
14205            let owned = block_typed_fields(&lines[start + 1..end]);
14206            structs += 1;
14207
14208            if owned.is_empty() {
14209                continue;
14210            }
14211            let block = raw_block_impl_of(source, &name);
14212            assert!(
14213                block.contains("fn walk_children"),
14214                "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
14215                 `walk_children`, so every key written inside {} would go unreported",
14216                if owned.len() == 1 { "" } else { "s" },
14217                owned.join(", "),
14218                if owned.len() == 1 { "it" } else { "them" }
14219            );
14220            for field in &owned {
14221                assert!(
14222                    mentions_field(&block, field),
14223                    "{name}'s `walk_children` never touches `self.{field}`, so every key written \
14224                     inside that block would go unreported — descend into it with \
14225                     `collect_block(.., &child_path(path, \"{field}\"), out)`"
14226                );
14227                children += 1;
14228            }
14229        }
14230        assert!(
14231            structs >= 30,
14232            "expected every Raw* struct to be scanned, found only {structs}"
14233        );
14234        assert!(
14235            children >= 31,
14236            "expected every nested block field to be scanned, found only {children}"
14237        );
14238    }
14239
14240    /// Whether `impl` reads `self.<field>` as that whole field, not as the head of a longer one:
14241    /// a plain substring test lets a struct owning both `foo` and `foo_bar` walk only `foo_bar`
14242    /// and still be called covered, which is the silent pass this scan exists to deny.
14243    fn mentions_field(impl_body: &str, field: &str) -> bool {
14244        let needle = format!("self.{field}");
14245        impl_body.match_indices(&needle).any(|(at, _)| {
14246            impl_body[at + needle.len()..]
14247                .chars()
14248                .next()
14249                .is_none_or(|next| !next.is_alphanumeric() && next != '_')
14250        })
14251    }
14252
14253    /// The fields of one struct body whose declared type names another `Raw*` type, in
14254    /// declaration order. Comment and attribute lines are skipped, so a doc comment that mentions
14255    /// a `Raw*` type by name is not read as a field.
14256    fn block_typed_fields(body: &[&str]) -> Vec<String> {
14257        let mut fields = Vec::new();
14258        for line in body {
14259            let trimmed = line.trim();
14260            if trimmed.starts_with("//") || trimmed.starts_with("#[") {
14261                continue;
14262            }
14263            let Some((field, declared)) = line
14264                .strip_prefix("    ")
14265                .and_then(|rest| rest.split_once(':'))
14266            else {
14267                continue;
14268            };
14269            if field.is_empty()
14270                || !field
14271                    .chars()
14272                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
14273            {
14274                continue;
14275            }
14276            if declared.contains("Raw") {
14277                fields.push(field.to_string());
14278            }
14279        }
14280        fields
14281    }
14282
14283    /// The text of `impl RawBlock for <name>`, cut at the next line that is exactly `}` at column
14284    /// 0 so one impl's body cannot be read as the next one's.
14285    fn raw_block_impl_of(source: &str, name: &str) -> String {
14286        let header = format!("impl RawBlock for {name} {{");
14287        let body = source
14288            .split_once(&header)
14289            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
14290            .1;
14291        body.split_once("\n}\n")
14292            .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
14293            .0
14294            .to_string()
14295    }
14296
14297    /// The walk's own result, taken straight off a deserialized `RawRuntimeManifest`.
14298    ///
14299    /// Deliberately not routed through [`RuntimeManifest::from_yaml_str`]: that validates, and a
14300    /// fixture that names every block at once would have to satisfy every semantic rule as well
14301    /// to reach the walk. What is under test here is which blocks are descended into, which the
14302    /// validator has no part in.
14303    fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
14304        let raw: RawRuntimeManifest = serde_norway::from_str(yaml).expect("fixture must parse");
14305        collect_unknown_keys(&raw)
14306            .into_iter()
14307            .map(|key| (key.key, key.block_path))
14308            .collect()
14309    }
14310
14311    #[test]
14312    fn spawn_workdir_snapshot_is_a_known_key_beside_a_sibling_probe() {
14313        let keys = raw_unknown_keys(
14314            "name: cap\nversion: 0.1.0\ncapabilities:\n  spawn:\n    allow: [local/r]\n    \
14315             workdir_snapshot: [local/r]\n    probe_spawn: 1\n",
14316        );
14317        assert_eq!(
14318            keys,
14319            vec![("probe_spawn".to_string(), "capabilities.spawn".to_string())]
14320        );
14321    }
14322
14323    /// Every block the manifest type owns, each carrying one key no field of it claims, reported
14324    /// once and at its own dotted path. A block added after this one is held to the same rule by
14325    /// `every_raw_struct_descends_into_the_blocks_it_owns`; this is what "descended into" means.
14326    #[test]
14327    fn every_block_reports_its_own_unknown_key_at_its_own_path() {
14328        let keys = raw_unknown_keys(
14329            r#"
14330name: every-block
14331version: 0.1.0
14332probe_root: 1
14333artifacts:
14334  - name: local/t
14335    version: 0.1.0
14336    runtime: tool
14337    probe_artifact: 1
14338    capabilities:
14339      probe_artifact_capabilities: 1
14340      shell:
14341        probe_artifact_shell: 1
14342capabilities:
14343  probe_capabilities: 1
14344  network:
14345    probe_network: 1
14346  peer_fetch:
14347    probe_peer_fetch: 1
14348  filesystem:
14349    probe_filesystem: 1
14350  shell:
14351    probe_shell: 1
14352    interpreter_runtime:
14353      - binary: python3
14354        probe_interpreter_runtime: 1
14355        dirs:
14356          - path: /usr/lib/python3
14357            probe_interpreter_runtime_dir: 1
14358    staged_runtime:
14359      - binary: rg
14360        probe_staged_runtime: 1
14361  spawn:
14362    probe_spawn: 1
14363  env:
14364    probe_env: 1
14365  limits:
14366    probe_limits: 1
14367  resources:
14368    probe_resources: 1
14369  state:
14370    probe_state: 1
14371  task_io:
14372    probe_task_io: 1
14373  conversation:
14374    probe_conversation: 1
14375  install:
14376    probe_install: 1
14377inference:
14378  probe_inference: 1
14379  driver:
14380    probe_driver: 1
14381  compaction:
14382    probe_compaction: 1
14383context:
14384  probe_context: 1
14385observability:
14386  probe_observability: 1
14387  eval:
14388    probe_eval: 1
14389    scorers:
14390      - type: exact_match
14391        probe_scorer: 1
14392trace:
14393  probe_trace: 1
14394network:
14395  probe_network_config: 1
14396lifecycle:
14397  probe_lifecycle: 1
14398exports:
14399  probe_exports: 1
14400  files:
14401    probe_files: 1
14402  peer_files:
14403    probe_peer_files: 1
14404control:
14405  probe_control: 1
14406"#,
14407        );
14408
14409        let expected: Vec<(String, String)> = [
14410            ("probe_root", ""),
14411            ("probe_artifact", "artifacts[0]"),
14412            ("probe_artifact_capabilities", "artifacts[0].capabilities"),
14413            ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
14414            ("probe_capabilities", "capabilities"),
14415            ("probe_network", "capabilities.network"),
14416            ("probe_peer_fetch", "capabilities.peer_fetch"),
14417            ("probe_filesystem", "capabilities.filesystem"),
14418            ("probe_shell", "capabilities.shell"),
14419            (
14420                "probe_interpreter_runtime",
14421                "capabilities.shell.interpreter_runtime[0]",
14422            ),
14423            (
14424                "probe_interpreter_runtime_dir",
14425                "capabilities.shell.interpreter_runtime[0].dirs[0]",
14426            ),
14427            (
14428                "probe_staged_runtime",
14429                "capabilities.shell.staged_runtime[0]",
14430            ),
14431            ("probe_spawn", "capabilities.spawn"),
14432            ("probe_env", "capabilities.env"),
14433            ("probe_limits", "capabilities.limits"),
14434            ("probe_resources", "capabilities.resources"),
14435            ("probe_state", "capabilities.state"),
14436            ("probe_task_io", "capabilities.task_io"),
14437            ("probe_conversation", "capabilities.conversation"),
14438            ("probe_install", "capabilities.install"),
14439            ("probe_inference", "inference"),
14440            ("probe_driver", "inference.driver"),
14441            ("probe_compaction", "inference.compaction"),
14442            ("probe_context", "context"),
14443            ("probe_observability", "observability"),
14444            ("probe_eval", "observability.eval"),
14445            ("probe_scorer", "observability.eval.scorers[0]"),
14446            ("probe_trace", "trace"),
14447            ("probe_network_config", "network"),
14448            ("probe_lifecycle", "lifecycle"),
14449            ("probe_exports", "exports"),
14450            ("probe_files", "exports.files"),
14451            ("probe_peer_files", "exports.peer_files"),
14452            ("probe_control", "control"),
14453        ]
14454        .into_iter()
14455        .map(|(key, path)| (key.to_string(), path.to_string()))
14456        .collect();
14457
14458        assert_eq!(keys, expected);
14459    }
14460
14461    /// The names serde matches on for one struct body: field names in declaration order, with a
14462    /// `#[serde(rename = "...")]` field under its renamed spelling, and the overflow map itself
14463    /// excluded because it claims no name of its own.
14464    fn serde_field_names(body: &[&str]) -> Vec<String> {
14465        let mut names = Vec::new();
14466        let mut rename: Option<String> = None;
14467        for line in body {
14468            let trimmed = line.trim();
14469            if trimmed.starts_with("#[") {
14470                if let Some(rest) = trimmed.split("rename = \"").nth(1) {
14471                    if !trimmed.contains("rename_all") {
14472                        rename = rest.split('"').next().map(str::to_string);
14473                    }
14474                }
14475                continue;
14476            }
14477            if trimmed.starts_with("//") {
14478                continue;
14479            }
14480            let Some(field) = line
14481                .strip_prefix("    ")
14482                .and_then(|rest| rest.split(':').next())
14483            else {
14484                continue;
14485            };
14486            if field.is_empty()
14487                || !field
14488                    .chars()
14489                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
14490            {
14491                continue;
14492            }
14493            if field == "unknown" {
14494                rename = None;
14495                continue;
14496            }
14497            names.push(rename.take().unwrap_or_else(|| field.to_string()));
14498        }
14499        names
14500    }
14501
14502    /// The `KNOWN_KEYS` array `impl RawBlock for <name>` declares, read out of the source text so
14503    /// the assertion compares two independent readings of the same file rather than one value
14504    /// against itself.
14505    fn known_keys_of(source: &str, name: &str) -> Vec<String> {
14506        let header = format!("impl RawBlock for {name} {{");
14507        let body = source
14508            .split_once(&header)
14509            .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
14510            .1;
14511        // rustfmt moves an array too long for the declaration's line onto the next one.
14512        let array = body
14513            .split_once("KNOWN_KEYS: &'static [&'static str] =")
14514            .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
14515            .1
14516            .trim_start()
14517            .strip_prefix("&[")
14518            .unwrap_or_else(|| panic!("{name}'s KNOWN_KEYS is not an array literal"))
14519            .split_once("];")
14520            .expect("KNOWN_KEYS array is unterminated")
14521            .0;
14522        array
14523            .split('"')
14524            .skip(1)
14525            .step_by(2)
14526            .map(str::to_string)
14527            .collect()
14528    }
14529}
14530
14531#[cfg(test)]
14532mod control_tests {
14533    use super::*;
14534
14535    const TOOL_ENTRY: &str = "  - name: local/card-api\n    version: 0.1.0\n    runtime: tool\n    gateway:\n      endpoint: https://cards.example.com\n      api_key: ${CARD_TOKEN}\n";
14536
14537    /// An `http` agent capsule whose driver is keyed by `${DRIVER_KEY}` and whose `card-api` tool
14538    /// is keyed by `${CARD_TOKEN}`, with `control` spliced in at the top level.
14539    fn agent(transport: &str, control: &str) -> String {
14540        let driver = if transport == "http" {
14541            "  - name: local/drv\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.example.com\n      api_key: ${DRIVER_KEY}\n"
14542        } else {
14543            "  - name: local/drv\n    version: 0.1.0\n    runtime: driver\n"
14544        };
14545        format!(
14546            "name: cap\nversion: 0.0.1\nartifacts:\n{driver}{TOOL_ENTRY}inference:\n  transport: {transport}\n  model: m\n  driver:\n    artifact: local/drv\n{control}"
14547        )
14548    }
14549
14550    fn refusal(yaml: &str) -> (String, String) {
14551        match RuntimeManifest::from_yaml_str(yaml) {
14552            Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14553            other => panic!("expected InvalidControl, got {other:?}"),
14554        }
14555    }
14556
14557    #[test]
14558    fn control_absent_empty_and_empty_lists_load_as_none() {
14559        for control in [
14560            "",
14561            "control: {}\n",
14562            "control:\n",
14563            "control:\n  settings: []\n  secrets: []\n",
14564        ] {
14565            let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
14566                .unwrap_or_else(|err| panic!("{control:?}: {err}"));
14567            assert_eq!(manifest.control, None, "{control:?}");
14568        }
14569    }
14570
14571    #[test]
14572    fn control_parses_settings_and_secrets_in_manifest_order() {
14573        let yaml = agent(
14574            "http",
14575            "control:\n  settings: [inference.max_tokens]\n  secrets: [CARD_TOKEN]\n",
14576        );
14577        for manifest in [
14578            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14579            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14580        ] {
14581            let control = manifest.control.unwrap();
14582            assert_eq!(
14583                control.settings,
14584                vec![ControllableSetting::InferenceMaxTokens]
14585            );
14586            assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
14587            assert!(control.declares_secret("CARD_TOKEN"));
14588            assert!(!control.declares_secret("DRIVER_KEY"));
14589        }
14590    }
14591
14592    #[test]
14593    fn control_setting_wire_names_round_trip() {
14594        for setting in ControllableSetting::ALL {
14595            assert_eq!(
14596                ControllableSetting::from_wire_name(setting.wire_name()),
14597                Some(*setting)
14598            );
14599        }
14600        assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
14601    }
14602
14603    /// Every refusal names `control.settings` or `control.secrets` as its field and quotes the
14604    /// entry it refused.
14605    #[test]
14606    fn control_manifest_refusals_name_the_field() {
14607        let cases: &[(&str, &str, &str, &str)] = &[
14608            (
14609                "http",
14610                "control:\n  settings: [inference.model]\n",
14611                "control.settings",
14612                "'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
14613            ),
14614            (
14615                "process",
14616                "control:\n  settings: [inference.max_tokens]\n",
14617                "control.settings",
14618                "'inference.max_tokens' is controllable only under inference.transport: http",
14619            ),
14620            (
14621                "http",
14622                "control:\n  settings: [inference.max_tokens, inference.max_tokens]\n",
14623                "control.settings",
14624                "'inference.max_tokens' is listed more than once",
14625            ),
14626            (
14627                "http",
14628                "control:\n  secrets: [CARD_TOKEN, CARD_TOKEN]\n",
14629                "control.secrets",
14630                "'CARD_TOKEN' is listed more than once",
14631            ),
14632            (
14633                "http",
14634                "control:\n  secrets: [card-token]\n",
14635                "control.secrets",
14636                "'card-token' is not a credential name",
14637            ),
14638            (
14639                "http",
14640                "control:\n  secrets: [OTHER_TOKEN]\n",
14641                "control.secrets",
14642                "'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
14643            ),
14644            (
14645                "http",
14646                "control:\n  secrets: [DRIVER_KEY]\n",
14647                "control.secrets",
14648                "'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.local/drv.gateway.api_key)",
14649            ),
14650            (
14651                "http",
14652                "control:\n  settings: inference.max_tokens\n",
14653                "control.settings",
14654                "must be a list of names",
14655            ),
14656            (
14657                "http",
14658                "control:\n  secrets: [1]\n",
14659                "control.secrets",
14660                "must be a list of names",
14661            ),
14662        ];
14663        for (transport, control, field, message) in cases {
14664            let (got_field, got_message) = refusal(&agent(transport, control));
14665            assert_eq!(&got_field, field, "{control:?}");
14666            assert!(
14667                got_message.starts_with(message),
14668                "{control:?}: {got_message}"
14669            );
14670            let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
14671                .unwrap_err()
14672                .to_string();
14673            assert!(
14674                rendered.contains(&format!("invalid control config for '{field}'")),
14675                "{rendered}"
14676            );
14677        }
14678    }
14679
14680    #[test]
14681    fn control_on_a_capsule_with_no_inference_is_refused() {
14682        let yaml = format!(
14683            "name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n  secrets: [CARD_TOKEN]\n"
14684        );
14685        let (field, message) = refusal(&yaml);
14686        assert_eq!(field, "control");
14687        assert!(message.contains("no inference: block"), "{message}");
14688    }
14689
14690    #[test]
14691    fn control_unknown_key_is_reported_once_at_its_block() {
14692        let manifest = RuntimeManifest::from_yaml_str(&agent(
14693            "http",
14694            "control:\n  settings: [inference.max_tokens]\n  agent_may_call: true\n",
14695        ))
14696        .unwrap();
14697        let keys: Vec<(&str, &str)> = manifest
14698            .unknown_keys
14699            .iter()
14700            .map(|key| (key.key.as_str(), key.block_path.as_str()))
14701            .collect();
14702        assert_eq!(keys, vec![("agent_may_call", "control")]);
14703        assert_eq!(
14704            manifest.control.unwrap().settings,
14705            vec![ControllableSetting::InferenceMaxTokens]
14706        );
14707    }
14708}
14709
14710#[cfg(test)]
14711mod driver_choice_tests {
14712    use super::*;
14713
14714    const DRV: &str = "  - name: local/drv\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://api.example.com\n      api_key: ${DRIVER_KEY}\n";
14715    const ALT: &str = "  - name: local/alt\n    version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: https://alt.example.com\n      api_key: ${ALT_KEY}\n";
14716    const ALT_NO_GATEWAY: &str = "  - name: local/alt\n    version: 0.1.0\n    runtime: driver\n";
14717    const TOOL: &str = "  - name: local/card-api\n    version: 0.1.0\n    runtime: tool\n";
14718    const GPT: &str =
14719        "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: local/alt\n";
14720    const HAIKU: &str =
14721        "    - name: haiku\n      model: haiku\n      driver:\n        artifact: local/drv\n";
14722    const SWITCHABLE: &str = "control:\n  settings: [inference.driver]\n";
14723
14724    /// An `http` capsule on `drv` with model `m`, `artifacts` after `drv`, `alternates` as the
14725    /// `inference.alternates` list body (empty for no key), and `control` at the top level.
14726    fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
14727        let alternates = if alternates.is_empty() {
14728            String::new()
14729        } else {
14730            format!("  alternates:\n{alternates}")
14731        };
14732        format!(
14733            "name: cap\nversion: 0.0.1\nartifacts:\n{DRV}{artifacts}inference:\n  transport: http\n  model: m\n  driver:\n    artifact: local/drv\n{alternates}{control}"
14734        )
14735    }
14736
14737    fn refusal(yaml: &str) -> (String, String) {
14738        match RuntimeManifest::from_yaml_str(yaml) {
14739            Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
14740            | Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14741            other => panic!("expected an inference or control refusal, got {other:?}"),
14742        }
14743    }
14744
14745    #[test]
14746    fn driver_choice_alternates_parse_in_manifest_order() {
14747        let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
14748        for manifest in [
14749            RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14750            RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14751        ] {
14752            let inference = manifest.inference.unwrap();
14753            assert_eq!(
14754                inference.alternates,
14755                vec![
14756                    InferenceAlternate {
14757                        name: "gpt".to_string(),
14758                        model: "gpt-5".to_string(),
14759                        driver: "local/alt".to_string(),
14760                    },
14761                    InferenceAlternate {
14762                        name: "haiku".to_string(),
14763                        model: "haiku".to_string(),
14764                        driver: "local/drv".to_string(),
14765                    },
14766                ]
14767            );
14768            let control = manifest.control.unwrap();
14769            assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
14770            assert!(control.agent_settings.is_empty());
14771        }
14772    }
14773
14774    #[test]
14775    fn driver_choice_absent_alternates_are_empty() {
14776        let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
14777        assert!(manifest.inference.unwrap().alternates.is_empty());
14778    }
14779
14780    #[test]
14781    fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
14782        let manifest = RuntimeManifest::from_yaml_str(&capsule(
14783            ALT,
14784            GPT,
14785            "control:\n  agent_settings: [inference.driver]\n",
14786        ))
14787        .unwrap();
14788        let control = manifest.control.unwrap();
14789        assert!(control.settings.is_empty());
14790        assert_eq!(
14791            control.agent_settings,
14792            vec![ControllableSetting::InferenceDriver]
14793        );
14794        assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
14795        assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
14796        assert!(control.secrets.is_empty());
14797    }
14798
14799    #[test]
14800    fn driver_choice_wire_name_round_trips() {
14801        assert_eq!(
14802            ControllableSetting::from_wire_name("inference.driver"),
14803            Some(ControllableSetting::InferenceDriver)
14804        );
14805        assert_eq!(
14806            ControllableSetting::InferenceDriver.wire_name(),
14807            "inference.driver"
14808        );
14809        assert_eq!(
14810            ControllableSetting::AGENT_SETTABLE,
14811            &[ControllableSetting::InferenceDriver]
14812        );
14813    }
14814
14815    /// An alternate's driver may carry `gateway:`, which a driver entry nothing names may not.
14816    #[test]
14817    fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
14818        RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
14819            .expect("an alternate's driver carries its own gateway");
14820        match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
14821            Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
14822                assert!(message.contains("inference.alternates"), "{message}")
14823            }
14824            other => panic!("expected InvalidArtifact, got {other:?}"),
14825        }
14826    }
14827
14828    /// Two models of one provider: the alternate shares the primary's driver and gateway.
14829    #[test]
14830    fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
14831        let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
14832        assert_eq!(
14833            manifest.inference.unwrap().alternates[0].driver,
14834            "local/drv"
14835        );
14836    }
14837
14838    #[test]
14839    fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
14840        let manifest = RuntimeManifest::from_yaml_str(&capsule(
14841            ALT,
14842            GPT,
14843            "control:\n  settings: [inference.driver]\n  secrets: [ALT_KEY]\n",
14844        ))
14845        .unwrap();
14846        assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
14847        let (field, message) = refusal(&capsule(
14848            ALT,
14849            GPT,
14850            "control:\n  settings: [inference.driver]\n  secrets: [DRIVER_KEY]\n",
14851        ));
14852        assert_eq!(field, "control.secrets");
14853        assert!(
14854            message.contains("keys the configured inference driver's gateway"),
14855            "{message}"
14856        );
14857    }
14858
14859    #[test]
14860    fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
14861        let alternate = "    - name: gpt\n      model: gpt-5\n      temperature: 1\n      driver:\n        artifact: local/alt\n";
14862        let manifest =
14863            RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
14864        let keys: Vec<(&str, &str)> = manifest
14865            .unknown_keys
14866            .iter()
14867            .map(|key| (key.key.as_str(), key.block_path.as_str()))
14868            .collect();
14869        assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
14870    }
14871
14872    /// Every refusal the alternates and the driver setting can draw, each naming its field and
14873    /// rendering as the manifest error `E-MAN-003` maps from.
14874    #[test]
14875    fn driver_choice_manifest_refusals_name_the_field() {
14876        let alternate = |name: &str, model: &str, driver: &str| {
14877            format!("    - name: {name}\n      model: {model}\n      driver:\n        artifact: {driver}\n")
14878        };
14879        let process = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/drv\n    version: 0.1.0\n    runtime: driver\ninference:\n  transport: process\n  driver:\n    artifact: local/drv\n";
14880        let cases: Vec<(String, &str, &str)> = vec![
14881            (
14882                format!("{process}  alternates:\n{GPT}"),
14883                "inference.alternates",
14884                "is not valid with transport: process",
14885            ),
14886            (
14887                format!("{process}control:\n  settings: [inference.driver]\n"),
14888                "control.settings",
14889                "'inference.driver' is controllable only under inference.transport: http",
14890            ),
14891            (
14892                capsule(ALT, &alternate("primary", "gpt-5", "local/alt"), SWITCHABLE),
14893                "inference.alternates[0].name",
14894                "'primary' is reserved",
14895            ),
14896            (
14897                capsule(
14898                    ALT,
14899                    &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt", "gpt-4", "local/alt")),
14900                    SWITCHABLE,
14901                ),
14902                "inference.alternates[1].name",
14903                "'gpt' names more than one alternate",
14904            ),
14905            (
14906                capsule(ALT, &alternate("GPT", "gpt-5", "local/alt"), SWITCHABLE),
14907                "inference.alternates[0].name",
14908                "'GPT' is not a driver choice name",
14909            ),
14910            (
14911                capsule(ALT, &alternate("5gpt", "gpt-5", "local/alt"), SWITCHABLE),
14912                "inference.alternates[0].name",
14913                "'5gpt' is not a driver choice name",
14914            ),
14915            (
14916                capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "local/alt"), SWITCHABLE),
14917                "inference.alternates[0].name",
14918                "is not a driver choice name",
14919            ),
14920            (
14921                capsule(ALT, "    - name: gpt\n      model: \"\"\n      driver:\n        artifact: local/alt\n", SWITCHABLE),
14922                "inference.alternates[0].model",
14923                "missing required field",
14924            ),
14925            (
14926                capsule(ALT, "    - name: gpt\n      model: gpt-5\n", SWITCHABLE),
14927                "inference.alternates[0].driver.artifact",
14928                "missing required field",
14929            ),
14930            (
14931                capsule("", &alternate("gpt", "gpt-5", "local/nowhere"), SWITCHABLE),
14932                "inference.alternates[0].driver.artifact",
14933                "artifact 'local/nowhere' is not declared in artifacts:",
14934            ),
14935            (
14936                capsule(TOOL, &alternate("gpt", "gpt-5", "local/card-api"), SWITCHABLE),
14937                "inference.alternates[0].driver.artifact",
14938                "artifact 'local/card-api' is declared with runtime: tool",
14939            ),
14940            (
14941                capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "local/alt"), SWITCHABLE),
14942                "inference.alternates[0].driver.artifact",
14943                "artifact 'local/alt' declares no gateway.endpoint",
14944            ),
14945            (
14946                capsule(
14947                    ALT,
14948                    "    - name: gpt\n      model: gpt-5\n      driver:\n        artifact: local/alt\n        config:\n          thinking: enabled\n",
14949                    SWITCHABLE,
14950                ),
14951                "inference.alternates[0].driver.config",
14952                "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",
14953            ),
14954            (
14955                capsule("", &alternate("same", "m", "local/drv"), SWITCHABLE),
14956                "inference.alternates[0]",
14957                "'same' is driver 'local/drv' with model 'm', which is the primary choice",
14958            ),
14959            (
14960                capsule(
14961                    ALT,
14962                    &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt-too", "gpt-5", "local/alt")),
14963                    SWITCHABLE,
14964                ),
14965                "inference.alternates[1]",
14966                "'gpt-too' is driver 'local/alt' with model 'gpt-5', which alternate 'gpt' already is",
14967            ),
14968            (
14969                capsule(ALT, GPT, ""),
14970                "inference.alternates",
14971                "declares alternates that nothing could ever select",
14972            ),
14973            (
14974                capsule(ALT, GPT, "control:\n  settings: [inference.max_tokens]\n"),
14975                "inference.alternates",
14976                "declares alternates that nothing could ever select",
14977            ),
14978            (
14979                capsule("", "", SWITCHABLE),
14980                "control.settings",
14981                "'inference.driver' selects among inference.alternates, and this capsule declares none",
14982            ),
14983            (
14984                capsule("", "", "control:\n  agent_settings: [inference.driver]\n"),
14985                "control.agent_settings",
14986                "'inference.driver' selects among inference.alternates, and this capsule declares none",
14987            ),
14988            (
14989                capsule(ALT, GPT, "control:\n  agent_settings: [inference.max_tokens]\n"),
14990                "control.agent_settings",
14991                "'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
14992            ),
14993            (
14994                capsule(
14995                    ALT,
14996                    GPT,
14997                    "control:\n  agent_settings: [inference.driver, inference.driver]\n",
14998                ),
14999                "control.agent_settings",
15000                "'inference.driver' is listed more than once",
15001            ),
15002            (
15003                capsule(ALT, GPT, "control:\n  agent_settings: inference.driver\n"),
15004                "control.agent_settings",
15005                "must be a list of names",
15006            ),
15007        ];
15008        for (yaml, field, message) in &cases {
15009            let (got_field, got_message) = refusal(yaml);
15010            assert_eq!(&got_field, field, "{yaml}");
15011            assert!(got_message.contains(message), "{yaml}\n{got_message}");
15012            let rendered = RuntimeManifest::from_yaml_str(yaml)
15013                .unwrap_err()
15014                .to_string();
15015            assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
15016        }
15017    }
15018}
15019
15020#[cfg(test)]
15021mod network_authentication_tests {
15022    use super::*;
15023
15024    fn network_authentication_of(
15025        block: &str,
15026    ) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
15027        RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
15028            .map(|manifest| manifest.network.and_then(|network| network.authentication))
15029    }
15030
15031    fn network_authentication_refusal(block: &str) -> (String, String) {
15032        match network_authentication_of(block) {
15033            Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
15034            other => panic!("expected InvalidNetworkConfig, got {other:?}"),
15035        }
15036    }
15037
15038    #[test]
15039    fn network_authentication_with_credentials_parses_sorted_by_name() {
15040        let auth = network_authentication_of(
15041            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15042             watcher:\n        scopes: [GetTask, murmur.WatchSession]\n      \
15043             reader:\n        scopes: [resources/files]\n",
15044        )
15045        .unwrap()
15046        .expect("declared");
15047        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
15048        assert_eq!(
15049            auth.credentials,
15050            vec![
15051                DoorCredential {
15052                    name: "reader".to_string(),
15053                    scopes: vec!["resources/files".to_string()],
15054                },
15055                DoorCredential {
15056                    name: "watcher".to_string(),
15057                    // Declared order, not sorted.
15058                    scopes: vec!["GetTask".to_string(), "murmur.WatchSession".to_string()],
15059                },
15060            ]
15061        );
15062    }
15063
15064    #[test]
15065    fn network_authentication_scheme_alone_parses() {
15066        let auth = network_authentication_of("network:\n  authentication:\n    scheme: bearer\n")
15067            .unwrap()
15068            .expect("declared");
15069        assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
15070        assert!(auth.credentials.is_empty());
15071    }
15072
15073    #[test]
15074    fn network_authentication_absent_is_a_public_door() {
15075        assert_eq!(network_authentication_of("").unwrap(), None);
15076        assert_eq!(
15077            network_authentication_of("network:\n  internal_port: 8080\n").unwrap(),
15078            None
15079        );
15080        let manifest = RuntimeManifest::from_yaml_str(
15081            "name: cap\nversion: 0.1.0\nnetwork:\n  internal_port: 8080\n",
15082        )
15083        .unwrap();
15084        assert_eq!(
15085            manifest.network,
15086            Some(NetworkConfig {
15087                internal_port: Some(8080),
15088                authentication: None,
15089            })
15090        );
15091    }
15092
15093    #[test]
15094    fn network_authentication_without_a_scheme_is_refused() {
15095        let (field, message) = network_authentication_refusal(
15096            "network:\n  authentication:\n    credentials:\n      w:\n        scopes: [GetTask]\n",
15097        );
15098        assert_eq!(field, "network.authentication.scheme");
15099        assert!(message.contains("required"), "{message}");
15100    }
15101
15102    #[test]
15103    fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
15104        for scheme in ["mtls", "Bearer", "basic"] {
15105            let (field, message) = network_authentication_refusal(&format!(
15106                "network:\n  authentication:\n    scheme: {scheme}\n"
15107            ));
15108            assert_eq!(field, "network.authentication.scheme");
15109            assert!(message.contains("bearer"), "{message}");
15110            assert!(message.contains(scheme), "{message}");
15111        }
15112    }
15113
15114    #[test]
15115    fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
15116        let long = format!("a{}", "b".repeat(32));
15117        for name in [
15118            "operator",
15119            "Watcher",
15120            "1st",
15121            "_x",
15122            "-x",
15123            "wat.cher",
15124            long.as_str(),
15125        ] {
15126            let (field, message) = network_authentication_refusal(&format!(
15127                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15128                 '{name}':\n        scopes: [GetTask]\n"
15129            ));
15130            assert_eq!(field, "network.authentication.credentials", "{name}");
15131            assert!(message.contains(name), "{message}");
15132        }
15133        let longest = format!("a{}", "b".repeat(31));
15134        for name in ["w", "a-b_c9", longest.as_str()] {
15135            network_authentication_of(&format!(
15136                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15137                 {name}:\n        scopes: [GetTask]\n"
15138            ))
15139            .unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
15140        }
15141    }
15142
15143    #[test]
15144    fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
15145        let credential = |body: &str| {
15146            format!(
15147                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15148                 watcher:\n{body}"
15149            )
15150        };
15151        let field = "network.authentication.credentials.watcher.scopes";
15152
15153        let (got, message) = network_authentication_refusal(&credential("        {}\n"));
15154        assert_eq!(got, field);
15155        assert!(message.contains("required"), "{message}");
15156
15157        let (got, message) = network_authentication_refusal(&credential("        scopes: []\n"));
15158        assert_eq!(got, field);
15159        assert!(message.contains("no scope"), "{message}");
15160
15161        let (got, message) =
15162            network_authentication_refusal(&credential("        scopes: [tasks/list]\n"));
15163        assert_eq!(got, field);
15164        assert!(message.contains("tasks/list"), "{message}");
15165        assert!(message.contains("resources/files"), "{message}");
15166
15167        let (got, message) =
15168            network_authentication_refusal(&credential("        scopes: [GetExtendedAgentCard]\n"));
15169        assert_eq!(got, field);
15170        assert!(message.contains("GetExtendedAgentCard"), "{message}");
15171
15172        let (got, message) = network_authentication_refusal(&credential(
15173            "        scopes: [GetTask, murmur.WatchSession, GetTask]\n",
15174        ));
15175        assert_eq!(got, field);
15176        assert!(message.contains("more than once"), "{message}");
15177    }
15178
15179    #[test]
15180    fn network_authentication_refuses_a_retired_scope_naming_its_replacement() {
15181        let field = "network.authentication.credentials.watcher.scopes";
15182        for (retired, replacement) in [
15183            ("message/send", "SendMessage"),
15184            ("message/stream", "SendStreamingMessage"),
15185            ("tasks/get", "GetTask"),
15186            ("tasks/cancel", "CancelTask"),
15187            ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
15188            ("stream/watch", "murmur.WatchSession"),
15189            ("session/stop", "murmur.StopSession"),
15190        ] {
15191            let error = network_authentication_of(&format!(
15192                "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15193                 watcher:\n        scopes: [murmur.WatchSession, {retired}]\n"
15194            ))
15195            .unwrap_err();
15196            let rendered = error.to_string();
15197            match error {
15198                RuntimeManifestError::RetiredDoorScope {
15199                    field: got_field,
15200                    scope,
15201                    replacement: got_replacement,
15202                } => {
15203                    assert_eq!(got_field, field);
15204                    assert_eq!(scope, retired);
15205                    assert_eq!(got_replacement, replacement);
15206                }
15207                other => panic!("expected RetiredDoorScope for {retired}, got {other:?}"),
15208            }
15209            assert!(
15210                rendered.contains(&format!(
15211                    "'{retired}' is a retired name of a door method, and the door answers only \
15212                     the names its card lists"
15213                )),
15214                "{rendered}"
15215            );
15216            assert!(rendered.contains(field), "{rendered}");
15217        }
15218    }
15219
15220    #[test]
15221    fn network_authentication_accepts_the_v1_scopes() {
15222        let auth = network_authentication_of(
15223            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15224             watcher:\n        scopes: [GetTask, murmur.WatchSession, murmur.StopSession]\n",
15225        )
15226        .unwrap()
15227        .unwrap();
15228        assert_eq!(
15229            auth.credentials[0].scopes,
15230            ["GetTask", "murmur.WatchSession", "murmur.StopSession"]
15231        );
15232    }
15233
15234    /// The two task methods beside `GetTask` are scopes a credential may list, in the method
15235    /// table's order.
15236    #[test]
15237    fn door_scope_list_tasks_and_subscribe_to_task_load_on_a_credential() {
15238        let auth = network_authentication_of(
15239            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15240             alice:\n        scopes: [SendMessage, ListTasks, SubscribeToTask]\n",
15241        )
15242        .unwrap()
15243        .unwrap();
15244        assert_eq!(
15245            auth.credentials[0].scopes,
15246            ["SendMessage", "ListTasks", "SubscribeToTask"]
15247        );
15248        assert_eq!(
15249            DOOR_SCOPES,
15250            [
15251                "SendMessage",
15252                "SendStreamingMessage",
15253                "murmur.WatchSession",
15254                "GetTask",
15255                "ListTasks",
15256                "CancelTask",
15257                "SubscribeToTask",
15258                "murmur.StopSession",
15259                "resources/files",
15260            ]
15261        );
15262    }
15263
15264    #[test]
15265    fn network_authentication_accepts_every_door_scope() {
15266        let auth = network_authentication_of(&format!(
15267            "network:\n  authentication:\n    scheme: bearer\n    credentials:\n      \
15268             everything:\n        scopes: [{}]\n",
15269            DOOR_SCOPES.join(", ")
15270        ))
15271        .unwrap()
15272        .unwrap();
15273        assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
15274        assert_eq!(OPERATOR_CREDENTIAL, "operator");
15275    }
15276
15277    #[test]
15278    fn network_authentication_is_refused_beside_spawn_allow() {
15279        let (field, message) = network_authentication_refusal(
15280            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
15281             allow: [local/worker]\n",
15282        );
15283        assert_eq!(field, "network.authentication");
15284        assert!(message.contains("capabilities.spawn.allow"), "{message}");
15285        assert!(message.contains("token"), "{message}");
15286        let error = RuntimeManifest::from_yaml_str(
15287            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n\
15288             capabilities:\n  spawn:\n    allow: [local/worker]\n",
15289        )
15290        .unwrap_err()
15291        .to_string();
15292        assert!(error.contains("network.authentication"), "{error}");
15293        assert!(error.contains("capabilities.spawn.allow"), "{error}");
15294
15295        // An empty allow-list delegates nothing, so it does not conflict.
15296        network_authentication_of(
15297            "network:\n  authentication:\n    scheme: bearer\ncapabilities:\n  spawn:\n    \
15298             allow: []\n",
15299        )
15300        .unwrap();
15301    }
15302
15303    #[test]
15304    fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
15305        let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
15306            "name: cap\nversion: 0.1.0\nnetwork:\n  authentication:\n    scheme: bearer\n    \
15307             schemes: x\n    credentials:\n      watcher:\n        scopes: [GetTask]\n        \
15308             scope: [GetTask]\n",
15309        )
15310        .unwrap()
15311        .unknown_keys
15312        .into_iter()
15313        .map(|key| (key.key, key.block_path, key.nearest_known))
15314        .collect();
15315        assert_eq!(
15316            keys,
15317            vec![
15318                (
15319                    "schemes".to_string(),
15320                    "network.authentication".to_string(),
15321                    Some("scheme".to_string())
15322                ),
15323                (
15324                    "scope".to_string(),
15325                    "network.authentication.credentials.watcher".to_string(),
15326                    Some("scopes".to_string())
15327                ),
15328            ]
15329        );
15330    }
15331}
15332
15333#[cfg(test)]
15334mod registry_block_tests {
15335    use super::*;
15336
15337    const REGISTRY_BLOCK: &str = "registry:\n  default: local\n";
15338
15339    #[test]
15340    fn registry_block_is_refused_by_both_entry_points() {
15341        let input = format!("name: cap\nversion: 0.0.1\nartifacts: []\n{REGISTRY_BLOCK}");
15342        for result in [
15343            RuntimeManifest::from_yaml_str(&input),
15344            RuntimeManifest::from_yaml_str_without_secrets(&input),
15345        ] {
15346            let err = result.unwrap_err();
15347            assert!(
15348                matches!(err, RuntimeManifestError::RegistryBlock),
15349                "{err:?}"
15350            );
15351            assert_eq!(err.to_string(), REGISTRY_BLOCK_MESSAGE);
15352        }
15353    }
15354
15355    #[test]
15356    fn registry_block_is_refused_before_any_other_field() {
15357        // `name` is missing and `lifecycle.queue_depth: 0` under `queue` is refused on its own;
15358        // the registry refusal still wins.
15359        let input = format!(
15360            "version: 0.0.1\nlifecycle:\n  task_acceptance: queue\n  queue_depth: 0\n\
15361             {REGISTRY_BLOCK}"
15362        );
15363        let err = RuntimeManifest::from_yaml_str(&input).unwrap_err();
15364        assert!(
15365            matches!(err, RuntimeManifestError::RegistryBlock),
15366            "{err:?}"
15367        );
15368    }
15369
15370    #[test]
15371    fn registry_block_refusal_applies_whatever_the_block_holds() {
15372        for block in ["registry: local\n", "registry: {}\n", "registry: []\n"] {
15373            let input = format!("name: cap\nversion: 0.0.1\n{block}");
15374            assert!(
15375                matches!(
15376                    RuntimeManifest::from_yaml_str(&input),
15377                    Err(RuntimeManifestError::RegistryBlock)
15378                ),
15379                "{block}"
15380            );
15381        }
15382    }
15383
15384    #[test]
15385    fn a_manifest_without_a_registry_block_parses_unchanged() {
15386        let manifest =
15387            RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
15388        assert_eq!(manifest.name, "cap");
15389        assert_eq!(manifest.version, "0.0.1");
15390    }
15391}
15392
15393#[cfg(test)]
15394mod scoped_name_tests {
15395    use super::*;
15396
15397    fn name_refusal(yaml: &str) -> (String, String, Option<String>) {
15398        match RuntimeManifest::from_yaml_str(yaml) {
15399            Err(RuntimeManifestError::InvalidArtifactName {
15400                field,
15401                message,
15402                hint,
15403            }) => (field, message, hint),
15404            other => panic!("expected InvalidArtifactName, got {other:?}"),
15405        }
15406    }
15407
15408    #[test]
15409    fn scoped_name_a_bare_artifact_entry_is_refused_with_the_scoped_forms() {
15410        let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: tool-editor\n    \
15411                    version: 0.1.0\n    runtime: tool\n";
15412        let (field, message, hint) = name_refusal(yaml);
15413        assert_eq!(field, "artifacts[0].name");
15414        assert_eq!(
15415            message,
15416            "'tool-editor' is not a full artifact name: an artifact is named <scope>/<name>"
15417        );
15418        assert_eq!(
15419            hint.as_deref(),
15420            Some(
15421                "name it with its scope: 'murmur/tool-editor' for a Murmur default artifact, \
15422                 '<owner>/tool-editor' for one published from github.com/<owner>, or \
15423                 'local/tool-editor' for one installed from a .mur.zip file"
15424            )
15425        );
15426        let rendered = RuntimeManifest::from_yaml_str(yaml)
15427            .unwrap_err()
15428            .to_string();
15429        assert!(
15430            rendered.starts_with("murmur.yaml: invalid artifact name in 'artifacts[0].name': "),
15431            "{rendered}"
15432        );
15433    }
15434
15435    #[test]
15436    fn scoped_name_a_source_entry_keeps_its_bare_name() {
15437        let bare = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: style\n    \
15438                    source: ./skills/style\n    runtime: skill\n";
15439        RuntimeManifest::from_yaml_str(bare).expect("a source: entry is named by its bare name");
15440
15441        let (field, message, hint) = name_refusal(&bare.replace("name: style", "name: ana/style"));
15442        assert_eq!(field, "artifacts[0].name");
15443        assert!(
15444            message
15445                .starts_with("'ana/style' declares 'source:', so it is named by its bare name; "),
15446            "{message}"
15447        );
15448        assert_eq!(hint.as_deref(), Some("drop the scope: name it 'style'"));
15449    }
15450
15451    #[test]
15452    fn scoped_name_spawn_allow_refuses_a_bare_entry_and_a_shared_bare_part() {
15453        let with_allow = |allow: &str| {
15454            format!("name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    allow: [{allow}]\n")
15455        };
15456        let manifest = RuntimeManifest::from_yaml_str(&with_allow("local/worker, ana/reviewer"))
15457            .expect("full names in two scopes with distinct bare parts parse");
15458        assert_eq!(
15459            manifest.capabilities.unwrap().spawn.unwrap().allow,
15460            vec!["local/worker".to_string(), "ana/reviewer".to_string()]
15461        );
15462
15463        let (field, message, hint) = name_refusal(&with_allow("worker"));
15464        assert_eq!(field, "capabilities.spawn.allow[0]");
15465        assert!(
15466            message.contains("'worker' is not a full artifact name"),
15467            "{message}"
15468        );
15469        assert!(hint.unwrap().contains("local/worker"));
15470
15471        let (field, message, hint) = name_refusal(&with_allow("local/worker, ana/worker"));
15472        assert_eq!(field, "capabilities.spawn.allow[1]");
15473        assert_eq!(
15474            message,
15475            "'local/worker' and 'ana/worker' share the name 'worker', which is how delegate-task \
15476             offers a capsule to the model"
15477        );
15478        assert_eq!(
15479            hint.as_deref(),
15480            Some("keep one of 'local/worker' and 'ana/worker' in capabilities.spawn.allow")
15481        );
15482    }
15483
15484    #[test]
15485    fn spawn_workdir_snapshot_lowers_beside_allow_and_is_empty_when_absent() {
15486        let yaml = "name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    \
15487                    allow: [local/reviewer, local/worker]\n    \
15488                    workdir_snapshot: [local/reviewer]\n";
15489        let spawn = RuntimeManifest::from_yaml_str(yaml)
15490            .expect("a snapshot entry that is also in allow parses")
15491            .capabilities
15492            .unwrap()
15493            .spawn
15494            .unwrap();
15495        assert_eq!(
15496            spawn,
15497            SpawnCapabilities {
15498                allow: vec!["local/reviewer".to_string(), "local/worker".to_string()],
15499                workdir_snapshot: vec!["local/reviewer".to_string()],
15500            }
15501        );
15502
15503        let absent = "name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    \
15504                      allow: [local/reviewer]\n";
15505        let spawn = RuntimeManifest::from_yaml_str(absent)
15506            .unwrap()
15507            .capabilities
15508            .unwrap()
15509            .spawn
15510            .unwrap();
15511        assert!(spawn.workdir_snapshot.is_empty());
15512    }
15513
15514    #[test]
15515    fn spawn_workdir_snapshot_refuses_an_entry_outside_allow_a_bare_name_and_a_duplicate() {
15516        let with = |snapshot: &str| {
15517            format!(
15518                "name: cap\nversion: 0.0.1\ncapabilities:\n  spawn:\n    \
15519                 allow: [local/reviewer, local/worker]\n    workdir_snapshot: [{snapshot}]\n"
15520            )
15521        };
15522
15523        let (field, message, hint) = name_refusal(&with("local/reviewer, local/other"));
15524        assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
15525        assert_eq!(
15526            message,
15527            "'local/other' is not in capabilities.spawn.allow, so this capsule cannot delegate \
15528             to it"
15529        );
15530        assert!(
15531            hint.as_deref()
15532                .is_some_and(|hint| hint.contains("capabilities.spawn.allow")),
15533            "{hint:?}"
15534        );
15535
15536        let (field, message, _) = name_refusal(&with("reviewer"));
15537        assert_eq!(field, "capabilities.spawn.workdir_snapshot[0]");
15538        assert!(
15539            message.contains("'reviewer' is not a full artifact name"),
15540            "{message}"
15541        );
15542
15543        let (field, message, hint) = name_refusal(&with("local/reviewer, local/reviewer"));
15544        assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
15545        assert_eq!(message, "'local/reviewer' is listed more than once");
15546        assert_eq!(
15547            hint.as_deref(),
15548            Some("keep one 'local/reviewer' in capabilities.spawn.workdir_snapshot")
15549        );
15550    }
15551
15552    #[test]
15553    fn scoped_name_a_reference_by_bare_part_names_the_declared_entry() {
15554        let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/driver-anthropic\n    \
15555                    version: 0.1.0\n    runtime: driver\n    gateway:\n      \
15556                    endpoint: https://api.anthropic.com\n      api_key: k\ninference:\n  \
15557                    transport: http\n  model: m\n  driver:\n    artifact: driver-anthropic\n";
15558        let message = RuntimeManifest::from_yaml_str(yaml)
15559            .unwrap_err()
15560            .to_string();
15561        assert!(
15562            message.contains(
15563                "artifact 'driver-anthropic' is not declared in artifacts: (the declared \
15564                 'murmur/driver-anthropic' has that name; refer to it as \
15565                 'murmur/driver-anthropic')"
15566            ),
15567            "{message}"
15568        );
15569    }
15570}
15571
15572#[cfg(test)]
15573mod inference_retry_tests {
15574    use super::*;
15575
15576    const HTTP: &str = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/drv\n    \
15577                        version: 0.1.0\n    runtime: driver\n    gateway:\n      endpoint: \
15578                        http://127.0.0.1:8080\n      api_key: sk-test\ninference:\n  transport: \
15579                        http\n  model: m\n  driver:\n    artifact: local/drv\n";
15580    const PROCESS: &str = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: local/drv\n    \
15581                           version: 0.1.0\n    runtime: driver\ninference:\n  transport: \
15582                           process\n  driver:\n    artifact: local/drv\n";
15583
15584    fn retry(yaml: &str) -> InferenceRetry {
15585        RuntimeManifest::from_yaml_str(yaml)
15586            .unwrap()
15587            .inference
15588            .unwrap()
15589            .retry
15590    }
15591
15592    fn refusal(yaml: &str) -> (String, String) {
15593        match RuntimeManifest::from_yaml_str(yaml) {
15594            Err(RuntimeManifestError::InvalidInferenceConfig { field, message }) => {
15595                (field, message)
15596            }
15597            other => panic!("expected InvalidInferenceConfig, got {other:?}"),
15598        }
15599    }
15600
15601    #[test]
15602    fn inference_retry_defaults_when_absent_and_per_key_when_partial() {
15603        assert_eq!(retry(HTTP), InferenceRetry::default());
15604        assert_eq!(
15605            InferenceRetry::default(),
15606            InferenceRetry {
15607                max_retries: 6,
15608                initial_delay_ms: 1_000,
15609                max_delay_ms: 60_000,
15610            }
15611        );
15612        assert_eq!(
15613            retry(&format!("{HTTP}  retry:\n    max_retries: 2\n")),
15614            InferenceRetry {
15615                max_retries: 2,
15616                ..InferenceRetry::default()
15617            }
15618        );
15619        assert_eq!(
15620            retry(&format!(
15621                "{HTTP}  retry:\n    initial_delay_ms: 10\n    max_delay_ms: 2000\n"
15622            )),
15623            InferenceRetry {
15624                max_retries: 6,
15625                initial_delay_ms: 10,
15626                max_delay_ms: 2000,
15627            }
15628        );
15629        assert_eq!(
15630            retry(&format!(
15631                "{HTTP}  retry:\n    max_retries: 0\n    initial_delay_ms: 60000\n    \
15632                 max_delay_ms: 3600000\n"
15633            )),
15634            InferenceRetry {
15635                max_retries: 0,
15636                initial_delay_ms: 60_000,
15637                max_delay_ms: 3_600_000,
15638            }
15639        );
15640        assert_eq!(retry(PROCESS), InferenceRetry::default());
15641    }
15642
15643    #[test]
15644    fn inference_retry_bounds_are_refused_naming_the_field() {
15645        for (block, field) in [
15646            ("max_retries: 21", "inference.retry.max_retries"),
15647            ("initial_delay_ms: 0", "inference.retry.initial_delay_ms"),
15648            (
15649                "initial_delay_ms: 60001",
15650                "inference.retry.initial_delay_ms",
15651            ),
15652            ("max_delay_ms: 0", "inference.retry.max_delay_ms"),
15653            ("max_delay_ms: 3600001", "inference.retry.max_delay_ms"),
15654            (
15655                "initial_delay_ms: 5000\n    max_delay_ms: 1000",
15656                "inference.retry.max_delay_ms",
15657            ),
15658        ] {
15659            let (got, message) = refusal(&format!("{HTTP}  retry:\n    {block}\n"));
15660            assert_eq!(got, field, "{block}: {message}");
15661        }
15662        let (_, message) = refusal(&format!(
15663            "{HTTP}  retry:\n    initial_delay_ms: 5000\n    max_delay_ms: 1000\n"
15664        ));
15665        assert!(message.contains("initial_delay_ms"), "{message}");
15666    }
15667
15668    #[test]
15669    fn inference_retry_is_refused_under_transport_process() {
15670        let (field, message) = refusal(&format!("{PROCESS}  retry:\n    max_retries: 2\n"));
15671        assert_eq!(field, "inference.retry");
15672        assert_eq!(
15673            message,
15674            "is not valid with transport: process; the harness retries its own provider calls"
15675        );
15676    }
15677
15678    #[test]
15679    fn inference_retry_unknown_key_is_reported_at_its_block() {
15680        let manifest = RuntimeManifest::from_yaml_str(&format!(
15681            "{HTTP}  retry:\n    max_retries: 2\n    jitter: full\n"
15682        ))
15683        .unwrap();
15684        let keys: Vec<(&str, &str)> = manifest
15685            .unknown_keys
15686            .iter()
15687            .map(|key| (key.key.as_str(), key.block_path.as_str()))
15688            .collect();
15689        assert_eq!(keys, vec![("jitter", "inference.retry")]);
15690    }
15691}
15692
15693#[cfg(test)]
15694mod host_paths_tests {
15695    use super::*;
15696
15697    const ENTRY: &str = "  - path: ~/.cargo\n    access: read-write\n";
15698
15699    /// The dotted field both entry points refuse `input` for; they must agree.
15700    fn refused_field(input: &str) -> String {
15701        let fields: Vec<String> = [
15702            RuntimeManifest::from_yaml_str(input),
15703            RuntimeManifest::from_yaml_str_without_secrets(input),
15704        ]
15705        .into_iter()
15706        .map(|result| match result {
15707            Err(RuntimeManifestError::HostPathsDeclared { field }) => field,
15708            other => panic!("expected HostPathsDeclared, got {other:?}"),
15709        })
15710        .collect();
15711        assert_eq!(
15712            fields[0], fields[1],
15713            "both entry points refuse the same field"
15714        );
15715        fields[0].clone()
15716    }
15717
15718    #[test]
15719    fn host_paths_is_refused_wherever_a_manifest_writes_it() {
15720        let top = format!("name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths:\n{ENTRY}");
15721        assert_eq!(refused_field(&top), "host_paths");
15722
15723        let capabilities = format!(
15724            "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  host_paths:\n  {}",
15725            ENTRY.replace('\n', "\n  ").trim_end()
15726        );
15727        assert_eq!(refused_field(&capabilities), "capabilities.host_paths");
15728
15729        let filesystem = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  \
15730                          filesystem:\n    host_paths: []\n";
15731        assert_eq!(
15732            refused_field(filesystem),
15733            "capabilities.filesystem.host_paths"
15734        );
15735
15736        let artifact = "name: cap\nversion: 0.0.1\nartifacts:\n  - name: murmur/tool-a\n    \
15737                        version: 0.1.0\n    runtime: tool\n  - name: murmur/tool-b\n    \
15738                        version: 0.1.0\n    runtime: tool\n    capabilities:\n      \
15739                        host_paths: []\n";
15740        assert_eq!(
15741            refused_field(artifact),
15742            "artifacts[1].capabilities.host_paths"
15743        );
15744    }
15745
15746    #[test]
15747    fn the_refusal_names_the_field_and_is_never_an_unknown_key_warning() {
15748        let input = "name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths: []\n";
15749        let error = RuntimeManifest::from_yaml_str(input).unwrap_err();
15750        assert!(error.to_string().contains("'host_paths'"), "{error}");
15751        assert!(error.to_string().contains("murmur.yaml"), "{error}");
15752    }
15753
15754    #[test]
15755    fn a_manifest_without_host_paths_parses_unchanged() {
15756        let input = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n  filesystem:\n    \
15757                     workdir_exec: false\n";
15758        assert!(RuntimeManifest::from_yaml_str(input).is_ok());
15759    }
15760}