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#[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 pub input_timeout_secs: Option<u64>,
44 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#[derive(Debug, Clone, Default)]
65pub struct LifecycleOverride {
66 pub task_acceptance: Option<TaskAcceptance>,
67 pub after_task: Option<AfterTask>,
68}
69
70#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum ArtifactImplementation {
127 Wasm,
128 Native,
129}
130
131pub 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
144pub 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#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum HookBinding {
169 OnStage,
171 OnSessionStart,
173 OnTaskStart,
175 OnInference,
177 OnToolCall,
179 OnShell,
181 OnCompaction,
183 OnTaskEnd,
185 OnSessionEnd,
187 All,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum HookExecutionMode {
197 Blocking,
199 Async,
201}
202
203#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum HookCommitPolicy {
209 None,
211 ReplaceContext,
213 WriteManifests,
215}
216
217#[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
235pub 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
247pub 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 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 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 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 pub source: Option<String>,
372 pub local_source: bool,
377 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 pub endpoint: Option<String>,
395 pub model: String,
396 pub api_key: Option<String>,
397 pub driver: Option<InferenceDriver>,
399 pub command: Option<String>,
401 pub compaction: Option<CompactionConfig>,
402 pub system_prompt: Option<String>,
403 pub system_prompt_file: Option<String>,
404 pub system_prompt_artifact: Option<String>,
408 pub max_turns: u32,
411}
412
413#[derive(Debug, Clone, PartialEq)]
414pub struct CompactionConfig {
415 pub threshold: Option<f32>,
417 pub model: Option<String>,
419}
420
421impl Eq for CompactionConfig {}
423
424#[derive(Debug, Clone, PartialEq, Eq)]
425pub struct ContextConfig {
426 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 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
487pub struct EnvCapabilities {
488 pub allow: Vec<String>,
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
499pub struct ResourceLimits {
500 pub memory_bytes: Option<usize>,
502 pub table_elements: Option<usize>,
504 pub instances: Option<usize>,
506 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 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 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 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 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(_) => {} }
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
1008fn 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 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 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
1284fn 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 #[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 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 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 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 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}