Skip to main content

murmur_artifact/
runtime_manifest.rs

1use std::{fs, net::IpAddr, path::Path};
2
3use serde::Deserialize;
4use thiserror::Error;
5use url::Url;
6
7use crate::manifest_path::MANIFEST_FILENAME;
8
9// ── Lifecycle types ───────────────────────────────────────────────────────────
10
11#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum TaskAcceptance {
14    None,
15    #[default]
16    Single,
17    Queue,
18}
19
20#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum AfterTask {
23    #[default]
24    Exit,
25    Sleep,
26}
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum ConversationMode {
31    #[default]
32    Stateless,
33    Threaded,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct LifecycleConfig {
38    pub task_acceptance: TaskAcceptance,
39    pub after_task: AfterTask,
40    pub queue_depth: usize,
41    /// If set, request-input calls that receive no follow-up within this many seconds
42    /// transition the task to "failed" with reason "input-timeout". Absent = wait indefinitely.
43    pub input_timeout_secs: Option<u64>,
44    /// Whether tasks sharing a contextId accumulate conversation history across turns.
45    /// Stateless (default): each task is fully independent; Threaded: history is loaded
46    /// and persisted per contextId within a session.
47    pub conversation_mode: ConversationMode,
48}
49
50impl Default for LifecycleConfig {
51    fn default() -> Self {
52        Self {
53            task_acceptance: TaskAcceptance::Single,
54            after_task: AfterTask::Exit,
55            queue_depth: 1,
56            input_timeout_secs: None,
57            conversation_mode: ConversationMode::Stateless,
58        }
59    }
60}
61
62/// Lifecycle override: allows a parent (mur-roost) or CLI flag to constrain
63/// the lifecycle down from what the manifest declares.
64#[derive(Debug, Clone, Default)]
65pub struct LifecycleOverride {
66    pub task_acceptance: Option<TaskAcceptance>,
67    pub after_task: Option<AfterTask>,
68}
69
70/// Artifact taxonomy for entries declared in a capsule manifest.
71///
72/// Variant meanings:
73///
74/// - `Tool`: agent-callable tool (WASM component or native binary). Visible in MURMUR.md and the
75///   LLM tool list. Implementation details (wasm vs native) are declared in the artifact's own
76///   murmur.yaml via `implementation:`.
77/// - `Driver`: inference driver implemented as a WASM component. The runtime calls it as a
78///   processor. Hidden from the LLM.
79/// - `Hook`: event-triggered artifact implemented as a WASM component. The runtime calls it at
80///   lifecycle points; behavioral contract (binding, execution_mode, commit_policy) is declared
81///   in the artifact's own murmur.yaml. Hidden from the LLM.
82/// - `Skill`: documentation artifact; no executable binary. Visible in the LLM tool inventory.
83///   Invoking a skill by name returns its `skill.md` content as the tool result (just-in-time
84///   injection). No WASM/native dispatch occurs. A skill bound via
85///   `inference.system_prompt_artifact` is excluded from the callable inventory because it is
86///   already part of the system prompt.
87#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
88#[serde(rename_all = "lowercase")]
89pub enum ArtifactRuntime {
90    Tool,
91    Driver,
92    Hook,
93    Skill,
94}
95
96impl ArtifactRuntime {
97    /// Whether artifacts of this role appear in the LLM's tool inventory.
98    ///
99    /// Deliberately an exhaustive match with one arm per variant and no wildcard: a new
100    /// `ArtifactRuntime` variant must state its own visibility here, so adding a role that
101    /// should be LLM-visible cannot silently default to hidden.
102    pub fn is_llm_visible(&self) -> bool {
103        match self {
104            ArtifactRuntime::Tool => true,
105            ArtifactRuntime::Skill => true,
106            ArtifactRuntime::Driver => false,
107            ArtifactRuntime::Hook => false,
108        }
109    }
110
111    pub fn as_str(&self) -> &'static str {
112        match self {
113            ArtifactRuntime::Tool => "tool",
114            ArtifactRuntime::Driver => "driver",
115            ArtifactRuntime::Hook => "hook",
116            ArtifactRuntime::Skill => "skill",
117        }
118    }
119}
120
121/// How a `runtime: tool` artifact is implemented.
122///
123/// Declared via `implementation:` in the artifact's own murmur.yaml.
124/// Defaults to `Wasm` when the field is absent.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum ArtifactImplementation {
127    Wasm,
128    Native,
129}
130
131/// Read the `implementation:` field from an artifact's murmur.yaml.
132/// Defaults to `Wasm` when the field is absent or unparseable.
133pub fn read_tool_implementation(
134    artifact_manifest_path: &Path,
135) -> Result<ArtifactImplementation, RuntimeManifestError> {
136    let content =
137        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
138            path: artifact_manifest_path.display().to_string(),
139            source,
140        })?;
141    Ok(parse_tool_implementation_from_yaml(&content))
142}
143
144/// Parse `implementation:` from an in-memory manifest YAML string.
145/// Defaults to `Wasm` when the field is absent or unparseable.
146pub fn parse_tool_implementation_from_yaml(yaml: &str) -> ArtifactImplementation {
147    serde_yaml::from_str::<serde_yaml::Value>(yaml)
148        .map(|v| {
149            v.as_mapping()
150                .and_then(|m| m.get(serde_yaml::Value::String("implementation".to_string())))
151                .and_then(serde_yaml::Value::as_str)
152                .map(|s| match s {
153                    "native" => ArtifactImplementation::Native,
154                    _ => ArtifactImplementation::Wasm,
155                })
156                .unwrap_or(ArtifactImplementation::Wasm)
157        })
158        .unwrap_or(ArtifactImplementation::Wasm)
159}
160
161// ── Hook configuration types ──────────────────────────────────────────────────
162
163/// Which lifecycle event(s) a hook binds to.
164///
165/// Declared via `binding:` in the artifact's own murmur.yaml.
166/// Defaults to `All` when the field is absent (receives all session events).
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum HookBinding {
169    /// `"on-stage"` — fires during `stage_session`; always blocking.
170    OnStage,
171    /// `"on-session-start"` — fires once before the first inference turn.
172    OnSessionStart,
173    /// `"on-task-start"` — fires once per task, before that task's first inference turn.
174    OnTaskStart,
175    /// `"on-inference"` — fires after each inference response is parsed.
176    OnInference,
177    /// `"on-tool-call"` — fires after each model-requested tool invocation.
178    OnToolCall,
179    /// `"on-shell"` — fires after each shell command returns.
180    OnShell,
181    /// `"on-compaction"` — fires when the session token threshold is reached.
182    OnCompaction,
183    /// `"on-task-end"` — fires once per task, after that task's agent loop returns.
184    OnTaskEnd,
185    /// `"on-session-end"` — fires at session teardown.
186    OnSessionEnd,
187    /// No `binding:` field — receives all session events (not on-stage).
188    All,
189}
190
191/// Whether the runtime waits for the hook result.
192///
193/// Declared via `execution_mode:` in the artifact's own murmur.yaml.
194/// Defaults to `Blocking` when the field is absent.
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum HookExecutionMode {
197    /// `"blocking"` — runtime waits for result before proceeding.
198    Blocking,
199    /// `"async"` — runtime proceeds immediately; result not committed.
200    Async,
201}
202
203/// What the runtime does with a successful hook output.
204///
205/// Declared via `commit_policy:` in the artifact's own murmur.yaml.
206/// Defaults to `None` when the field is absent.
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum HookCommitPolicy {
209    /// `"none"` — output discarded.
210    None,
211    /// `"replace-context"` — runtime replaces conversation history. Valid only with on-compaction.
212    ReplaceContext,
213    /// `"write-manifests"` — runtime writes tool manifests to workdir/tools/. Valid only with on-stage.
214    WriteManifests,
215}
216
217/// Behavioral contract for a hook artifact, read from its own murmur.yaml.
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct HookConfig {
220    pub binding: HookBinding,
221    pub execution_mode: HookExecutionMode,
222    pub commit_policy: HookCommitPolicy,
223}
224
225impl Default for HookConfig {
226    fn default() -> Self {
227        Self {
228            binding: HookBinding::All,
229            execution_mode: HookExecutionMode::Blocking,
230            commit_policy: HookCommitPolicy::None,
231        }
232    }
233}
234
235/// Read a hook's behavioral contract from its murmur.yaml at the given path.
236pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
237    let content =
238        fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
239            path: artifact_manifest_path.display().to_string(),
240            source,
241        })?;
242    parse_hook_config_from_yaml(&content).map_err(|msg| {
243        RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
244    })
245}
246
247/// Parse hook behavioral contract from an in-memory manifest YAML string.
248pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
249    let v: serde_yaml::Value =
250        serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
251
252    let binding = v
253        .as_mapping()
254        .and_then(|m| m.get(serde_yaml::Value::String("binding".to_string())))
255        .and_then(serde_yaml::Value::as_str)
256        .map(|s| match s {
257            "on-stage" => Ok(HookBinding::OnStage),
258            "on-session-start" => Ok(HookBinding::OnSessionStart),
259            "on-task-start" => Ok(HookBinding::OnTaskStart),
260            "on-inference" => Ok(HookBinding::OnInference),
261            "on-tool-call" => Ok(HookBinding::OnToolCall),
262            "on-shell" => Ok(HookBinding::OnShell),
263            "on-compaction" => Ok(HookBinding::OnCompaction),
264            "on-task-end" => Ok(HookBinding::OnTaskEnd),
265            "on-session-end" => Ok(HookBinding::OnSessionEnd),
266            other => Err(format!(
267                "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
268                 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
269            )),
270        })
271        .transpose()?
272        .unwrap_or(HookBinding::All);
273
274    let execution_mode = v
275        .as_mapping()
276        .and_then(|m| m.get(serde_yaml::Value::String("execution_mode".to_string())))
277        .and_then(serde_yaml::Value::as_str)
278        .map(|s| match s {
279            "blocking" => Ok(HookExecutionMode::Blocking),
280            "async" => Ok(HookExecutionMode::Async),
281            other => Err(format!(
282                "unknown execution_mode '{other}'; expected: blocking, async"
283            )),
284        })
285        .transpose()?
286        .unwrap_or(HookExecutionMode::Blocking);
287
288    let commit_policy = v
289        .as_mapping()
290        .and_then(|m| m.get(serde_yaml::Value::String("commit_policy".to_string())))
291        .and_then(serde_yaml::Value::as_str)
292        .map(|s| match s {
293            "none" => Ok(HookCommitPolicy::None),
294            "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
295            "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
296            other => Err(format!(
297                "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests"
298            )),
299        })
300        .transpose()?
301        .unwrap_or(HookCommitPolicy::None);
302
303    // Validation
304    if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
305        return Err(format!(
306            "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
307             (got '{}')",
308            match &commit_policy {
309                HookCommitPolicy::None => "none",
310                HookCommitPolicy::ReplaceContext => "replace-context",
311                HookCommitPolicy::WriteManifests => "write-manifests",
312            }
313        ));
314    }
315    if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
316        return Err(
317            "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
318             'on-stage'"
319                .to_string(),
320        );
321    }
322
323    Ok(HookConfig {
324        binding,
325        execution_mode,
326        commit_policy,
327    })
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
331pub struct RuntimeManifest {
332    pub name: String,
333    pub version: String,
334    pub artifacts: Vec<RuntimeArtifact>,
335    pub capabilities: Option<Capabilities>,
336    pub inference: Option<InferenceConfig>,
337    pub context: Option<ContextConfig>,
338    pub observability: Option<ObservabilityConfig>,
339    pub trace: Option<TraceConfig>,
340    pub network: Option<NetworkConfig>,
341    pub lifecycle: Option<LifecycleConfig>,
342    /// Pins the mur runtime version required by this capsule.
343    /// Used by `mur deploy` to select the binary version to install on the VM,
344    /// and by `mur run` to warn on version mismatch.
345    /// If absent, the running mur binary's version is used.
346    pub mur_version: Option<String>,
347}
348
349impl RuntimeManifest {
350    pub fn effective_lifecycle(&self) -> LifecycleConfig {
351        self.lifecycle.clone().unwrap_or_default()
352    }
353}
354
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct NetworkConfig {
357    /// Internal port the capsule expects to listen on. Default 14159 when absent.
358    pub internal_port: Option<u16>,
359}
360
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub struct RuntimeArtifact {
363    pub name: String,
364    pub version: String,
365    pub runtime: ArtifactRuntime,
366    /// Optional local source path. When set, the runtime resolves the artifact directly from
367    /// this path instead of from the registry — no `.mur.zip`, no publish. For a skill the path
368    /// points at a `skill.md` file or at a directory containing one (case-insensitive).
369    /// Relative paths resolve against the directory containing `murmur.yaml`.
370    /// Only permitted when `local_source` is true.
371    pub source: Option<String>,
372    /// Whether this artifact may be resolved from a local `source:` path. Declared via
373    /// `local_source:` in the project manifest's artifact entry; when absent it defaults to
374    /// true for `runtime: skill` and false for every other role, which is exactly the role-based
375    /// gate this field replaced.
376    pub local_source: bool,
377    /// Whether this artifact's payload may be bound as the system prompt via
378    /// `inference.system_prompt_artifact`. Declared via `prompt_payload:` in the project
379    /// manifest's artifact entry; when absent it defaults to true for `runtime: skill` and
380    /// false for every other role.
381    pub prompt_payload: bool,
382}
383
384#[derive(Debug, Clone, PartialEq, Eq)]
385pub struct InferenceDriver {
386    pub artifact: String,
387    pub config: Option<String>,
388}
389
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct InferenceConfig {
392    pub transport: String,
393    /// HTTP endpoint for the WASM driver. Present for `transport: http`, absent for `transport: process`.
394    pub endpoint: Option<String>,
395    pub model: String,
396    pub api_key: Option<String>,
397    /// WASM driver artifact. Present for `transport: http`, absent for `transport: process`.
398    pub driver: Option<InferenceDriver>,
399    /// CLI binary to spawn. Present for `transport: process`, absent for `transport: http`.
400    pub command: Option<String>,
401    pub compaction: Option<CompactionConfig>,
402    pub system_prompt: Option<String>,
403    pub system_prompt_file: Option<String>,
404    /// Name of a skill artifact declared in `artifacts:` whose `skill.md` is used as the system
405    /// prompt. Mutually exclusive with `system_prompt` and `system_prompt_file`. The skill is
406    /// excluded from the callable tool inventory (it's already in the system prompt).
407    pub system_prompt_artifact: Option<String>,
408    /// Maximum LLM turns per capsule task. Defaults to 10 when absent in the manifest.
409    /// Enforced as a hard ceiling by the runtime.
410    pub max_turns: u32,
411}
412
413#[derive(Debug, Clone, PartialEq)]
414pub struct CompactionConfig {
415    /// Fraction of context_window at which compaction fires (0.0–1.0]. Default: 0.98.
416    pub threshold: Option<f32>,
417    /// Model for compaction. None means use the primary inference model.
418    pub model: Option<String>,
419}
420
421// Threshold values are validated to (0.0, 1.0] at parse time so NaN is impossible.
422impl Eq for CompactionConfig {}
423
424#[derive(Debug, Clone, PartialEq, Eq)]
425pub struct ContextConfig {
426    /// Token budget for this session; None disables compaction.
427    pub max_tokens: Option<u32>,
428}
429
430#[derive(Debug, Clone, PartialEq, Eq)]
431pub struct ObservabilityConfig {
432    pub otel_endpoint: Option<String>,
433    pub eval: Option<EvalConfig>,
434}
435
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct TraceConfig {
438    /// When true, the raw tool output is captured in each tool_call trace event.
439    /// Defaults to false because tool output can be large (file diffs, shell dumps).
440    pub include_tool_output: bool,
441}
442
443#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
444pub struct EvalConfig {
445    #[serde(skip_serializing_if = "Option::is_none")]
446    pub dataset_id: Option<String>,
447    pub scorers: Vec<ScorerConfig>,
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
451#[serde(tag = "type", rename_all = "snake_case")]
452pub enum ScorerConfig {
453    ExitOk { name: String },
454    MaxTurns { name: String, max: u32 },
455    MaxTokens { name: String, max: u64 },
456    ToolSequence { name: String, expected: Vec<String> },
457    LlmJudge { name: String },
458}
459
460#[derive(Debug, Clone, PartialEq, Eq)]
461pub struct NetworkCapabilities {
462    pub allow: Vec<String>,
463}
464
465#[derive(Debug, Clone, PartialEq, Eq)]
466pub struct FilesystemCapabilities {
467    pub scope: Option<String>,
468}
469
470#[derive(Debug, Clone, PartialEq, Eq)]
471pub struct ShellCapabilities {
472    pub allow: Vec<String>,
473    pub strip_env: Option<Vec<String>>,
474    pub baseline_env: Option<Vec<String>>,
475}
476
477#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct SpawnCapabilities {
479    pub allow: Vec<String>,
480}
481
482/// Host environment variables a WASM guest may observe, from `capabilities.env`.
483///
484/// An empty `allow` is legitimate (a declared-but-empty block is a no-op), so unlike
485/// `capabilities.shell.allow` this is not rejected at parse time.
486#[derive(Debug, Clone, PartialEq, Eq, Default)]
487pub struct EnvCapabilities {
488    pub allow: Vec<String>,
489}
490
491/// Per-guest execution limits from `capabilities.limits`.
492///
493/// Every field is `Option` because this type carries only what the manifest actually
494/// declared — an omitted field stays `None` here and the runtime substitutes its own
495/// default (see `capsule_runtime::limits::ExecutionLimits`). Keeping the "absent" state
496/// distinct from "explicitly set" is what lets the runtime own the defaults in one place
497/// instead of duplicating them into the manifest parser.
498#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
499pub struct ResourceLimits {
500    /// Cap on a guest's linear-memory growth, in bytes.
501    pub memory_bytes: Option<usize>,
502    /// Cap on a guest's table growth, in elements.
503    pub table_elements: Option<usize>,
504    /// Cap on the number of instances a single store may create.
505    pub instances: Option<usize>,
506    /// Wall-clock budget for a single guest invocation, in seconds.
507    pub deadline_seconds: Option<u64>,
508}
509
510#[derive(Debug, Clone, PartialEq, Eq)]
511pub struct Capabilities {
512    pub network: Option<NetworkCapabilities>,
513    pub filesystem: Option<FilesystemCapabilities>,
514    pub shell: Option<ShellCapabilities>,
515    pub spawn: Option<SpawnCapabilities>,
516    pub env: Option<EnvCapabilities>,
517    pub limits: Option<ResourceLimits>,
518}
519
520#[derive(Debug, Error)]
521pub enum RuntimeManifestError {
522    #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
523    NotFound(String),
524    #[error("{0}")]
525    YamlSyntax(String),
526    #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
527    MissingField { field: String },
528    #[error("{}: invalid artifact declaration at index {index}: {message}", MANIFEST_FILENAME)]
529    InvalidArtifact { index: usize, message: String },
530    #[error("{}: invalid inference config for '{field}': {message}", MANIFEST_FILENAME)]
531    InvalidInferenceConfig { field: String, message: String },
532    #[error("{}: invalid capability config for '{field}': {message}", MANIFEST_FILENAME)]
533    InvalidCapabilities { field: String, message: String },
534    #[error(
535        "{}: inference.api_key references {reference} but the environment variable is not set",
536        MANIFEST_FILENAME
537    )]
538    MissingInferenceEnvVar {
539        field: String,
540        reference: String,
541        variable: String,
542    },
543    #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
544    Io {
545        path: String,
546        #[source]
547        source: std::io::Error,
548    },
549}
550
551#[derive(Debug, Deserialize)]
552struct RawRuntimeManifest {
553    name: Option<String>,
554    version: Option<String>,
555    #[serde(default)]
556    artifacts: Vec<RawArtifact>,
557    #[serde(default)]
558    capabilities: Option<RawCapabilities>,
559    #[serde(default)]
560    inference: Option<RawInferenceConfig>,
561    #[serde(default)]
562    context: Option<RawContextConfig>,
563    #[serde(default)]
564    observability: Option<RawObservabilityConfig>,
565    #[serde(default)]
566    trace: Option<RawTraceConfig>,
567    #[serde(default)]
568    network: Option<RawNetworkConfig>,
569    #[serde(default)]
570    lifecycle: Option<RawLifecycleConfig>,
571    #[serde(default)]
572    mur_version: Option<String>,
573}
574
575#[derive(Debug, Deserialize)]
576struct RawLifecycleConfig {
577    #[serde(default)]
578    task_acceptance: Option<TaskAcceptance>,
579    #[serde(default)]
580    after_task: Option<AfterTask>,
581    #[serde(default)]
582    queue_depth: Option<usize>,
583    #[serde(default)]
584    input_timeout_secs: Option<u64>,
585    #[serde(default)]
586    conversation: Option<ConversationMode>,
587}
588
589#[derive(Debug, Deserialize)]
590struct RawNetworkConfig {
591    internal_port: Option<u16>,
592}
593
594#[derive(Debug, Deserialize)]
595struct RawContextConfig {
596    max_tokens: Option<u32>,
597}
598
599#[derive(Debug, Deserialize)]
600struct RawObservabilityConfig {
601    #[serde(default)]
602    otel_endpoint: Option<String>,
603    #[serde(default)]
604    eval: Option<RawEvalConfig>,
605}
606
607#[derive(Debug, Deserialize)]
608struct RawTraceConfig {
609    #[serde(default)]
610    include_tool_output: Option<bool>,
611}
612
613#[derive(Debug, Deserialize)]
614struct RawEvalConfig {
615    #[serde(default)]
616    dataset_id: Option<String>,
617    #[serde(default)]
618    scorers: Vec<RawScorerConfig>,
619}
620
621#[derive(Debug, Deserialize)]
622struct RawScorerConfig {
623    #[serde(rename = "type")]
624    scorer_type: String,
625    name: Option<String>,
626    max: Option<u64>,
627    expected: Option<Vec<String>>,
628}
629
630#[derive(Debug, Deserialize)]
631struct RawArtifact {
632    name: Option<String>,
633    version: Option<String>,
634    runtime: Option<String>,
635    #[serde(default)]
636    source: Option<String>,
637    #[serde(default)]
638    local_source: Option<bool>,
639    #[serde(default)]
640    prompt_payload: Option<bool>,
641}
642
643#[derive(Debug, Deserialize)]
644struct RawCapabilities {
645    #[serde(default)]
646    network: Option<RawNetworkCapabilities>,
647    #[serde(default)]
648    filesystem: Option<RawFilesystemCapabilities>,
649    #[serde(default)]
650    shell: Option<RawShellCapabilities>,
651    #[serde(default)]
652    spawn: Option<RawSpawnCapabilities>,
653    #[serde(default)]
654    env: Option<RawEnvCapabilities>,
655    #[serde(default)]
656    limits: Option<RawResourceLimits>,
657}
658
659#[derive(Debug, Deserialize)]
660struct RawSpawnCapabilities {
661    #[serde(default)]
662    allow: Vec<String>,
663}
664
665#[derive(Debug, Deserialize)]
666struct RawEnvCapabilities {
667    #[serde(default)]
668    allow: Vec<String>,
669}
670
671#[derive(Debug, Deserialize)]
672struct RawResourceLimits {
673    #[serde(default)]
674    memory_bytes: Option<usize>,
675    #[serde(default)]
676    table_elements: Option<usize>,
677    #[serde(default)]
678    instances: Option<usize>,
679    #[serde(default)]
680    deadline_seconds: Option<u64>,
681}
682
683#[derive(Debug, Deserialize)]
684struct RawNetworkCapabilities {
685    #[serde(default)]
686    allow: Vec<String>,
687}
688
689#[derive(Debug, Deserialize)]
690struct RawFilesystemCapabilities {
691    #[serde(default)]
692    scope: Option<String>,
693}
694
695#[derive(Debug, Deserialize)]
696struct RawShellCapabilities {
697    #[serde(default)]
698    allow: Vec<String>,
699    #[serde(default)]
700    strip_env: Option<Vec<String>>,
701    #[serde(default)]
702    baseline_env: Option<Vec<String>>,
703}
704
705#[derive(Debug, Deserialize)]
706struct RawInferenceConfig {
707    transport: Option<String>,
708    endpoint: Option<String>,
709    model: Option<String>,
710    #[serde(default)]
711    api_key: Option<String>,
712    #[serde(default)]
713    command: Option<String>,
714    #[serde(default)]
715    system_prompt: Option<String>,
716    #[serde(default)]
717    system_prompt_file: Option<String>,
718    #[serde(default)]
719    system_prompt_artifact: Option<String>,
720    #[serde(default)]
721    driver: Option<RawInferenceDriver>,
722    #[serde(default)]
723    provider: Option<RawInferenceDriver>,
724    #[serde(default)]
725    compaction: Option<RawCompactionConfig>,
726    #[serde(default)]
727    max_turns: Option<u32>,
728}
729
730#[derive(Debug, Deserialize)]
731struct RawCompactionConfig {
732    threshold: Option<f32>,
733    model: Option<String>,
734}
735
736#[derive(Debug, Deserialize)]
737struct RawInferenceDriver {
738    artifact: Option<String>,
739    #[serde(default)]
740    config: Option<serde_yaml::Value>,
741}
742
743#[must_use = "validated runtime manifest is required before run"]
744pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
745    let content = fs::read_to_string(path).map_err(|source| {
746        if source.kind() == std::io::ErrorKind::NotFound {
747            return RuntimeManifestError::NotFound(path.display().to_string());
748        }
749
750        RuntimeManifestError::Io {
751            path: path.display().to_string(),
752            source,
753        }
754    })?;
755
756    RuntimeManifest::from_yaml_str(&content)
757}
758
759impl RuntimeManifest {
760    pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
761        let raw: RawRuntimeManifest = serde_yaml::from_str(input).map_err(|err| {
762            if let Some(location) = err.location() {
763                RuntimeManifestError::YamlSyntax(format!(
764                    "{}: YAML syntax error at line {}, column {}: {}",
765                    MANIFEST_FILENAME,
766                    location.line(),
767                    location.column(),
768                    err
769                ))
770            } else {
771                RuntimeManifestError::YamlSyntax(format!(
772                    "{MANIFEST_FILENAME}: YAML syntax error: {err}"
773                ))
774            }
775        })?;
776
777        let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
778            RuntimeManifestError::MissingField {
779                field: "name".to_string(),
780            }
781        })?;
782
783        let version = raw
784            .version
785            .filter(|s| !s.trim().is_empty())
786            .ok_or_else(|| RuntimeManifestError::MissingField {
787                field: "version".to_string(),
788            })?;
789
790        let artifacts = raw
791            .artifacts
792            .into_iter()
793            .enumerate()
794            .map(|(index, artifact)| {
795                let name = artifact
796                    .name
797                    .filter(|s| !s.trim().is_empty())
798                    .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
799                        index,
800                        message: "missing required field 'name'".to_string(),
801                    })?;
802
803                let source = artifact.source.filter(|s| !s.trim().is_empty());
804
805                let runtime = match artifact.runtime.as_deref() {
806                    None | Some("tool") => ArtifactRuntime::Tool,
807                    Some("wasm") | Some("native") => {
808                        return Err(RuntimeManifestError::InvalidArtifact {
809                            index,
810                            message: format!(
811                                "use 'runtime: tool'; implementation is declared in the \
812                                 artifact's own manifest (got '{}')",
813                                artifact.runtime.as_deref().unwrap_or("")
814                            ),
815                        });
816                    }
817                    Some("driver") => ArtifactRuntime::Driver,
818                    Some("hook") => ArtifactRuntime::Hook,
819                    Some("skill") => ArtifactRuntime::Skill,
820                    Some(other) => {
821                        return Err(RuntimeManifestError::InvalidArtifact {
822                            index,
823                            message: format!(
824                                "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
825                            ),
826                        });
827                    }
828                };
829
830                // Capabilities are keyed on declared properties, not on the role. When the
831                // property is absent we derive it from the role, which reproduces the previous
832                // skill-only gate exactly and leaves every existing manifest unchanged.
833                let local_source = artifact
834                    .local_source
835                    .unwrap_or(runtime == ArtifactRuntime::Skill);
836                let prompt_payload = artifact
837                    .prompt_payload
838                    .unwrap_or(runtime == ArtifactRuntime::Skill);
839
840                // `source:` requires the artifact to declare `local_source: true`.
841                if source.is_some() && !local_source {
842                    return Err(RuntimeManifestError::InvalidArtifact {
843                        index,
844                        message: format!(
845                            "artifact '{name}' declares 'source:' but does not declare \
846                             'local_source: true' (runtime: {})",
847                            runtime.as_str()
848                        ),
849                    });
850                }
851
852                // When `source:` is present, `version:` is optional — substitute "local".
853                // If both are set, `version:` is ignored and a warning is printed to stderr.
854                let version = if source.is_some() {
855                    if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
856                        eprintln!(
857                            "warning: artifact '{name}' declares both 'source:' and \
858                             'version: {explicit}'; version is ignored for local-source skills \
859                             (using 'local')"
860                        );
861                    }
862                    "local".to_string()
863                } else {
864                    artifact
865                        .version
866                        .filter(|s| !s.trim().is_empty())
867                        .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
868                            index,
869                            message: "missing required field 'version'".to_string(),
870                        })?
871                };
872
873                Ok(RuntimeArtifact {
874                    name,
875                    version,
876                    runtime,
877                    source,
878                    local_source,
879                    prompt_payload,
880                })
881            })
882            .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
883
884        let capabilities = parse_capabilities(raw.capabilities)?;
885        let inference = parse_inference(raw.inference)?;
886
887        // Validate system_prompt_artifact: must name a declared artifact whose payload may be
888        // bound as the system prompt (`prompt_payload`, defaulted from the role when absent).
889        if let Some(ref sp_art) = inference.as_ref().and_then(|i| i.system_prompt_artifact.clone()) {
890            let matching = artifacts
891                .iter()
892                .find(|a| &a.name == sp_art);
893            match matching {
894                None => {
895                    return Err(RuntimeManifestError::InvalidInferenceConfig {
896                        field: "inference.system_prompt_artifact".to_string(),
897                        message: format!(
898                            "artifact '{sp_art}' is not declared in artifacts:"
899                        ),
900                    });
901                }
902                Some(a) if !a.prompt_payload => {
903                    return Err(RuntimeManifestError::InvalidInferenceConfig {
904                        field: "inference.system_prompt_artifact".to_string(),
905                        message: format!(
906                            "artifact '{sp_art}' (runtime: {}) does not declare \
907                             'prompt_payload: true', which system_prompt_artifact requires",
908                            a.runtime.as_str()
909                        ),
910                    });
911                }
912                Some(_) => {} // valid
913            }
914        }
915        let context = parse_context(raw.context)?;
916        let observability = parse_observability(raw.observability);
917        let trace = raw.trace.map(|raw_trace| TraceConfig {
918            include_tool_output: raw_trace.include_tool_output.unwrap_or(false),
919        });
920        let network = raw.network.map(|n| NetworkConfig {
921            internal_port: n.internal_port,
922        });
923        let lifecycle = raw.lifecycle.map(|raw_lc| {
924            let defaults = LifecycleConfig::default();
925            LifecycleConfig {
926                task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
927                after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
928                queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
929                input_timeout_secs: raw_lc.input_timeout_secs,
930                conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
931            }
932        });
933
934        Ok(Self {
935            name,
936            version,
937            artifacts,
938            capabilities,
939            inference,
940            context,
941            observability,
942            trace,
943            network,
944            lifecycle,
945            mur_version: raw.mur_version.filter(|s| !s.trim().is_empty()),
946        })
947    }
948}
949
950fn parse_capabilities(
951    raw: Option<RawCapabilities>,
952) -> Result<Option<Capabilities>, RuntimeManifestError> {
953    let Some(raw_caps) = raw else {
954        return Ok(None);
955    };
956
957    let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
958        allow: raw_network.allow,
959    });
960
961    let filesystem = raw_caps
962        .filesystem
963        .map(|raw_filesystem| FilesystemCapabilities {
964            scope: raw_filesystem.scope.and_then(|scope| {
965                let trimmed = scope.trim();
966                (!trimmed.is_empty()).then(|| trimmed.to_string())
967            }),
968        });
969
970    let shell = raw_caps
971        .shell
972        .map(|raw_shell| {
973            if raw_shell.allow.is_empty() {
974                return Err(RuntimeManifestError::InvalidCapabilities {
975                    field: "capabilities.shell.allow".to_string(),
976                    message: "must contain at least one binary".to_string(),
977                });
978            }
979
980            Ok(ShellCapabilities {
981                allow: raw_shell.allow,
982                strip_env: raw_shell.strip_env,
983                baseline_env: raw_shell.baseline_env,
984            })
985        })
986        .transpose()?;
987
988    let spawn = raw_caps
989        .spawn
990        .map(|raw_spawn| SpawnCapabilities { allow: raw_spawn.allow });
991
992    let env = raw_caps.env.map(|raw_env| EnvCapabilities {
993        allow: raw_env.allow,
994    });
995
996    let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
997
998    Ok(Some(Capabilities {
999        network,
1000        filesystem,
1001        shell,
1002        spawn,
1003        env,
1004        limits,
1005    }))
1006}
1007
1008/// Lower `capabilities.limits`, rejecting values that can only ever produce a guest that
1009/// traps on its first instruction. A zero cap is always a manifest authoring mistake, and
1010/// catching it here means `mur run` fails at parse time with a field name rather than
1011/// surfacing much later as an opaque wasm trap.
1012fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
1013    let reject_zero_usize =
1014        |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
1015            match value {
1016                Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
1017                    field: format!("capabilities.limits.{field}"),
1018                    message: "must be greater than zero".to_string(),
1019                }),
1020                _ => Ok(()),
1021            }
1022        };
1023
1024    reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
1025    reject_zero_usize(raw.table_elements, "table_elements")?;
1026    reject_zero_usize(raw.instances, "instances")?;
1027    if raw.deadline_seconds == Some(0) {
1028        return Err(RuntimeManifestError::InvalidCapabilities {
1029            field: "capabilities.limits.deadline_seconds".to_string(),
1030            message: "must be greater than zero".to_string(),
1031        });
1032    }
1033
1034    Ok(ResourceLimits {
1035        memory_bytes: raw.memory_bytes,
1036        table_elements: raw.table_elements,
1037        instances: raw.instances,
1038        deadline_seconds: raw.deadline_seconds,
1039    })
1040}
1041
1042fn parse_inference(
1043    raw: Option<RawInferenceConfig>,
1044) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
1045    let Some(raw) = raw else {
1046        return Ok(None);
1047    };
1048
1049    let transport = raw
1050        .transport
1051        .map(|s| s.trim().to_string())
1052        .filter(|s| !s.is_empty())
1053        .unwrap_or_else(|| "http".to_string());
1054
1055    let compaction = parse_compaction(raw.compaction)?;
1056    let system_prompt = optional_trimmed_string(raw.system_prompt);
1057    let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
1058    let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
1059
1060    let prompt_sources_set = [
1061        system_prompt.is_some(),
1062        system_prompt_file.is_some(),
1063        system_prompt_artifact.is_some(),
1064    ]
1065    .iter()
1066    .filter(|&&b| b)
1067    .count();
1068    if prompt_sources_set > 1 {
1069        return Err(RuntimeManifestError::InvalidInferenceConfig {
1070            field: "inference.system_prompt".to_string(),
1071            message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
1072                      may be set"
1073                .to_string(),
1074        });
1075    }
1076
1077    let max_turns = match raw.max_turns {
1078        None => 10,
1079        Some(0) => {
1080            return Err(RuntimeManifestError::InvalidInferenceConfig {
1081                field: "inference.max_turns".to_string(),
1082                message: "must be greater than 0".to_string(),
1083            });
1084        }
1085        Some(n) => n,
1086    };
1087
1088    match transport.as_str() {
1089        "http" => {
1090            let endpoint = required_inference_field(raw.endpoint, "endpoint")?;
1091            validate_inference_endpoint(&endpoint)?;
1092            let model = required_inference_field(raw.model, "model")?;
1093
1094            let api_key = match raw.api_key {
1095                None => None,
1096                Some(value) => {
1097                    let trimmed = value.trim();
1098                    if trimmed.is_empty() {
1099                        None
1100                    } else {
1101                        Some(resolve_inference_api_key(trimmed)?)
1102                    }
1103                }
1104            };
1105
1106            let driver_raw = raw.driver.or(raw.provider).ok_or_else(|| {
1107                RuntimeManifestError::InvalidInferenceConfig {
1108                    field: "inference.driver".to_string(),
1109                    message: "missing required field".to_string(),
1110                }
1111            })?;
1112            let driver = parse_inference_driver(driver_raw)?;
1113
1114            Ok(Some(InferenceConfig {
1115                transport,
1116                endpoint: Some(endpoint),
1117                model,
1118                api_key,
1119                driver: Some(driver),
1120                command: None,
1121                compaction,
1122                system_prompt,
1123                system_prompt_file,
1124                system_prompt_artifact,
1125                max_turns,
1126            }))
1127        }
1128        "process" => {
1129            // Fields that are invalid with transport: process
1130            if raw.driver.is_some() || raw.provider.is_some() {
1131                return Err(RuntimeManifestError::InvalidInferenceConfig {
1132                    field: "inference.driver.artifact".to_string(),
1133                    message: "is not valid with transport: process".to_string(),
1134                });
1135            }
1136            if raw.endpoint.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false) {
1137                return Err(RuntimeManifestError::InvalidInferenceConfig {
1138                    field: "inference.endpoint".to_string(),
1139                    message: "is not valid with transport: process".to_string(),
1140                });
1141            }
1142            if raw.api_key.as_ref().map(|s| !s.trim().is_empty()).unwrap_or(false) {
1143                return Err(RuntimeManifestError::InvalidInferenceConfig {
1144                    field: "inference.api_key".to_string(),
1145                    message: "is not valid with transport: process".to_string(),
1146                });
1147            }
1148
1149            let command = required_inference_field(raw.command, "command")?;
1150            // model is OPTIONAL for transport: process — an empty string means "use the CLI's
1151            // configured/account-default model" (e.g. a codex subscription's default; passing an
1152            // unsupported model there is a hard 400). The Claude dialect still needs a real model
1153            // and will surface the CLI's own error if given none.
1154            let model = optional_trimmed_string(raw.model).unwrap_or_default();
1155
1156            Ok(Some(InferenceConfig {
1157                transport,
1158                endpoint: None,
1159                model,
1160                api_key: None,
1161                driver: None,
1162                command: Some(command),
1163                compaction,
1164                system_prompt,
1165                system_prompt_file,
1166                system_prompt_artifact,
1167                max_turns,
1168            }))
1169        }
1170        other => Err(RuntimeManifestError::InvalidInferenceConfig {
1171            field: "inference.transport".to_string(),
1172            message: format!("unknown value '{other}'"),
1173        }),
1174    }
1175}
1176
1177fn parse_context(
1178    raw: Option<RawContextConfig>,
1179) -> Result<Option<ContextConfig>, RuntimeManifestError> {
1180    let Some(raw) = raw else {
1181        return Ok(None);
1182    };
1183
1184    if let Some(max_tokens) = raw.max_tokens {
1185        if max_tokens == 0 {
1186            return Err(RuntimeManifestError::InvalidInferenceConfig {
1187                field: "context.max_tokens".to_string(),
1188                message: "must be greater than 0".to_string(),
1189            });
1190        }
1191    }
1192
1193    Ok(Some(ContextConfig {
1194        max_tokens: raw.max_tokens,
1195    }))
1196}
1197
1198fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
1199    raw.map(|raw| ObservabilityConfig {
1200        otel_endpoint: raw
1201            .otel_endpoint
1202            .map(|value| value.trim().to_string())
1203            .filter(|value| !value.is_empty()),
1204        eval: raw.eval.map(parse_eval_config),
1205    })
1206}
1207
1208fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
1209    let scorers = raw
1210        .scorers
1211        .into_iter()
1212        .filter_map(|raw| {
1213            let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
1214            match raw.scorer_type.as_str() {
1215                "exit_ok" => Some(ScorerConfig::ExitOk { name }),
1216                "max_turns" => Some(ScorerConfig::MaxTurns {
1217                    name,
1218                    max: raw.max.unwrap_or(10) as u32,
1219                }),
1220                "max_tokens" => Some(ScorerConfig::MaxTokens {
1221                    name,
1222                    max: raw.max.unwrap_or(100_000),
1223                }),
1224                "tool_sequence" => Some(ScorerConfig::ToolSequence {
1225                    name,
1226                    expected: raw.expected.unwrap_or_default(),
1227                }),
1228                "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
1229                other => {
1230                    eprintln!("[murmur-artifact] unknown scorer type '{other}' — skipping");
1231                    None
1232                }
1233            }
1234        })
1235        .collect();
1236
1237    EvalConfig {
1238        dataset_id: raw.dataset_id,
1239        scorers,
1240    }
1241}
1242
1243fn parse_compaction(
1244    raw: Option<RawCompactionConfig>,
1245) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
1246    let Some(raw) = raw else {
1247        return Ok(None);
1248    };
1249
1250    if let Some(threshold) = raw.threshold {
1251        if threshold <= 0.0 || threshold > 1.0 {
1252            return Err(RuntimeManifestError::InvalidInferenceConfig {
1253                field: "inference.compaction.threshold".to_string(),
1254                message: format!("must be in (0.0, 1.0] but got {threshold}"),
1255            });
1256        }
1257    }
1258
1259    Ok(Some(CompactionConfig {
1260        threshold: raw.threshold,
1261        model: raw.model,
1262    }))
1263}
1264
1265fn optional_trimmed_string(value: Option<String>) -> Option<String> {
1266    value
1267        .map(|s| s.trim().to_string())
1268        .filter(|s| !s.is_empty())
1269}
1270
1271fn required_inference_field(
1272    value: Option<String>,
1273    key: &str,
1274) -> Result<String, RuntimeManifestError> {
1275    value
1276        .map(|value| value.trim().to_string())
1277        .filter(|value| !value.is_empty())
1278        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
1279            field: format!("inference.{key}"),
1280            message: "missing required field".to_string(),
1281        })
1282}
1283
1284/// Rejects `inference.endpoint` values that would let a manifest redirect the
1285/// inference trust root to an unencrypted, non-loopback host (murmur-security-assessment.md C-3).
1286///
1287/// Accepted: any `https://` URL, or `http://` URLs whose host is `localhost` or an
1288/// IP literal for which [`IpAddr::is_loopback`] returns true (e.g. `127.0.0.1`, `::1`).
1289/// Rejected: malformed URLs, unsupported schemes (anything but `http`/`https`), and
1290/// `http://` URLs whose host is not loopback.
1291fn validate_inference_endpoint(endpoint: &str) -> Result<(), RuntimeManifestError> {
1292    let url =
1293        Url::parse(endpoint).map_err(|source| RuntimeManifestError::InvalidInferenceConfig {
1294            field: "inference.endpoint".to_string(),
1295            message: format!("failed to parse '{endpoint}' as a URL: {source}"),
1296        })?;
1297
1298    match url.scheme() {
1299        "https" => Ok(()),
1300        "http" => {
1301            let host = url.host_str().unwrap_or("");
1302            let is_loopback = host == "localhost"
1303                || host
1304                    .parse::<IpAddr>()
1305                    .map(|ip| ip.is_loopback())
1306                    .unwrap_or(false);
1307
1308            if is_loopback {
1309                Ok(())
1310            } else {
1311                Err(RuntimeManifestError::InvalidInferenceConfig {
1312                    field: "inference.endpoint".to_string(),
1313                    message: format!(
1314                        "'{endpoint}' uses plain http:// with non-loopback host '{host}' — \
1315                         use https:// for remote endpoints, or http://localhost or \
1316                         a loopback IP literal for local inference"
1317                    ),
1318                })
1319            }
1320        }
1321        other => Err(RuntimeManifestError::InvalidInferenceConfig {
1322            field: "inference.endpoint".to_string(),
1323            message: format!(
1324                "unsupported scheme '{other}' in '{endpoint}' — expected http or https"
1325            ),
1326        }),
1327    }
1328}
1329
1330fn parse_inference_driver(
1331    raw: RawInferenceDriver,
1332) -> Result<InferenceDriver, RuntimeManifestError> {
1333    let artifact = raw
1334        .artifact
1335        .map(|value| value.trim().to_string())
1336        .filter(|value| !value.is_empty())
1337        .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
1338            field: "inference.driver.artifact".to_string(),
1339            message: "missing required field".to_string(),
1340        })?;
1341
1342    let config = parse_inference_driver_config(raw.config)?;
1343
1344    Ok(InferenceDriver { artifact, config })
1345}
1346
1347fn parse_inference_driver_config(
1348    raw: Option<serde_yaml::Value>,
1349) -> Result<Option<String>, RuntimeManifestError> {
1350    let Some(raw) = raw else {
1351        return Ok(None);
1352    };
1353
1354    if matches!(raw, serde_yaml::Value::Null) {
1355        return Ok(None);
1356    }
1357
1358    if !matches!(raw, serde_yaml::Value::Mapping(_)) {
1359        return Err(RuntimeManifestError::InvalidInferenceConfig {
1360            field: "inference.driver.config".to_string(),
1361            message: "expected mapping/object".to_string(),
1362        });
1363    }
1364
1365    let value =
1366        serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
1367            field: "inference.driver.config".to_string(),
1368            message: format!("failed to convert to JSON: {err}"),
1369        })?;
1370
1371    serde_json::to_string(&value).map(Some).map_err(|err| {
1372        RuntimeManifestError::InvalidInferenceConfig {
1373            field: "inference.driver.config".to_string(),
1374            message: format!("failed to encode JSON: {err}"),
1375        }
1376    })
1377}
1378
1379fn resolve_inference_api_key(value: &str) -> Result<String, RuntimeManifestError> {
1380    if let Some(variable) = parse_env_reference(value) {
1381        return std::env::var(variable).map_err(|_| RuntimeManifestError::MissingInferenceEnvVar {
1382            field: "inference.api_key".to_string(),
1383            reference: value.to_string(),
1384            variable: variable.to_string(),
1385        });
1386    }
1387
1388    Ok(value.to_string())
1389}
1390
1391fn parse_env_reference(value: &str) -> Option<&str> {
1392    if !value.starts_with("${") || !value.ends_with('}') {
1393        return None;
1394    }
1395
1396    let variable = &value[2..value.len() - 1];
1397    is_valid_env_variable(variable).then_some(variable)
1398}
1399
1400fn is_valid_env_variable(value: &str) -> bool {
1401    let mut chars = value.chars();
1402    let Some(first) = chars.next() else {
1403        return false;
1404    };
1405
1406    if !(first == '_' || first.is_ascii_uppercase()) {
1407        return false;
1408    }
1409
1410    chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
1411}
1412
1413#[cfg(test)]
1414mod tests {
1415    use super::*;
1416
1417    #[test]
1418    fn artifact_runtime_defaults_to_tool() {
1419        let manifest = RuntimeManifest::from_yaml_str(
1420            r#"
1421name: cap
1422version: 0.0.1
1423artifacts:
1424  - name: echo
1425    version: 1.2.3
1426"#,
1427        )
1428        .unwrap();
1429
1430        assert_eq!(manifest.artifacts.len(), 1);
1431        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
1432    }
1433
1434    #[test]
1435    fn artifact_runtime_tool_explicit() {
1436        let manifest = RuntimeManifest::from_yaml_str(
1437            r#"
1438name: cap
1439version: 0.0.1
1440artifacts:
1441  - name: echo
1442    version: 1.2.3
1443    runtime: tool
1444"#,
1445        )
1446        .unwrap();
1447
1448        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
1449    }
1450
1451    #[test]
1452    fn artifact_runtime_wasm_is_error() {
1453        let err = RuntimeManifest::from_yaml_str(
1454            r#"
1455name: cap
1456version: 0.0.1
1457artifacts:
1458  - name: echo
1459    version: 1.2.3
1460    runtime: wasm
1461"#,
1462        )
1463        .unwrap_err();
1464
1465        let msg = err.to_string();
1466        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
1467        assert!(msg.contains("wasm"), "error was: {msg}");
1468    }
1469
1470    #[test]
1471    fn artifact_runtime_native_is_error() {
1472        let err = RuntimeManifest::from_yaml_str(
1473            r#"
1474name: cap
1475version: 0.0.1
1476artifacts:
1477  - name: echo
1478    version: 1.2.3
1479    runtime: native
1480"#,
1481        )
1482        .unwrap_err();
1483
1484        let msg = err.to_string();
1485        assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
1486        assert!(msg.contains("native"), "error was: {msg}");
1487    }
1488
1489    #[test]
1490    fn invalid_artifact_reports_index() {
1491        let err = RuntimeManifest::from_yaml_str(
1492            r#"
1493name: cap
1494version: 0.0.1
1495artifacts:
1496  - name: ""
1497    version: 1.2.3
1498"#,
1499        )
1500        .unwrap_err();
1501
1502        let msg = err.to_string();
1503        assert!(msg.contains("index 0"));
1504        assert!(msg.contains("missing required field 'name'"));
1505    }
1506
1507    #[test]
1508    fn capabilities_parse_when_all_fields_present() {
1509        let manifest = RuntimeManifest::from_yaml_str(
1510            r#"
1511name: cap
1512version: 0.0.1
1513artifacts:
1514  - name: echo
1515    version: 1.2.3
1516capabilities:
1517  network:
1518    allow:
1519      - https://api.anthropic.com
1520      - http://127.0.0.1:8080
1521  filesystem:
1522    scope: ./sandbox
1523"#,
1524        )
1525        .unwrap();
1526
1527        let capabilities = manifest.capabilities.expect("capabilities should exist");
1528        assert_eq!(
1529            capabilities.network.unwrap().allow,
1530            vec![
1531                "https://api.anthropic.com".to_string(),
1532                "http://127.0.0.1:8080".to_string()
1533            ]
1534        );
1535        assert_eq!(
1536            capabilities.filesystem.unwrap().scope,
1537            Some("./sandbox".to_string())
1538        );
1539    }
1540
1541    #[test]
1542    fn env_capabilities_parse_allowlist() {
1543        let manifest = RuntimeManifest::from_yaml_str(
1544            r#"
1545name: cap
1546version: 0.0.1
1547artifacts:
1548  - name: echo
1549    version: 1.2.3
1550capabilities:
1551  env:
1552    allow:
1553      - MY_APP_REGION
1554      - GITHUB_TOKEN
1555"#,
1556        )
1557        .unwrap();
1558
1559        let capabilities = manifest.capabilities.expect("capabilities should exist");
1560        assert_eq!(
1561            capabilities.env.unwrap().allow,
1562            vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
1563        );
1564    }
1565
1566    #[test]
1567    fn env_capabilities_absent_when_block_omitted() {
1568        let manifest = RuntimeManifest::from_yaml_str(
1569            r#"
1570name: cap
1571version: 0.0.1
1572artifacts:
1573  - name: echo
1574    version: 1.2.3
1575capabilities:
1576  network:
1577    allow:
1578      - https://api.anthropic.com
1579"#,
1580        )
1581        .unwrap();
1582
1583        assert!(manifest.capabilities.unwrap().env.is_none());
1584    }
1585
1586    /// Unlike `capabilities.shell.allow`, an empty `env.allow` is a legitimate no-op rather
1587    /// than a manifest error.
1588    #[test]
1589    fn env_capabilities_allow_empty_list_is_accepted() {
1590        let manifest = RuntimeManifest::from_yaml_str(
1591            r#"
1592name: cap
1593version: 0.0.1
1594artifacts:
1595  - name: echo
1596    version: 1.2.3
1597capabilities:
1598  env: {}
1599"#,
1600        )
1601        .unwrap();
1602
1603        assert_eq!(
1604            manifest.capabilities.unwrap().env,
1605            Some(EnvCapabilities { allow: vec![] })
1606        );
1607    }
1608
1609    #[test]
1610    fn missing_capabilities_block_is_backward_compatible() {
1611        let manifest = RuntimeManifest::from_yaml_str(
1612            r#"
1613name: cap
1614version: 0.0.1
1615artifacts:
1616  - name: echo
1617    version: 1.2.3
1618"#,
1619        )
1620        .unwrap();
1621
1622        assert!(manifest.capabilities.is_none());
1623    }
1624
1625    #[test]
1626    fn network_only_capabilities_parse() {
1627        let manifest = RuntimeManifest::from_yaml_str(
1628            r#"
1629name: cap
1630version: 0.0.1
1631artifacts:
1632  - name: echo
1633    version: 1.2.3
1634capabilities:
1635  network:
1636    allow:
1637      - https://allowed.example.com
1638"#,
1639        )
1640        .unwrap();
1641
1642        let capabilities = manifest.capabilities.unwrap();
1643        assert_eq!(
1644            capabilities.network.unwrap().allow,
1645            vec!["https://allowed.example.com".to_string()]
1646        );
1647        assert!(capabilities.filesystem.is_none());
1648    }
1649
1650    #[test]
1651    fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
1652        let manifest = RuntimeManifest::from_yaml_str(
1653            r#"
1654name: cap
1655version: 0.0.1
1656artifacts:
1657  - name: echo
1658    version: 1.2.3
1659capabilities:
1660  network: {}
1661"#,
1662        )
1663        .unwrap();
1664
1665        let capabilities = manifest.capabilities.unwrap();
1666        assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
1667    }
1668
1669    #[test]
1670    fn network_internal_port_parses() {
1671        let manifest = RuntimeManifest::from_yaml_str(
1672            r#"
1673name: worker
1674version: 0.1.0
1675artifacts: []
1676
1677network:
1678  internal_port: 58172
1679"#,
1680        )
1681        .unwrap();
1682
1683        assert_eq!(
1684            manifest.network.unwrap().internal_port,
1685            Some(58172),
1686            "network.internal_port should parse to Some(58172)"
1687        );
1688    }
1689
1690    #[test]
1691    fn network_internal_port_absent_when_omitted() {
1692        let manifest = RuntimeManifest::from_yaml_str(
1693            r#"
1694name: worker
1695version: 0.1.0
1696artifacts: []
1697"#,
1698        )
1699        .unwrap();
1700
1701        assert!(manifest.network.is_none());
1702    }
1703
1704    #[test]
1705    fn shell_capabilities_parse_with_allow_and_env_fields() {
1706        let manifest = RuntimeManifest::from_yaml_str(
1707            r#"
1708name: cap
1709version: 0.0.1
1710artifacts: []
1711capabilities:
1712  shell:
1713    allow:
1714      - bash
1715      - git
1716    strip_env:
1717      - "*_TOKEN"
1718      - SECRET_KEY
1719    baseline_env:
1720      - SSH_AUTH_SOCK
1721"#,
1722        )
1723        .unwrap();
1724
1725        let shell = manifest
1726            .capabilities
1727            .unwrap()
1728            .shell
1729            .expect("shell capabilities should be present");
1730        assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
1731        assert_eq!(
1732            shell.strip_env,
1733            Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
1734        );
1735        assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
1736    }
1737
1738    #[test]
1739    fn shell_capabilities_require_non_empty_allowlist() {
1740        let err = RuntimeManifest::from_yaml_str(
1741            r#"
1742name: cap
1743version: 0.0.1
1744artifacts: []
1745capabilities:
1746  shell:
1747    allow: []
1748"#,
1749        )
1750        .unwrap_err();
1751
1752        let msg = err.to_string();
1753        assert!(msg.contains("capabilities.shell.allow"));
1754        assert!(msg.contains("at least one"));
1755    }
1756
1757    #[test]
1758    fn parses_inference_block() {
1759        let manifest = RuntimeManifest::from_yaml_str(
1760            r#"
1761name: cap
1762version: 0.0.1
1763artifacts: []
1764inference:
1765  transport: http
1766  endpoint: http://127.0.0.1:8080
1767  model: test-model
1768  api_key: literal-key
1769  driver:
1770    artifact: murmur-driver-anthropic
1771    config:
1772      temperature: 0.2
1773      headers:
1774        x-test: true
1775"#,
1776        )
1777        .unwrap();
1778
1779        let inference = manifest.inference.expect("inference should exist");
1780        assert_eq!(inference.transport, "http");
1781        assert_eq!(inference.endpoint, Some("http://127.0.0.1:8080".to_string()));
1782        assert_eq!(inference.model, "test-model");
1783        assert_eq!(inference.api_key, Some("literal-key".to_string()));
1784        let driver = inference.driver.as_ref().expect("driver should be present");
1785        assert_eq!(driver.artifact, "murmur-driver-anthropic");
1786        assert_eq!(
1787            driver.config,
1788            Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
1789        );
1790        assert!(inference.system_prompt.is_none());
1791        assert!(inference.system_prompt_file.is_none());
1792    }
1793
1794    #[test]
1795    fn https_endpoint_accepted() {
1796        let manifest = RuntimeManifest::from_yaml_str(
1797            r#"
1798name: cap
1799version: 0.0.1
1800artifacts: []
1801inference:
1802  transport: http
1803  endpoint: https://api.anthropic.com
1804  model: test-model
1805  driver:
1806    artifact: murmur-driver-anthropic
1807"#,
1808        )
1809        .unwrap();
1810
1811        assert_eq!(
1812            manifest.inference.unwrap().endpoint,
1813            Some("https://api.anthropic.com".to_string())
1814        );
1815    }
1816
1817    #[test]
1818    fn http_localhost_endpoint_accepted() {
1819        let manifest = RuntimeManifest::from_yaml_str(
1820            r#"
1821name: cap
1822version: 0.0.1
1823artifacts: []
1824inference:
1825  transport: http
1826  endpoint: http://localhost:11434
1827  model: test-model
1828  driver:
1829    artifact: murmur-driver-anthropic
1830"#,
1831        )
1832        .unwrap();
1833
1834        assert_eq!(
1835            manifest.inference.unwrap().endpoint,
1836            Some("http://localhost:11434".to_string())
1837        );
1838    }
1839
1840    #[test]
1841    fn http_loopback_ip_endpoint_accepted() {
1842        let manifest = RuntimeManifest::from_yaml_str(
1843            r#"
1844name: cap
1845version: 0.0.1
1846artifacts: []
1847inference:
1848  transport: http
1849  endpoint: http://127.0.0.1:11434
1850  model: test-model
1851  driver:
1852    artifact: murmur-driver-anthropic
1853"#,
1854        )
1855        .unwrap();
1856
1857        assert_eq!(
1858            manifest.inference.unwrap().endpoint,
1859            Some("http://127.0.0.1:11434".to_string())
1860        );
1861    }
1862
1863    #[test]
1864    fn http_non_localhost_endpoint_rejected() {
1865        let err = RuntimeManifest::from_yaml_str(
1866            r#"
1867name: cap
1868version: 0.0.1
1869artifacts: []
1870inference:
1871  transport: http
1872  endpoint: http://api.attacker.example.com
1873  model: test-model
1874  driver:
1875    artifact: murmur-driver-anthropic
1876"#,
1877        )
1878        .unwrap_err();
1879
1880        let msg = err.to_string();
1881        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
1882        assert!(msg.contains("api.attacker.example.com"), "error was: {msg}");
1883    }
1884
1885    #[test]
1886    fn schemeless_endpoint_rejected() {
1887        let err = RuntimeManifest::from_yaml_str(
1888            r#"
1889name: cap
1890version: 0.0.1
1891artifacts: []
1892inference:
1893  transport: http
1894  endpoint: api.anthropic.com
1895  model: test-model
1896  driver:
1897    artifact: murmur-driver-anthropic
1898"#,
1899        )
1900        .unwrap_err();
1901
1902        let msg = err.to_string();
1903        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
1904        assert!(msg.contains("failed to parse"), "error was: {msg}");
1905    }
1906
1907    #[test]
1908    fn malformed_endpoint_rejected() {
1909        let err = RuntimeManifest::from_yaml_str(
1910            r#"
1911name: cap
1912version: 0.0.1
1913artifacts: []
1914inference:
1915  transport: http
1916  endpoint: "not a url"
1917  model: test-model
1918  driver:
1919    artifact: murmur-driver-anthropic
1920"#,
1921        )
1922        .unwrap_err();
1923
1924        let msg = err.to_string();
1925        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
1926        assert!(msg.contains("failed to parse"), "error was: {msg}");
1927    }
1928
1929    #[test]
1930    fn unsupported_scheme_endpoint_rejected() {
1931        let err = RuntimeManifest::from_yaml_str(
1932            r#"
1933name: cap
1934version: 0.0.1
1935artifacts: []
1936inference:
1937  transport: http
1938  endpoint: ftp://example.com
1939  model: test-model
1940  driver:
1941    artifact: murmur-driver-anthropic
1942"#,
1943        )
1944        .unwrap_err();
1945
1946        let msg = err.to_string();
1947        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
1948        assert!(msg.contains("unsupported scheme 'ftp'"), "error was: {msg}");
1949    }
1950
1951    #[test]
1952    fn parses_inline_system_prompt() {
1953        let manifest = RuntimeManifest::from_yaml_str(
1954            r#"
1955name: cap
1956version: 0.0.1
1957artifacts: []
1958inference:
1959  endpoint: http://127.0.0.1:8080
1960  model: test-model
1961  system_prompt: "Always begin with CONFIRMED:"
1962  driver:
1963    artifact: murmur-driver-anthropic
1964"#,
1965        )
1966        .unwrap();
1967
1968        let inference = manifest.inference.unwrap();
1969        assert_eq!(
1970            inference.system_prompt,
1971            Some("Always begin with CONFIRMED:".to_string())
1972        );
1973        assert!(inference.system_prompt_file.is_none());
1974    }
1975
1976    #[test]
1977    fn parses_system_prompt_file() {
1978        let manifest = RuntimeManifest::from_yaml_str(
1979            r#"
1980name: cap
1981version: 0.0.1
1982artifacts: []
1983inference:
1984  endpoint: http://127.0.0.1:8080
1985  model: test-model
1986  system_prompt_file: "conventions.md"
1987  driver:
1988    artifact: murmur-driver-anthropic
1989"#,
1990        )
1991        .unwrap();
1992
1993        let inference = manifest.inference.unwrap();
1994        assert_eq!(
1995            inference.system_prompt_file,
1996            Some("conventions.md".to_string())
1997        );
1998        assert!(inference.system_prompt.is_none());
1999    }
2000
2001    #[test]
2002    fn rejects_both_inline_and_file_system_prompt() {
2003        let err = RuntimeManifest::from_yaml_str(
2004            r#"
2005name: cap
2006version: 0.0.1
2007artifacts: []
2008inference:
2009  endpoint: http://127.0.0.1:8080
2010  model: test-model
2011  system_prompt: "inline"
2012  system_prompt_file: "conventions.md"
2013  driver:
2014    artifact: murmur-driver-anthropic
2015"#,
2016        )
2017        .unwrap_err();
2018
2019        let msg = err.to_string();
2020        assert!(msg.contains("inference.system_prompt"));
2021        assert!(msg.contains("system_prompt_file"));
2022    }
2023
2024    #[test]
2025    fn resolves_api_key_env_reference() {
2026        let key = "MURMUR_TEST_INFERENCE_KEY_RESOLVE";
2027        std::env::set_var(key, "resolved-secret");
2028
2029        let manifest = RuntimeManifest::from_yaml_str(&format!(
2030            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: http\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  api_key: ${{{key}}}\n  driver:\n    artifact: murmur-driver-anthropic\n"
2031        ))
2032        .unwrap();
2033
2034        assert_eq!(
2035            manifest.inference.unwrap().api_key,
2036            Some("resolved-secret".to_string())
2037        );
2038
2039        std::env::remove_var(key);
2040    }
2041
2042    #[test]
2043    fn missing_api_key_env_reference_reports_clear_error() {
2044        let key = "MURMUR_TEST_INFERENCE_KEY_MISSING";
2045        std::env::remove_var(key);
2046
2047        let err = RuntimeManifest::from_yaml_str(&format!(
2048            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  transport: http\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  api_key: ${{{key}}}\n  driver:\n    artifact: murmur-driver-anthropic\n"
2049        ))
2050        .unwrap_err();
2051
2052        let msg = err.to_string();
2053        assert!(msg.contains("inference.api_key"));
2054        assert!(msg.contains(&format!("${{{key}}}")));
2055        assert!(msg.contains("environment variable is not set"));
2056    }
2057
2058    #[test]
2059    fn accepts_legacy_inference_provider_field() {
2060        let manifest = RuntimeManifest::from_yaml_str(
2061            r#"
2062name: cap
2063version: 0.0.1
2064artifacts: []
2065inference:
2066  transport: http
2067  endpoint: http://127.0.0.1:8080
2068  model: test-model
2069  provider:
2070    artifact: murmur-driver-openai
2071"#,
2072        )
2073        .unwrap();
2074
2075        assert_eq!(
2076            manifest.inference.unwrap().driver.as_ref().unwrap().artifact,
2077            "murmur-driver-openai"
2078        );
2079    }
2080
2081    #[test]
2082    fn prefers_driver_field_over_legacy_provider_field() {
2083        let manifest = RuntimeManifest::from_yaml_str(
2084            r#"
2085name: cap
2086version: 0.0.1
2087artifacts: []
2088inference:
2089  transport: http
2090  endpoint: http://127.0.0.1:8080
2091  model: test-model
2092  driver:
2093    artifact: murmur-driver-anthropic
2094  provider:
2095    artifact: murmur-driver-openai
2096"#,
2097        )
2098        .unwrap();
2099
2100        assert_eq!(
2101            manifest.inference.unwrap().driver.as_ref().unwrap().artifact,
2102            "murmur-driver-anthropic"
2103        );
2104    }
2105
2106    #[test]
2107    fn inference_requires_driver_artifact() {
2108        let err = RuntimeManifest::from_yaml_str(
2109            r#"
2110name: cap
2111version: 0.0.1
2112artifacts: []
2113inference:
2114  transport: http
2115  endpoint: http://127.0.0.1:8080
2116  model: test-model
2117"#,
2118        )
2119        .unwrap_err();
2120
2121        assert!(err.to_string().contains("inference.driver"));
2122    }
2123
2124    #[test]
2125    fn test_runtime_driver_deserializes() {
2126        let manifest = RuntimeManifest::from_yaml_str(
2127            r#"
2128name: cap
2129version: 0.0.1
2130artifacts:
2131  - name: my-provider
2132    version: 1.0.0
2133    runtime: driver
2134"#,
2135        )
2136        .unwrap();
2137
2138        assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
2139    }
2140
2141    #[test]
2142    fn test_runtime_unknown_is_error() {
2143        let err = RuntimeManifest::from_yaml_str(
2144            r#"
2145name: cap
2146version: 0.0.1
2147artifacts:
2148  - name: foo
2149    version: 1.0.0
2150    runtime: unknown_type
2151"#,
2152        )
2153        .unwrap_err();
2154
2155        let msg = err.to_string();
2156        assert!(msg.contains("runtime"), "error should mention 'runtime'");
2157        assert!(
2158            msg.contains("unknown_type"),
2159            "error should identify the bad value"
2160        );
2161    }
2162
2163    #[test]
2164    fn test_runtime_all_variants_roundtrip() {
2165        for variant in [
2166            ArtifactRuntime::Tool,
2167            ArtifactRuntime::Driver,
2168            ArtifactRuntime::Hook,
2169            ArtifactRuntime::Skill,
2170        ] {
2171            let json = serde_json::to_string(&variant).unwrap();
2172            let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
2173            assert_eq!(variant, back);
2174        }
2175    }
2176
2177    #[test]
2178    fn skill_source_without_version_defaults_to_local() {
2179        let manifest = RuntimeManifest::from_yaml_str(
2180            r#"
2181name: cap
2182version: 0.0.1
2183artifacts:
2184  - name: my-skill
2185    source: ./skills/my-skill/skill.md
2186    runtime: skill
2187"#,
2188        )
2189        .unwrap();
2190
2191        assert_eq!(manifest.artifacts.len(), 1);
2192        let artifact = &manifest.artifacts[0];
2193        assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
2194        assert_eq!(artifact.source.as_deref(), Some("./skills/my-skill/skill.md"));
2195        assert_eq!(artifact.version, "local");
2196    }
2197
2198    #[test]
2199    fn skill_source_directory_path_is_accepted() {
2200        let manifest = RuntimeManifest::from_yaml_str(
2201            r#"
2202name: cap
2203version: 0.0.1
2204artifacts:
2205  - name: my-skill
2206    source: ./skills/my-skill/
2207    runtime: skill
2208"#,
2209        )
2210        .unwrap();
2211
2212        assert_eq!(manifest.artifacts[0].source.as_deref(), Some("./skills/my-skill/"));
2213        assert_eq!(manifest.artifacts[0].version, "local");
2214    }
2215
2216    #[test]
2217    fn skill_source_with_explicit_version_substitutes_local() {
2218        // Both set is allowed; version is ignored and "local" is substituted.
2219        let manifest = RuntimeManifest::from_yaml_str(
2220            r#"
2221name: cap
2222version: 0.0.1
2223artifacts:
2224  - name: my-skill
2225    source: ./skills/my-skill/skill.md
2226    version: 9.9.9
2227    runtime: skill
2228"#,
2229        )
2230        .unwrap();
2231
2232        assert_eq!(manifest.artifacts[0].version, "local");
2233    }
2234
2235    #[test]
2236    fn local_source_true_allows_source_on_non_skill_runtime() {
2237        for runtime in ["tool", "driver", "hook"] {
2238            let yaml = format!(
2239                r#"
2240name: cap
2241version: 0.0.1
2242artifacts:
2243  - name: my-thing
2244    source: ./local/path
2245    local_source: true
2246    runtime: {runtime}
2247"#
2248            );
2249            let manifest = RuntimeManifest::from_yaml_str(&yaml)
2250                .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
2251            let artifact = &manifest.artifacts[0];
2252            assert!(artifact.local_source, "runtime {runtime}");
2253            assert_eq!(artifact.source.as_deref(), Some("./local/path"));
2254            assert_eq!(artifact.version, "local", "runtime {runtime}");
2255        }
2256    }
2257
2258    #[test]
2259    fn local_source_false_on_skill_with_source_is_error() {
2260        // The gate reads the declared field, not the role: an explicit `local_source: false`
2261        // overrides the skill default and rejects `source:`.
2262        let err = RuntimeManifest::from_yaml_str(
2263            r#"
2264name: cap
2265version: 0.0.1
2266artifacts:
2267  - name: my-skill
2268    source: ./skills/my-skill
2269    local_source: false
2270    runtime: skill
2271"#,
2272        )
2273        .unwrap_err();
2274        let msg = err.to_string();
2275        assert!(msg.contains("'local_source: true'"), "error was: {msg}");
2276        assert!(msg.contains("my-skill"), "error was: {msg}");
2277    }
2278
2279    #[test]
2280    fn local_source_defaults_from_role_when_absent() {
2281        let manifest = RuntimeManifest::from_yaml_str(
2282            r#"
2283name: cap
2284version: 0.0.1
2285artifacts:
2286  - name: my-skill
2287    source: ./skills/my-skill
2288    runtime: skill
2289  - name: my-tool
2290    version: 1.0.0
2291    runtime: tool
2292"#,
2293        )
2294        .unwrap();
2295        assert!(manifest.artifacts[0].local_source, "skill defaults to true");
2296        assert!(
2297            !manifest.artifacts[1].local_source,
2298            "tool defaults to false"
2299        );
2300    }
2301
2302    #[test]
2303    fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
2304        let manifest = RuntimeManifest::from_yaml_str(
2305            r#"
2306name: cap
2307version: 0.0.1
2308artifacts:
2309  - name: my-tool
2310    version: 1.0.0
2311    runtime: tool
2312    prompt_payload: true
2313inference:
2314  endpoint: http://127.0.0.1:8080
2315  model: test-model
2316  system_prompt_artifact: my-tool
2317  driver:
2318    artifact: murmur-driver-anthropic
2319"#,
2320        )
2321        .unwrap();
2322        assert!(manifest.artifacts[0].prompt_payload);
2323    }
2324
2325    #[test]
2326    fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
2327        let err = RuntimeManifest::from_yaml_str(
2328            r#"
2329name: cap
2330version: 0.0.1
2331artifacts:
2332  - name: my-skill
2333    version: 1.0.0
2334    runtime: skill
2335    prompt_payload: false
2336inference:
2337  endpoint: http://127.0.0.1:8080
2338  model: test-model
2339  system_prompt_artifact: my-skill
2340  driver:
2341    artifact: murmur-driver-anthropic
2342"#,
2343        )
2344        .unwrap_err();
2345        let msg = err.to_string();
2346        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
2347    }
2348
2349    #[test]
2350    fn prompt_payload_defaults_from_role_when_absent() {
2351        let manifest = RuntimeManifest::from_yaml_str(
2352            r#"
2353name: cap
2354version: 0.0.1
2355artifacts:
2356  - name: my-skill
2357    version: 1.0.0
2358    runtime: skill
2359  - name: my-tool
2360    version: 1.0.0
2361    runtime: tool
2362"#,
2363        )
2364        .unwrap();
2365        assert!(
2366            manifest.artifacts[0].prompt_payload,
2367            "skill defaults to true"
2368        );
2369        assert!(
2370            !manifest.artifacts[1].prompt_payload,
2371            "tool defaults to false"
2372        );
2373    }
2374
2375    #[test]
2376    fn source_on_non_skill_runtime_is_error() {
2377        for runtime in ["tool", "driver", "hook"] {
2378            let yaml = format!(
2379                r#"
2380name: cap
2381version: 0.0.1
2382artifacts:
2383  - name: my-thing
2384    source: ./local/path
2385    runtime: {runtime}
2386"#
2387            );
2388            let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
2389            let msg = err.to_string();
2390            assert!(
2391                msg.contains("'local_source: true'"),
2392                "runtime {runtime}: error was: {msg}"
2393            );
2394            assert!(
2395                msg.contains("my-thing"),
2396                "runtime {runtime}: error was: {msg}"
2397            );
2398        }
2399    }
2400
2401    #[test]
2402    fn registry_skill_still_requires_version() {
2403        let err = RuntimeManifest::from_yaml_str(
2404            r#"
2405name: cap
2406version: 0.0.1
2407artifacts:
2408  - name: my-skill
2409    runtime: skill
2410"#,
2411        )
2412        .unwrap_err();
2413
2414        let msg = err.to_string();
2415        assert!(
2416            msg.contains("missing required field 'version'"),
2417            "error was: {msg}"
2418        );
2419    }
2420
2421    #[test]
2422    fn rejects_unknown_inference_transport() {
2423        let err = RuntimeManifest::from_yaml_str(
2424            r#"
2425name: cap
2426version: 0.0.1
2427artifacts: []
2428inference:
2429  transport: grpc
2430  endpoint: ignored
2431  model: test-model
2432  driver:
2433    artifact: murmur-driver-anthropic
2434"#,
2435        )
2436        .unwrap_err();
2437
2438        let msg = err.to_string();
2439        assert!(msg.contains("inference.transport"), "error was: {msg}");
2440        assert!(msg.contains("unknown value"), "error was: {msg}");
2441        assert!(msg.contains("grpc"), "error was: {msg}");
2442    }
2443
2444    #[test]
2445    fn parses_process_transport() {
2446        let manifest = RuntimeManifest::from_yaml_str(
2447            r#"
2448name: cap
2449version: 0.0.1
2450artifacts: []
2451inference:
2452  transport: process
2453  command: claude
2454  model: claude-haiku-4-5-20251001
2455  max_turns: 20
2456"#,
2457        )
2458        .unwrap();
2459
2460        let inference = manifest.inference.expect("inference should exist");
2461        assert_eq!(inference.transport, "process");
2462        assert_eq!(inference.command, Some("claude".to_string()));
2463        assert_eq!(inference.model, "claude-haiku-4-5-20251001");
2464        assert_eq!(inference.max_turns, 20);
2465        assert!(inference.driver.is_none());
2466        assert!(inference.endpoint.is_none());
2467        assert!(inference.api_key.is_none());
2468    }
2469
2470    #[test]
2471    fn process_transport_requires_command() {
2472        let err = RuntimeManifest::from_yaml_str(
2473            r#"
2474name: cap
2475version: 0.0.1
2476artifacts: []
2477inference:
2478  transport: process
2479  model: claude-haiku-4-5-20251001
2480"#,
2481        )
2482        .unwrap_err();
2483
2484        let msg = err.to_string();
2485        assert!(msg.contains("inference.command"), "error was: {msg}");
2486    }
2487
2488    #[test]
2489    fn process_transport_allows_absent_model() {
2490        // model is optional for transport: process — an empty/absent model means "use the CLI's
2491        // account-default model" (e.g. a codex subscription default). The command is still required.
2492        let manifest = RuntimeManifest::from_yaml_str(
2493            r#"
2494name: cap
2495version: 0.0.1
2496artifacts: []
2497inference:
2498  transport: process
2499  command: codex
2500"#,
2501        )
2502        .expect("process transport should parse without a model");
2503        let inference = manifest.inference.expect("inference present");
2504        assert_eq!(inference.transport, "process");
2505        assert_eq!(inference.command.as_deref(), Some("codex"));
2506        assert_eq!(inference.model, "", "absent model resolves to empty (provider default)");
2507    }
2508
2509    #[test]
2510    fn process_transport_rejects_driver_artifact() {
2511        let err = RuntimeManifest::from_yaml_str(
2512            r#"
2513name: cap
2514version: 0.0.1
2515artifacts: []
2516inference:
2517  transport: process
2518  command: claude
2519  model: claude-haiku-4-5-20251001
2520  driver:
2521    artifact: murmur-driver-anthropic
2522"#,
2523        )
2524        .unwrap_err();
2525
2526        let msg = err.to_string();
2527        assert!(msg.contains("inference.driver.artifact"), "error was: {msg}");
2528        assert!(msg.contains("not valid with transport: process"), "error was: {msg}");
2529    }
2530
2531    #[test]
2532    fn process_transport_rejects_endpoint() {
2533        let err = RuntimeManifest::from_yaml_str(
2534            r#"
2535name: cap
2536version: 0.0.1
2537artifacts: []
2538inference:
2539  transport: process
2540  command: claude
2541  model: claude-haiku-4-5-20251001
2542  endpoint: http://localhost:8080
2543"#,
2544        )
2545        .unwrap_err();
2546
2547        let msg = err.to_string();
2548        assert!(msg.contains("inference.endpoint"), "error was: {msg}");
2549        assert!(msg.contains("not valid with transport: process"), "error was: {msg}");
2550    }
2551
2552    #[test]
2553    fn process_transport_rejects_api_key() {
2554        // Key assembled at runtime so the source never contains a
2555        // credential-shaped literal that secret scanners could flag.
2556        let key = ["sk-", "ant-", "secret"].concat();
2557        let err = RuntimeManifest::from_yaml_str(&format!(
2558            r#"
2559name: cap
2560version: 0.0.1
2561artifacts: []
2562inference:
2563  transport: process
2564  command: claude
2565  model: claude-haiku-4-5-20251001
2566  api_key: {key}
2567"#
2568        ))
2569        .unwrap_err();
2570
2571        let msg = err.to_string();
2572        assert!(msg.contains("inference.api_key"), "error was: {msg}");
2573        assert!(msg.contains("not valid with transport: process"), "error was: {msg}");
2574    }
2575
2576    #[test]
2577    fn hook_config_defaults_when_fields_absent() {
2578        let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
2579        assert_eq!(config.binding, HookBinding::All);
2580        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
2581        assert_eq!(config.commit_policy, HookCommitPolicy::None);
2582    }
2583
2584    #[test]
2585    fn hook_config_parses_all_fields() {
2586        let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
2587        let config = parse_hook_config_from_yaml(yaml).unwrap();
2588        assert_eq!(config.binding, HookBinding::OnCompaction);
2589        assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
2590        assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
2591    }
2592
2593    #[test]
2594    fn hook_config_parses_on_task_start() {
2595        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
2596        let config = parse_hook_config_from_yaml(yaml).unwrap();
2597        assert_eq!(config.binding, HookBinding::OnTaskStart);
2598    }
2599
2600    #[test]
2601    fn hook_config_parses_on_task_end() {
2602        let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
2603        let config = parse_hook_config_from_yaml(yaml).unwrap();
2604        assert_eq!(config.binding, HookBinding::OnTaskEnd);
2605    }
2606
2607    #[test]
2608    fn hook_config_unknown_binding_lists_task_events() {
2609        let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
2610        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
2611        assert!(err.contains("on-task-start"), "error was: {err}");
2612        assert!(err.contains("on-task-end"), "error was: {err}");
2613    }
2614
2615    #[test]
2616    fn hook_config_async_with_commit_is_rejected() {
2617        let yaml =
2618            "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
2619        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
2620        assert!(err.contains("async-with-commit"), "error was: {err}");
2621    }
2622
2623    #[test]
2624    fn hook_config_on_stage_async_is_rejected() {
2625        let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
2626        let err = parse_hook_config_from_yaml(yaml).unwrap_err();
2627        assert!(err.contains("on-stage"), "error was: {err}");
2628        assert!(err.contains("blocking"), "error was: {err}");
2629    }
2630
2631    #[test]
2632    fn hook_config_async_none_is_valid() {
2633        let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
2634        let config = parse_hook_config_from_yaml(yaml).unwrap();
2635        assert_eq!(config.execution_mode, HookExecutionMode::Async);
2636        assert_eq!(config.commit_policy, HookCommitPolicy::None);
2637    }
2638
2639    #[test]
2640    fn parse_tool_implementation_defaults_to_wasm() {
2641        let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
2642        assert_eq!(impl_, ArtifactImplementation::Wasm);
2643    }
2644
2645    #[test]
2646    fn parse_tool_implementation_native() {
2647        let impl_ = parse_tool_implementation_from_yaml(
2648            "name: my-tool\nruntime: tool\nimplementation: native\n",
2649        );
2650        assert_eq!(impl_, ArtifactImplementation::Native);
2651    }
2652
2653    #[test]
2654    fn mur_version_parses_correctly() {
2655        let manifest = RuntimeManifest::from_yaml_str(
2656            "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"0.4.5\"\n",
2657        )
2658        .unwrap();
2659        assert_eq!(manifest.mur_version, Some("0.4.5".to_string()));
2660    }
2661
2662    #[test]
2663    fn mur_version_absent_is_none() {
2664        let manifest = RuntimeManifest::from_yaml_str(
2665            "name: cap\nversion: 0.1.0\nartifacts: []\n",
2666        )
2667        .unwrap();
2668        assert_eq!(manifest.mur_version, None);
2669    }
2670
2671    #[test]
2672    fn mur_version_empty_string_is_none() {
2673        let manifest = RuntimeManifest::from_yaml_str(
2674            "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"\"\n",
2675        )
2676        .unwrap();
2677        assert_eq!(manifest.mur_version, None);
2678    }
2679
2680    #[test]
2681    fn inference_max_turns_defaults_to_10() {
2682        let manifest = RuntimeManifest::from_yaml_str(
2683            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  driver:\n    artifact: murmur-driver-anthropic\n",
2684        ).unwrap();
2685        assert_eq!(manifest.inference.unwrap().max_turns, 10);
2686    }
2687
2688    #[test]
2689    fn inference_max_turns_explicit_value() {
2690        let manifest = RuntimeManifest::from_yaml_str(
2691            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  max_turns: 20\n  driver:\n    artifact: murmur-driver-anthropic\n",
2692        ).unwrap();
2693        assert_eq!(manifest.inference.unwrap().max_turns, 20);
2694    }
2695
2696    #[test]
2697    fn inference_max_turns_zero_is_rejected() {
2698        let err = RuntimeManifest::from_yaml_str(
2699            "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n  endpoint: http://127.0.0.1:8080\n  model: test-model\n  max_turns: 0\n  driver:\n    artifact: murmur-driver-anthropic\n",
2700        ).unwrap_err();
2701        let msg = err.to_string();
2702        assert!(msg.contains("inference.max_turns"), "error was: {msg}");
2703        assert!(msg.contains("greater than 0"), "error was: {msg}");
2704    }
2705
2706    #[test]
2707    fn skill_is_llm_visible() {
2708        assert!(ArtifactRuntime::Skill.is_llm_visible());
2709    }
2710
2711    #[test]
2712    fn driver_and_hook_are_not_llm_visible() {
2713        assert!(!ArtifactRuntime::Driver.is_llm_visible());
2714        assert!(!ArtifactRuntime::Hook.is_llm_visible());
2715    }
2716
2717    #[test]
2718    fn parses_system_prompt_artifact() {
2719        let manifest = RuntimeManifest::from_yaml_str(
2720            r#"
2721name: cap
2722version: 0.0.1
2723artifacts:
2724  - name: my-skill
2725    version: 1.0.0
2726    runtime: skill
2727inference:
2728  endpoint: http://127.0.0.1:8080
2729  model: test-model
2730  system_prompt_artifact: my-skill
2731  driver:
2732    artifact: murmur-driver-anthropic
2733"#,
2734        )
2735        .unwrap();
2736
2737        let inference = manifest.inference.unwrap();
2738        assert_eq!(
2739            inference.system_prompt_artifact,
2740            Some("my-skill".to_string())
2741        );
2742        assert!(inference.system_prompt.is_none());
2743        assert!(inference.system_prompt_file.is_none());
2744    }
2745
2746    #[test]
2747    fn rejects_system_prompt_artifact_with_inline_prompt() {
2748        let err = RuntimeManifest::from_yaml_str(
2749            r#"
2750name: cap
2751version: 0.0.1
2752artifacts:
2753  - name: my-skill
2754    version: 1.0.0
2755    runtime: skill
2756inference:
2757  endpoint: http://127.0.0.1:8080
2758  model: test-model
2759  system_prompt: "inline"
2760  system_prompt_artifact: my-skill
2761  driver:
2762    artifact: murmur-driver-anthropic
2763"#,
2764        )
2765        .unwrap_err();
2766
2767        let msg = err.to_string();
2768        assert!(msg.contains("at most one"), "error was: {msg}");
2769    }
2770
2771    #[test]
2772    fn rejects_system_prompt_artifact_naming_non_skill() {
2773        let err = RuntimeManifest::from_yaml_str(
2774            r#"
2775name: cap
2776version: 0.0.1
2777artifacts:
2778  - name: my-tool
2779    version: 1.0.0
2780    runtime: tool
2781inference:
2782  endpoint: http://127.0.0.1:8080
2783  model: test-model
2784  system_prompt_artifact: my-tool
2785  driver:
2786    artifact: murmur-driver-anthropic
2787"#,
2788        )
2789        .unwrap_err();
2790
2791        let msg = err.to_string();
2792        assert!(
2793            msg.contains("system_prompt_artifact"),
2794            "error was: {msg}"
2795        );
2796        assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
2797    }
2798
2799    #[test]
2800    fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
2801        let err = RuntimeManifest::from_yaml_str(
2802            r#"
2803name: cap
2804version: 0.0.1
2805artifacts: []
2806inference:
2807  endpoint: http://127.0.0.1:8080
2808  model: test-model
2809  system_prompt_artifact: missing-skill
2810  driver:
2811    artifact: murmur-driver-anthropic
2812"#,
2813        )
2814        .unwrap_err();
2815
2816        let msg = err.to_string();
2817        assert!(
2818            msg.contains("system_prompt_artifact"),
2819            "error was: {msg}"
2820        );
2821        assert!(msg.contains("not declared"), "error was: {msg}");
2822    }
2823
2824    #[test]
2825    fn rejects_all_three_system_prompt_sources() {
2826        let err = RuntimeManifest::from_yaml_str(
2827            r#"
2828name: cap
2829version: 0.0.1
2830artifacts:
2831  - name: my-skill
2832    version: 1.0.0
2833    runtime: skill
2834inference:
2835  endpoint: http://127.0.0.1:8080
2836  model: test-model
2837  system_prompt: "inline"
2838  system_prompt_file: "conventions.md"
2839  system_prompt_artifact: my-skill
2840  driver:
2841    artifact: murmur-driver-anthropic
2842"#,
2843        )
2844        .unwrap_err();
2845
2846        let msg = err.to_string();
2847        assert!(msg.contains("at most one"), "error was: {msg}");
2848    }
2849}