1use std::{fs, net::IpAddr, path::Path};
2
3use serde::{Deserialize, Serialize};
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
191impl HookBinding {
192 #[must_use]
197 pub fn as_str(&self) -> &'static str {
198 match self {
199 Self::OnStage => "on-stage",
200 Self::OnSessionStart => "on-session-start",
201 Self::OnTaskStart => "on-task-start",
202 Self::OnInference => "on-inference",
203 Self::OnToolCall => "on-tool-call",
204 Self::OnShell => "on-shell",
205 Self::OnCompaction => "on-compaction",
206 Self::OnTaskEnd => "on-task-end",
207 Self::OnSessionEnd => "on-session-end",
208 Self::All => "all",
209 }
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq)]
218pub enum HookExecutionMode {
219 Blocking,
221 Async,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum HookCommitPolicy {
231 None,
233 ReplaceContext,
235 WriteManifests,
237 ReopenTask,
240}
241
242impl HookCommitPolicy {
243 #[must_use]
245 pub fn as_str(&self) -> &'static str {
246 match self {
247 Self::None => "none",
248 Self::ReplaceContext => "replace-context",
249 Self::WriteManifests => "write-manifests",
250 Self::ReopenTask => "reopen-task",
251 }
252 }
253}
254
255#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
265#[serde(rename_all = "snake_case")]
266pub enum HookOverflowPolicy {
267 #[default]
271 Drop,
272 Block,
275}
276
277impl HookOverflowPolicy {
278 #[must_use]
280 pub fn as_str(&self) -> &'static str {
281 match self {
282 Self::Drop => "drop",
283 Self::Block => "block",
284 }
285 }
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct HookConfig {
291 pub binding: HookBinding,
292 pub execution_mode: HookExecutionMode,
293 pub commit_policy: HookCommitPolicy,
294}
295
296impl Default for HookConfig {
297 fn default() -> Self {
298 Self {
299 binding: HookBinding::All,
300 execution_mode: HookExecutionMode::Blocking,
301 commit_policy: HookCommitPolicy::None,
302 }
303 }
304}
305
306#[must_use]
325pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
326 match binding {
327 HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
328 HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
329 HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
330 HookBinding::OnSessionStart
331 | HookBinding::OnTaskStart
332 | HookBinding::OnInference
333 | HookBinding::OnToolCall
334 | HookBinding::OnShell
335 | HookBinding::OnSessionEnd
336 | HookBinding::All => None,
337 }
338}
339
340pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
342 let content =
343 fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
344 path: artifact_manifest_path.display().to_string(),
345 source,
346 })?;
347 parse_hook_config_from_yaml(&content).map_err(|msg| {
348 RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
349 })
350}
351
352pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
354 let v: serde_yaml::Value =
355 serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
356
357 let binding = v
358 .as_mapping()
359 .and_then(|m| m.get(serde_yaml::Value::String("binding".to_string())))
360 .and_then(serde_yaml::Value::as_str)
361 .map(|s| match s {
362 "on-stage" => Ok(HookBinding::OnStage),
363 "on-session-start" => Ok(HookBinding::OnSessionStart),
364 "on-task-start" => Ok(HookBinding::OnTaskStart),
365 "on-inference" => Ok(HookBinding::OnInference),
366 "on-tool-call" => Ok(HookBinding::OnToolCall),
367 "on-shell" => Ok(HookBinding::OnShell),
368 "on-compaction" => Ok(HookBinding::OnCompaction),
369 "on-task-end" => Ok(HookBinding::OnTaskEnd),
370 "on-session-end" => Ok(HookBinding::OnSessionEnd),
371 other => Err(format!(
372 "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
373 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
374 )),
375 })
376 .transpose()?
377 .unwrap_or(HookBinding::All);
378
379 let execution_mode = v
380 .as_mapping()
381 .and_then(|m| m.get(serde_yaml::Value::String("execution_mode".to_string())))
382 .and_then(serde_yaml::Value::as_str)
383 .map(|s| match s {
384 "blocking" => Ok(HookExecutionMode::Blocking),
385 "async" => Ok(HookExecutionMode::Async),
386 other => Err(format!(
387 "unknown execution_mode '{other}'; expected: blocking, async"
388 )),
389 })
390 .transpose()?
391 .unwrap_or(HookExecutionMode::Blocking);
392
393 let commit_policy = v
394 .as_mapping()
395 .and_then(|m| m.get(serde_yaml::Value::String("commit_policy".to_string())))
396 .and_then(serde_yaml::Value::as_str)
397 .map(|s| match s {
398 "none" => Ok(HookCommitPolicy::None),
399 "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
400 "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
401 "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
402 other => Err(format!(
403 "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
404 reopen-task"
405 )),
406 })
407 .transpose()?
408 .unwrap_or(HookCommitPolicy::None);
409
410 if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
412 return Err(format!(
413 "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
414 (got '{}')",
415 commit_policy.as_str()
416 ));
417 }
418 if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
419 return Err(
420 "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
421 'on-stage'"
422 .to_string(),
423 );
424 }
425 if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
430 let honored = commit_policy_for_binding(&binding);
431 if honored.as_ref() != Some(&commit_policy) {
432 let note = if binding == HookBinding::OnInference {
433 " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
434 } else {
435 ""
436 };
437 return Err(format!(
438 "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
439 commit_policy '{}'{note}",
440 commit_policy.as_str(),
441 binding.as_str(),
442 binding.as_str(),
443 honored.as_ref().map_or("none", HookCommitPolicy::as_str)
444 ));
445 }
446 }
447
448 Ok(HookConfig {
449 binding,
450 execution_mode,
451 commit_policy,
452 })
453}
454
455#[derive(Debug, Clone, PartialEq, Eq)]
456pub struct RuntimeManifest {
457 pub name: String,
458 pub version: String,
459 pub artifacts: Vec<RuntimeArtifact>,
460 pub capabilities: Option<Capabilities>,
461 pub inference: Option<InferenceConfig>,
462 pub context: Option<ContextConfig>,
463 pub observability: Option<ObservabilityConfig>,
464 pub trace: Option<TraceConfig>,
465 pub network: Option<NetworkConfig>,
466 pub lifecycle: Option<LifecycleConfig>,
467 pub mur_version: Option<String>,
472}
473
474impl RuntimeManifest {
475 pub fn effective_lifecycle(&self) -> LifecycleConfig {
476 self.lifecycle.clone().unwrap_or_default()
477 }
478}
479
480#[derive(Debug, Clone, PartialEq, Eq)]
481pub struct NetworkConfig {
482 pub internal_port: Option<u16>,
484}
485
486#[derive(Debug, Clone, PartialEq, Eq)]
487pub struct RuntimeArtifact {
488 pub name: String,
489 pub version: String,
490 pub runtime: ArtifactRuntime,
491 pub source: Option<String>,
497 pub local_source: bool,
502 pub prompt_payload: bool,
507 pub capabilities: Option<Capabilities>,
526 pub on_overflow: HookOverflowPolicy,
531}
532
533#[derive(Debug, Clone, PartialEq, Eq)]
534pub struct InferenceDriver {
535 pub artifact: String,
536 pub config: Option<String>,
537}
538
539#[derive(Debug, Clone, PartialEq, Eq)]
540pub struct InferenceConfig {
541 pub transport: String,
542 pub endpoint: Option<String>,
544 pub model: String,
545 pub api_key: Option<String>,
546 pub driver: Option<InferenceDriver>,
548 pub command: Option<String>,
550 pub compaction: Option<CompactionConfig>,
551 pub system_prompt: Option<String>,
552 pub system_prompt_file: Option<String>,
553 pub system_prompt_artifact: Option<String>,
557 pub max_turns: u32,
560 pub max_task_reopens: u32,
565 pub max_tokens: Option<u32>,
572}
573
574#[derive(Debug, Clone, PartialEq)]
575pub struct CompactionConfig {
576 pub threshold: Option<f32>,
578 pub model: Option<String>,
580 pub system_prompt: Option<String>,
583 pub system_prompt_file: Option<String>,
587 pub dump_summaries: Option<bool>,
591}
592
593impl Eq for CompactionConfig {}
595
596#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct ContextConfig {
598 pub max_tokens: Option<u32>,
600}
601
602#[derive(Debug, Clone, PartialEq, Eq)]
603pub struct ObservabilityConfig {
604 pub otel_endpoint: Option<String>,
605 pub eval: Option<EvalConfig>,
606}
607
608#[derive(Debug, Clone, PartialEq, Eq)]
609pub struct TraceConfig {
610 pub include_tool_output: bool,
613}
614
615#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
616pub struct EvalConfig {
617 #[serde(skip_serializing_if = "Option::is_none")]
618 pub dataset_id: Option<String>,
619 pub scorers: Vec<ScorerConfig>,
620}
621
622#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
623#[serde(tag = "type", rename_all = "snake_case")]
624pub enum ScorerConfig {
625 ExitOk { name: String },
626 MaxTurns { name: String, max: u32 },
627 MaxTokens { name: String, max: u64 },
628 ToolSequence { name: String, expected: Vec<String> },
629 LlmJudge { name: String },
630}
631
632#[derive(Debug, Clone, PartialEq, Eq)]
633pub struct NetworkCapabilities {
634 pub allow: Vec<String>,
635 pub unix_sockets: bool,
643}
644
645#[derive(Debug, Clone, PartialEq, Eq)]
646pub struct FilesystemCapabilities {
647 pub scope: Option<String>,
648 pub workdir_exec: bool,
663}
664
665#[derive(Debug, Clone, PartialEq, Eq)]
666pub struct ShellCapabilities {
667 pub allow: Vec<String>,
668 pub strip_env: Option<Vec<String>>,
669 pub baseline_env: Option<Vec<String>>,
670 pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
677 pub staged_runtime: Vec<StagedRuntimeGrant>,
683}
684
685#[derive(Debug, Clone, PartialEq, Eq)]
690pub struct InterpreterRuntimeGrant {
691 pub binary: String,
695 pub dirs: Vec<InterpreterRuntimeDir>,
698}
699
700#[derive(Debug, Clone, PartialEq, Eq)]
702pub struct InterpreterRuntimeDir {
703 pub path: String,
705 pub list_dir: bool,
710}
711
712#[derive(Debug, Clone, PartialEq, Eq)]
729pub struct StagedRuntimeGrant {
730 pub binary: String,
734 pub source_path: String,
738 pub pin: String,
743}
744
745#[derive(Debug, Clone, PartialEq, Eq)]
746pub struct SpawnCapabilities {
747 pub allow: Vec<String>,
748}
749
750#[derive(Debug, Clone, PartialEq, Eq, Default)]
755pub struct EnvCapabilities {
756 pub allow: Vec<String>,
757}
758
759#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
767pub struct ResourceLimits {
768 pub memory_bytes: Option<usize>,
770 pub table_elements: Option<usize>,
772 pub instances: Option<usize>,
774 pub deadline_seconds: Option<u64>,
776}
777
778#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
792pub struct ResourceCapabilities {
793 pub max_processes: Option<u64>,
795 pub max_open_files: Option<u64>,
797 pub max_file_size_bytes: Option<u64>,
799 pub cpu_seconds: Option<u64>,
801 pub memory_bytes: Option<u64>,
804 pub cgroup_memory_bytes: Option<u64>,
806 pub cgroup_pids_max: Option<u64>,
808 pub cgroup_cpu_percent: Option<u32>,
810 pub cgroup_io_bytes_per_sec: Option<u64>,
813 pub workdir_max_bytes: Option<u64>,
815}
816
817#[derive(
830 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
831)]
832#[serde(rename_all = "snake_case")]
833pub enum ContainmentClass {
834 #[default]
838 Advisory,
839 Scoped,
843 Sealed,
851}
852
853impl ContainmentClass {
854 pub fn as_str(self) -> &'static str {
857 match self {
858 Self::Advisory => "advisory",
859 Self::Scoped => "scoped",
860 Self::Sealed => "sealed",
861 }
862 }
863
864 pub const ALL: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
866}
867
868impl std::fmt::Display for ContainmentClass {
869 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
870 f.write_str(self.as_str())
871 }
872}
873
874#[derive(Debug, Clone, PartialEq, Eq)]
876pub struct ParseContainmentClassError {
877 pub value: String,
878}
879
880impl std::fmt::Display for ParseContainmentClassError {
881 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
882 write!(
883 f,
884 "must be one of: advisory, scoped, sealed; got '{}'",
885 self.value
886 )
887 }
888}
889
890impl std::error::Error for ParseContainmentClassError {}
891
892impl std::str::FromStr for ContainmentClass {
893 type Err = ParseContainmentClassError;
894
895 fn from_str(value: &str) -> Result<Self, Self::Err> {
896 match value.trim() {
897 "advisory" => Ok(Self::Advisory),
898 "scoped" => Ok(Self::Scoped),
899 "sealed" => Ok(Self::Sealed),
900 other => Err(ParseContainmentClassError {
901 value: other.to_string(),
902 }),
903 }
904 }
905}
906
907pub fn effective_containment_floor(
913 workspace_default: Option<ContainmentClass>,
914 manifest_declared: Option<ContainmentClass>,
915 cli_flag: Option<ContainmentClass>,
916) -> ContainmentClass {
917 [workspace_default, manifest_declared, cli_flag]
918 .into_iter()
919 .flatten()
920 .max()
921 .unwrap_or_default()
922}
923
924#[derive(Debug, Clone, PartialEq, Eq)]
925pub struct Capabilities {
926 pub network: Option<NetworkCapabilities>,
927 pub filesystem: Option<FilesystemCapabilities>,
928 pub shell: Option<ShellCapabilities>,
929 pub spawn: Option<SpawnCapabilities>,
930 pub env: Option<EnvCapabilities>,
931 pub limits: Option<ResourceLimits>,
932 pub resources: Option<ResourceCapabilities>,
933 pub containment: Option<ContainmentClass>,
937}
938
939#[derive(Debug, Error)]
940pub enum RuntimeManifestError {
941 #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
942 NotFound(String),
943 #[error("{0}")]
944 YamlSyntax(String),
945 #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
946 MissingField { field: String },
947 #[error(
948 "{}: invalid artifact declaration at index {index}: {message}",
949 MANIFEST_FILENAME
950 )]
951 InvalidArtifact { index: usize, message: String },
952 #[error(
953 "{}: invalid inference config for '{field}': {message}",
954 MANIFEST_FILENAME
955 )]
956 InvalidInferenceConfig { field: String, message: String },
957 #[error(
958 "{}: invalid capability config for '{field}': {message}",
959 MANIFEST_FILENAME
960 )]
961 InvalidCapabilities { field: String, message: String },
962 #[error(
963 "{}: inference.api_key references {reference} but the environment variable is not set",
964 MANIFEST_FILENAME
965 )]
966 MissingInferenceEnvVar {
967 field: String,
968 reference: String,
969 variable: String,
970 },
971 #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
972 Io {
973 path: String,
974 #[source]
975 source: std::io::Error,
976 },
977}
978
979#[derive(Debug, Deserialize)]
980struct RawRuntimeManifest {
981 name: Option<String>,
982 version: Option<String>,
983 #[serde(default)]
984 artifacts: Vec<RawArtifact>,
985 #[serde(default)]
986 capabilities: Option<RawCapabilities>,
987 #[serde(default)]
988 inference: Option<RawInferenceConfig>,
989 #[serde(default)]
990 context: Option<RawContextConfig>,
991 #[serde(default)]
992 observability: Option<RawObservabilityConfig>,
993 #[serde(default)]
994 trace: Option<RawTraceConfig>,
995 #[serde(default)]
996 network: Option<RawNetworkConfig>,
997 #[serde(default)]
998 lifecycle: Option<RawLifecycleConfig>,
999 #[serde(default)]
1000 mur_version: Option<String>,
1001}
1002
1003#[derive(Debug, Deserialize)]
1004struct RawLifecycleConfig {
1005 #[serde(default)]
1006 task_acceptance: Option<TaskAcceptance>,
1007 #[serde(default)]
1008 after_task: Option<AfterTask>,
1009 #[serde(default)]
1010 queue_depth: Option<usize>,
1011 #[serde(default)]
1012 input_timeout_secs: Option<u64>,
1013 #[serde(default)]
1014 conversation: Option<ConversationMode>,
1015}
1016
1017#[derive(Debug, Deserialize)]
1018struct RawNetworkConfig {
1019 internal_port: Option<u16>,
1020}
1021
1022#[derive(Debug, Deserialize)]
1023struct RawContextConfig {
1024 max_tokens: Option<u32>,
1025}
1026
1027#[derive(Debug, Deserialize)]
1028struct RawObservabilityConfig {
1029 #[serde(default)]
1030 otel_endpoint: Option<String>,
1031 #[serde(default)]
1032 eval: Option<RawEvalConfig>,
1033}
1034
1035#[derive(Debug, Deserialize)]
1036struct RawTraceConfig {
1037 #[serde(default)]
1038 include_tool_output: Option<bool>,
1039}
1040
1041#[derive(Debug, Deserialize)]
1042struct RawEvalConfig {
1043 #[serde(default)]
1044 dataset_id: Option<String>,
1045 #[serde(default)]
1046 scorers: Vec<RawScorerConfig>,
1047}
1048
1049#[derive(Debug, Deserialize)]
1050struct RawScorerConfig {
1051 #[serde(rename = "type")]
1052 scorer_type: String,
1053 name: Option<String>,
1054 max: Option<u64>,
1055 expected: Option<Vec<String>>,
1056}
1057
1058#[derive(Debug, Deserialize)]
1059struct RawArtifact {
1060 name: Option<String>,
1061 version: Option<String>,
1062 runtime: Option<String>,
1063 #[serde(default)]
1064 source: Option<String>,
1065 #[serde(default)]
1066 local_source: Option<bool>,
1067 #[serde(default)]
1068 prompt_payload: Option<bool>,
1069 #[serde(default)]
1070 capabilities: Option<RawCapabilities>,
1071 #[serde(default)]
1072 on_overflow: Option<String>,
1073}
1074
1075#[derive(Debug, Deserialize)]
1076struct RawCapabilities {
1077 #[serde(default)]
1078 network: Option<RawNetworkCapabilities>,
1079 #[serde(default)]
1080 filesystem: Option<RawFilesystemCapabilities>,
1081 #[serde(default)]
1082 shell: Option<RawShellCapabilities>,
1083 #[serde(default)]
1084 spawn: Option<RawSpawnCapabilities>,
1085 #[serde(default)]
1086 env: Option<RawEnvCapabilities>,
1087 #[serde(default)]
1088 limits: Option<RawResourceLimits>,
1089 #[serde(default)]
1090 resources: Option<RawResourceCapabilities>,
1091 #[serde(default)]
1095 containment: Option<String>,
1096}
1097
1098#[derive(Debug, Deserialize)]
1099struct RawSpawnCapabilities {
1100 #[serde(default)]
1101 allow: Vec<String>,
1102}
1103
1104#[derive(Debug, Deserialize)]
1105struct RawEnvCapabilities {
1106 #[serde(default)]
1107 allow: Vec<String>,
1108}
1109
1110#[derive(Debug, Deserialize)]
1111struct RawResourceLimits {
1112 #[serde(default)]
1113 memory_bytes: Option<usize>,
1114 #[serde(default)]
1115 table_elements: Option<usize>,
1116 #[serde(default)]
1117 instances: Option<usize>,
1118 #[serde(default)]
1119 deadline_seconds: Option<u64>,
1120}
1121
1122#[derive(Debug, Deserialize)]
1123struct RawResourceCapabilities {
1124 #[serde(default)]
1125 max_processes: Option<u64>,
1126 #[serde(default)]
1127 max_open_files: Option<u64>,
1128 #[serde(default)]
1129 max_file_size_bytes: Option<u64>,
1130 #[serde(default)]
1131 cpu_seconds: Option<u64>,
1132 #[serde(default)]
1133 memory_bytes: Option<u64>,
1134 #[serde(default)]
1135 cgroup_memory_bytes: Option<u64>,
1136 #[serde(default)]
1137 cgroup_pids_max: Option<u64>,
1138 #[serde(default)]
1139 cgroup_cpu_percent: Option<u32>,
1140 #[serde(default)]
1141 cgroup_io_bytes_per_sec: Option<u64>,
1142 #[serde(default)]
1143 workdir_max_bytes: Option<u64>,
1144}
1145
1146#[derive(Debug, Deserialize)]
1147struct RawNetworkCapabilities {
1148 #[serde(default)]
1149 allow: Vec<String>,
1150 #[serde(default)]
1151 unix_sockets: bool,
1152}
1153
1154#[derive(Debug, Deserialize)]
1155struct RawFilesystemCapabilities {
1156 #[serde(default)]
1157 scope: Option<String>,
1158 #[serde(default)]
1162 workdir_exec: bool,
1163}
1164
1165#[derive(Debug, Deserialize)]
1166struct RawShellCapabilities {
1167 #[serde(default)]
1168 allow: Vec<String>,
1169 #[serde(default)]
1170 strip_env: Option<Vec<String>>,
1171 #[serde(default)]
1172 baseline_env: Option<Vec<String>>,
1173 #[serde(default)]
1174 interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
1175 #[serde(default)]
1176 staged_runtime: Vec<RawStagedRuntimeGrant>,
1177}
1178
1179#[derive(Debug, Deserialize)]
1180struct RawInterpreterRuntimeGrant {
1181 #[serde(default)]
1182 binary: Option<String>,
1183 #[serde(default)]
1184 dirs: Vec<RawInterpreterRuntimeDir>,
1185}
1186
1187#[derive(Debug, Deserialize)]
1188struct RawInterpreterRuntimeDir {
1189 #[serde(default)]
1190 path: Option<String>,
1191 #[serde(default)]
1195 list_dir: Option<bool>,
1196}
1197
1198#[derive(Debug, Deserialize)]
1199struct RawStagedRuntimeGrant {
1200 #[serde(default)]
1201 binary: Option<String>,
1202 #[serde(default)]
1203 source_path: Option<String>,
1204 #[serde(default)]
1208 pin: Option<String>,
1209}
1210
1211#[derive(Debug, Deserialize)]
1212struct RawInferenceConfig {
1213 transport: Option<String>,
1214 endpoint: Option<String>,
1215 model: Option<String>,
1216 #[serde(default)]
1217 api_key: Option<String>,
1218 #[serde(default)]
1219 command: Option<String>,
1220 #[serde(default)]
1221 system_prompt: Option<String>,
1222 #[serde(default)]
1223 system_prompt_file: Option<String>,
1224 #[serde(default)]
1225 system_prompt_artifact: Option<String>,
1226 #[serde(default)]
1227 driver: Option<RawInferenceDriver>,
1228 #[serde(default)]
1229 provider: Option<RawInferenceDriver>,
1230 #[serde(default)]
1231 compaction: Option<RawCompactionConfig>,
1232 #[serde(default)]
1233 max_turns: Option<u32>,
1234 #[serde(default)]
1235 max_task_reopens: Option<u32>,
1236 #[serde(default)]
1237 max_tokens: Option<u32>,
1238}
1239
1240#[derive(Debug, Deserialize)]
1241struct RawCompactionConfig {
1242 threshold: Option<f32>,
1243 model: Option<String>,
1244 system_prompt: Option<String>,
1245 system_prompt_file: Option<String>,
1246 dump_summaries: Option<bool>,
1247}
1248
1249#[derive(Debug, Deserialize)]
1250struct RawInferenceDriver {
1251 artifact: Option<String>,
1252 #[serde(default)]
1253 config: Option<serde_yaml::Value>,
1254}
1255
1256#[must_use = "validated runtime manifest is required before run"]
1257pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
1258 let content = fs::read_to_string(path).map_err(|source| {
1259 if source.kind() == std::io::ErrorKind::NotFound {
1260 return RuntimeManifestError::NotFound(path.display().to_string());
1261 }
1262
1263 RuntimeManifestError::Io {
1264 path: path.display().to_string(),
1265 source,
1266 }
1267 })?;
1268
1269 RuntimeManifest::from_yaml_str(&content)
1270}
1271
1272impl RuntimeManifest {
1273 pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
1274 let raw: RawRuntimeManifest = serde_yaml::from_str(input).map_err(|err| {
1275 if let Some(location) = err.location() {
1276 RuntimeManifestError::YamlSyntax(format!(
1277 "{}: YAML syntax error at line {}, column {}: {}",
1278 MANIFEST_FILENAME,
1279 location.line(),
1280 location.column(),
1281 err
1282 ))
1283 } else {
1284 RuntimeManifestError::YamlSyntax(format!(
1285 "{MANIFEST_FILENAME}: YAML syntax error: {err}"
1286 ))
1287 }
1288 })?;
1289
1290 let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
1291 RuntimeManifestError::MissingField {
1292 field: "name".to_string(),
1293 }
1294 })?;
1295
1296 let version = raw
1297 .version
1298 .filter(|s| !s.trim().is_empty())
1299 .ok_or_else(|| RuntimeManifestError::MissingField {
1300 field: "version".to_string(),
1301 })?;
1302
1303 let artifacts = raw
1304 .artifacts
1305 .into_iter()
1306 .enumerate()
1307 .map(|(index, artifact)| {
1308 let name = artifact
1309 .name
1310 .filter(|s| !s.trim().is_empty())
1311 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
1312 index,
1313 message: "missing required field 'name'".to_string(),
1314 })?;
1315
1316 let source = artifact.source.filter(|s| !s.trim().is_empty());
1317
1318 let runtime = match artifact.runtime.as_deref() {
1319 None | Some("tool") => ArtifactRuntime::Tool,
1320 Some("wasm") | Some("native") => {
1321 return Err(RuntimeManifestError::InvalidArtifact {
1322 index,
1323 message: format!(
1324 "use 'runtime: tool'; implementation is declared in the \
1325 artifact's own manifest (got '{}')",
1326 artifact.runtime.as_deref().unwrap_or("")
1327 ),
1328 });
1329 }
1330 Some("driver") => ArtifactRuntime::Driver,
1331 Some("hook") => ArtifactRuntime::Hook,
1332 Some("skill") => ArtifactRuntime::Skill,
1333 Some(other) => {
1334 return Err(RuntimeManifestError::InvalidArtifact {
1335 index,
1336 message: format!(
1337 "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
1338 ),
1339 });
1340 }
1341 };
1342
1343 let local_source = artifact
1347 .local_source
1348 .unwrap_or(runtime == ArtifactRuntime::Skill);
1349 let prompt_payload = artifact
1350 .prompt_payload
1351 .unwrap_or(runtime == ArtifactRuntime::Skill);
1352
1353 if source.is_some() && !local_source {
1355 return Err(RuntimeManifestError::InvalidArtifact {
1356 index,
1357 message: format!(
1358 "artifact '{name}' declares 'source:' but does not declare \
1359 'local_source: true' (runtime: {})",
1360 runtime.as_str()
1361 ),
1362 });
1363 }
1364
1365 let version = if source.is_some() {
1368 if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
1369 eprintln!(
1370 "warning: artifact '{name}' declares both 'source:' and \
1371 'version: {explicit}'; version is ignored for local-source skills \
1372 (using 'local')"
1373 );
1374 }
1375 "local".to_string()
1376 } else {
1377 artifact
1378 .version
1379 .filter(|s| !s.trim().is_empty())
1380 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
1381 index,
1382 message: "missing required field 'version'".to_string(),
1383 })?
1384 };
1385
1386 let capabilities = match artifact.capabilities {
1392 None => None,
1393 Some(raw_caps) => {
1394 if runtime == ArtifactRuntime::Skill {
1395 return Err(RuntimeManifestError::InvalidArtifact {
1396 index,
1397 message: format!(
1398 "artifact '{name}' declares per-artifact 'capabilities:' but \
1399 has 'runtime: {}'; per-artifact capabilities are only \
1400 recognized on 'runtime: hook', 'runtime: tool', and \
1401 'runtime: driver' entries — use the capsule-wide top-level \
1402 'capabilities:' block instead",
1403 runtime.as_str()
1404 ),
1405 });
1406 }
1407 parse_capabilities(Some(raw_caps))?
1408 }
1409 };
1410
1411 let on_overflow = match artifact.on_overflow.as_deref() {
1417 None => HookOverflowPolicy::default(),
1418 Some(raw) => {
1419 if runtime != ArtifactRuntime::Hook {
1420 return Err(RuntimeManifestError::InvalidArtifact {
1421 index,
1422 message: format!(
1423 "artifact '{name}' declares 'on_overflow:' but has \
1424 'runtime: {}'; the key governs an async hook's job queue \
1425 and is only recognized on 'runtime: hook' entries",
1426 runtime.as_str()
1427 ),
1428 });
1429 }
1430 match raw {
1431 "drop" => HookOverflowPolicy::Drop,
1432 "block" => HookOverflowPolicy::Block,
1433 other => {
1434 return Err(RuntimeManifestError::InvalidArtifact {
1435 index,
1436 message: format!(
1437 "artifact '{name}' declares unknown on_overflow \
1438 '{other}'; expected: drop, block"
1439 ),
1440 });
1441 }
1442 }
1443 }
1444 };
1445
1446 Ok(RuntimeArtifact {
1447 name,
1448 version,
1449 runtime,
1450 source,
1451 local_source,
1452 prompt_payload,
1453 capabilities,
1454 on_overflow,
1455 })
1456 })
1457 .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
1458
1459 let capabilities = parse_capabilities(raw.capabilities)?;
1460 let inference = parse_inference(raw.inference)?;
1461
1462 if let Some(ref sp_art) = inference
1465 .as_ref()
1466 .and_then(|i| i.system_prompt_artifact.clone())
1467 {
1468 let matching = artifacts.iter().find(|a| &a.name == sp_art);
1469 match matching {
1470 None => {
1471 return Err(RuntimeManifestError::InvalidInferenceConfig {
1472 field: "inference.system_prompt_artifact".to_string(),
1473 message: format!("artifact '{sp_art}' is not declared in artifacts:"),
1474 });
1475 }
1476 Some(a) if !a.prompt_payload => {
1477 return Err(RuntimeManifestError::InvalidInferenceConfig {
1478 field: "inference.system_prompt_artifact".to_string(),
1479 message: format!(
1480 "artifact '{sp_art}' (runtime: {}) does not declare \
1481 'prompt_payload: true', which system_prompt_artifact requires",
1482 a.runtime.as_str()
1483 ),
1484 });
1485 }
1486 Some(_) => {} }
1488 }
1489 let context = parse_context(raw.context)?;
1490 let observability = parse_observability(raw.observability);
1491 let trace = raw.trace.map(|raw_trace| TraceConfig {
1492 include_tool_output: raw_trace.include_tool_output.unwrap_or(false),
1493 });
1494 let network = raw.network.map(|n| NetworkConfig {
1495 internal_port: n.internal_port,
1496 });
1497 let lifecycle = raw.lifecycle.map(|raw_lc| {
1498 let defaults = LifecycleConfig::default();
1499 LifecycleConfig {
1500 task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
1501 after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
1502 queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
1503 input_timeout_secs: raw_lc.input_timeout_secs,
1504 conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
1505 }
1506 });
1507
1508 Ok(Self {
1509 name,
1510 version,
1511 artifacts,
1512 capabilities,
1513 inference,
1514 context,
1515 observability,
1516 trace,
1517 network,
1518 lifecycle,
1519 mur_version: raw.mur_version.filter(|s| !s.trim().is_empty()),
1520 })
1521 }
1522}
1523
1524fn parse_capabilities(
1525 raw: Option<RawCapabilities>,
1526) -> Result<Option<Capabilities>, RuntimeManifestError> {
1527 let Some(raw_caps) = raw else {
1528 return Ok(None);
1529 };
1530
1531 let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
1532 allow: raw_network.allow,
1533 unix_sockets: raw_network.unix_sockets,
1534 });
1535
1536 let filesystem = raw_caps
1537 .filesystem
1538 .map(|raw_filesystem| FilesystemCapabilities {
1539 scope: raw_filesystem.scope.and_then(|scope| {
1540 let trimmed = scope.trim();
1541 (!trimmed.is_empty()).then(|| trimmed.to_string())
1542 }),
1543 workdir_exec: raw_filesystem.workdir_exec,
1548 });
1549
1550 let shell = raw_caps
1551 .shell
1552 .map(|raw_shell| {
1553 if raw_shell.allow.is_empty() {
1554 return Err(RuntimeManifestError::InvalidCapabilities {
1555 field: "capabilities.shell.allow".to_string(),
1556 message: "must contain at least one binary".to_string(),
1557 });
1558 }
1559
1560 let interpreter_runtime =
1561 parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
1562 let staged_runtime = parse_staged_runtime(
1565 &raw_shell.allow,
1566 &interpreter_runtime,
1567 raw_shell.staged_runtime,
1568 )?;
1569
1570 Ok(ShellCapabilities {
1571 allow: raw_shell.allow,
1572 strip_env: raw_shell.strip_env,
1573 baseline_env: raw_shell.baseline_env,
1574 interpreter_runtime,
1575 staged_runtime,
1576 })
1577 })
1578 .transpose()?;
1579
1580 let spawn = raw_caps.spawn.map(|raw_spawn| SpawnCapabilities {
1581 allow: raw_spawn.allow,
1582 });
1583
1584 let env = raw_caps.env.map(|raw_env| EnvCapabilities {
1585 allow: raw_env.allow,
1586 });
1587
1588 let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
1589
1590 let resources = raw_caps
1591 .resources
1592 .map(parse_resource_capabilities)
1593 .transpose()?;
1594
1595 let containment = raw_caps
1596 .containment
1597 .as_deref()
1598 .map(str::trim)
1599 .filter(|value| !value.is_empty())
1600 .map(|value| {
1601 value.parse::<ContainmentClass>().map_err(|error| {
1602 RuntimeManifestError::InvalidCapabilities {
1603 field: "capabilities.containment".to_string(),
1604 message: error.to_string(),
1605 }
1606 })
1607 })
1608 .transpose()?;
1609
1610 Ok(Some(Capabilities {
1611 network,
1612 filesystem,
1613 shell,
1614 spawn,
1615 env,
1616 limits,
1617 resources,
1618 containment,
1619 }))
1620}
1621
1622fn parse_interpreter_runtime(
1628 allow: &[String],
1629 raw_grants: Vec<RawInterpreterRuntimeGrant>,
1630) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
1631 let mut grants = Vec::with_capacity(raw_grants.len());
1632 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
1633 let base = format!("capabilities.shell.interpreter_runtime[{index}]");
1634
1635 let binary = raw_grant
1636 .binary
1637 .filter(|b| !b.trim().is_empty())
1638 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
1639 field: format!("{base}.binary"),
1640 message: "must name a binary".to_string(),
1641 })?;
1642
1643 if !allow.contains(&binary) {
1647 return Err(RuntimeManifestError::InvalidCapabilities {
1648 field: format!("{base}.binary"),
1649 message: format!(
1650 "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
1651 only narrow filesystem access for an already-allowlisted binary, never \
1652 grant exec"
1653 ),
1654 });
1655 }
1656
1657 if raw_grant.dirs.is_empty() {
1658 return Err(RuntimeManifestError::InvalidCapabilities {
1659 field: format!("{base}.dirs"),
1660 message: "must name at least one directory".to_string(),
1661 });
1662 }
1663
1664 let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
1665 for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
1666 let dir_base = format!("{base}.dirs[{dir_index}]");
1667
1668 let path = raw_dir
1669 .path
1670 .filter(|p| !p.trim().is_empty())
1671 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
1672 field: format!("{dir_base}.path"),
1673 message: "must name a host directory".to_string(),
1674 })?;
1675 if !path.starts_with('/') {
1676 return Err(RuntimeManifestError::InvalidCapabilities {
1677 field: format!("{dir_base}.path"),
1678 message: format!(
1679 "'{path}' must be an absolute host path (start with '/') — these are \
1680 filesystem paths outside the workdir"
1681 ),
1682 });
1683 }
1684
1685 let list_dir =
1686 raw_dir
1687 .list_dir
1688 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
1689 field: format!("{dir_base}.list_dir"),
1690 message: format!(
1691 "'{path}' must set list_dir explicitly to true or false — \
1692 enumerability is never inferred"
1693 ),
1694 })?;
1695
1696 dirs.push(InterpreterRuntimeDir { path, list_dir });
1697 }
1698
1699 grants.push(InterpreterRuntimeGrant { binary, dirs });
1700 }
1701
1702 Ok(grants)
1703}
1704
1705fn parse_staged_runtime(
1726 allow: &[String],
1727 interpreter_runtime: &[InterpreterRuntimeGrant],
1728 raw_grants: Vec<RawStagedRuntimeGrant>,
1729) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
1730 let mut grants = Vec::with_capacity(raw_grants.len());
1731 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
1732 let base = format!("capabilities.shell.staged_runtime[{index}]");
1733
1734 let binary = raw_grant
1735 .binary
1736 .filter(|b| !b.trim().is_empty())
1737 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
1738 field: format!("{base}.binary"),
1739 message: "must name a binary".to_string(),
1740 })?;
1741
1742 if !allow.contains(&binary) {
1743 return Err(RuntimeManifestError::InvalidCapabilities {
1744 field: format!("{base}.binary"),
1745 message: format!(
1746 "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
1747 stage the runtime tree behind an already-allowlisted binary, never grant exec"
1748 ),
1749 });
1750 }
1751
1752 if interpreter_runtime
1753 .iter()
1754 .any(|grant| grant.binary == binary)
1755 {
1756 return Err(RuntimeManifestError::InvalidCapabilities {
1757 field: format!("{base}.binary"),
1758 message: format!(
1759 "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
1760 are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
1761 tree into the capsule's own root, which is what makes widening the capsule's \
1762 host filesystem scope unnecessary rather than something to add to it. Remove \
1763 the interpreter_runtime grant for '{binary}'"
1764 ),
1765 });
1766 }
1767
1768 let source_path = raw_grant
1769 .source_path
1770 .filter(|p| !p.trim().is_empty())
1771 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
1772 field: format!("{base}.source_path"),
1773 message: "must name the host directory holding the pinned runtime tree".to_string(),
1774 })?;
1775 if !source_path.starts_with('/') {
1776 return Err(RuntimeManifestError::InvalidCapabilities {
1777 field: format!("{base}.source_path"),
1778 message: format!(
1779 "'{source_path}' must be an absolute host path (start with '/') — \
1780 staged-runtime sources are host filesystem paths outside the workdir"
1781 ),
1782 });
1783 }
1784
1785 let pin = raw_grant
1786 .pin
1787 .map(|p| p.trim().to_string())
1788 .filter(|p| !p.is_empty())
1789 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
1790 field: format!("{base}.pin"),
1791 message: format!(
1792 "'{source_path}' must declare a non-empty pin identifying which build this \
1793 tree is — it is never inferred, because it is the value a human compares \
1794 across two hosts to confirm the same runtime shipped to both"
1795 ),
1796 })?;
1797
1798 grants.push(StagedRuntimeGrant {
1799 binary,
1800 source_path,
1801 pin,
1802 });
1803 }
1804
1805 Ok(grants)
1806}
1807
1808fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
1813 let reject_zero_usize =
1814 |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
1815 match value {
1816 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
1817 field: format!("capabilities.limits.{field}"),
1818 message: "must be greater than zero".to_string(),
1819 }),
1820 _ => Ok(()),
1821 }
1822 };
1823
1824 reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
1825 reject_zero_usize(raw.table_elements, "table_elements")?;
1826 reject_zero_usize(raw.instances, "instances")?;
1827 if raw.deadline_seconds == Some(0) {
1828 return Err(RuntimeManifestError::InvalidCapabilities {
1829 field: "capabilities.limits.deadline_seconds".to_string(),
1830 message: "must be greater than zero".to_string(),
1831 });
1832 }
1833
1834 Ok(ResourceLimits {
1835 memory_bytes: raw.memory_bytes,
1836 table_elements: raw.table_elements,
1837 instances: raw.instances,
1838 deadline_seconds: raw.deadline_seconds,
1839 })
1840}
1841
1842fn parse_resource_capabilities(
1848 raw: RawResourceCapabilities,
1849) -> Result<ResourceCapabilities, RuntimeManifestError> {
1850 let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
1851 match value {
1852 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
1853 field: format!("capabilities.resources.{field}"),
1854 message: "must be greater than zero".to_string(),
1855 }),
1856 _ => Ok(()),
1857 }
1858 };
1859
1860 reject_zero(raw.max_processes, "max_processes")?;
1861 reject_zero(raw.max_open_files, "max_open_files")?;
1862 reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
1863 reject_zero(raw.cpu_seconds, "cpu_seconds")?;
1864 reject_zero(raw.memory_bytes, "memory_bytes")?;
1865 reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
1866 reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
1867 reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
1868 reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
1869 reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
1870
1871 Ok(ResourceCapabilities {
1872 max_processes: raw.max_processes,
1873 max_open_files: raw.max_open_files,
1874 max_file_size_bytes: raw.max_file_size_bytes,
1875 cpu_seconds: raw.cpu_seconds,
1876 memory_bytes: raw.memory_bytes,
1877 cgroup_memory_bytes: raw.cgroup_memory_bytes,
1878 cgroup_pids_max: raw.cgroup_pids_max,
1879 cgroup_cpu_percent: raw.cgroup_cpu_percent,
1880 cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
1881 workdir_max_bytes: raw.workdir_max_bytes,
1882 })
1883}
1884
1885fn parse_inference(
1886 raw: Option<RawInferenceConfig>,
1887) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
1888 let Some(raw) = raw else {
1889 return Ok(None);
1890 };
1891
1892 let transport = raw
1893 .transport
1894 .map(|s| s.trim().to_string())
1895 .filter(|s| !s.is_empty())
1896 .unwrap_or_else(|| "http".to_string());
1897
1898 let compaction = parse_compaction(raw.compaction)?;
1899 let system_prompt = optional_trimmed_string(raw.system_prompt);
1900 let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
1901 let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
1902
1903 let prompt_sources_set = [
1904 system_prompt.is_some(),
1905 system_prompt_file.is_some(),
1906 system_prompt_artifact.is_some(),
1907 ]
1908 .iter()
1909 .filter(|&&b| b)
1910 .count();
1911 if prompt_sources_set > 1 {
1912 return Err(RuntimeManifestError::InvalidInferenceConfig {
1913 field: "inference.system_prompt".to_string(),
1914 message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
1915 may be set"
1916 .to_string(),
1917 });
1918 }
1919
1920 let max_turns = match raw.max_turns {
1921 None => 10,
1922 Some(0) => {
1923 return Err(RuntimeManifestError::InvalidInferenceConfig {
1924 field: "inference.max_turns".to_string(),
1925 message: "must be greater than 0".to_string(),
1926 });
1927 }
1928 Some(n) => n,
1929 };
1930
1931 let max_task_reopens = raw.max_task_reopens.unwrap_or(1);
1934
1935 match transport.as_str() {
1936 "http" => {
1937 let endpoint = required_inference_field(raw.endpoint, "endpoint")?;
1938 validate_inference_endpoint(&endpoint)?;
1939 let model = required_inference_field(raw.model, "model")?;
1940
1941 let api_key = match raw.api_key {
1942 None => None,
1943 Some(value) => {
1944 let trimmed = value.trim();
1945 if trimmed.is_empty() {
1946 None
1947 } else {
1948 Some(resolve_inference_api_key(trimmed)?)
1949 }
1950 }
1951 };
1952
1953 let driver_raw = raw.driver.or(raw.provider).ok_or_else(|| {
1954 RuntimeManifestError::InvalidInferenceConfig {
1955 field: "inference.driver".to_string(),
1956 message: "missing required field".to_string(),
1957 }
1958 })?;
1959 let driver = parse_inference_driver(driver_raw)?;
1960
1961 if raw.max_tokens == Some(0) {
1965 return Err(RuntimeManifestError::InvalidInferenceConfig {
1966 field: "inference.max_tokens".to_string(),
1967 message: "must be greater than 0".to_string(),
1968 });
1969 }
1970
1971 Ok(Some(InferenceConfig {
1972 transport,
1973 endpoint: Some(endpoint),
1974 model,
1975 api_key,
1976 driver: Some(driver),
1977 command: None,
1978 compaction,
1979 system_prompt,
1980 system_prompt_file,
1981 system_prompt_artifact,
1982 max_turns,
1983 max_task_reopens,
1984 max_tokens: raw.max_tokens,
1985 }))
1986 }
1987 "process" => {
1988 if raw.driver.is_some() || raw.provider.is_some() {
1990 return Err(RuntimeManifestError::InvalidInferenceConfig {
1991 field: "inference.driver.artifact".to_string(),
1992 message: "is not valid with transport: process".to_string(),
1993 });
1994 }
1995 if raw
1996 .endpoint
1997 .as_ref()
1998 .map(|s| !s.trim().is_empty())
1999 .unwrap_or(false)
2000 {
2001 return Err(RuntimeManifestError::InvalidInferenceConfig {
2002 field: "inference.endpoint".to_string(),
2003 message: "is not valid with transport: process".to_string(),
2004 });
2005 }
2006 if raw
2007 .api_key
2008 .as_ref()
2009 .map(|s| !s.trim().is_empty())
2010 .unwrap_or(false)
2011 {
2012 return Err(RuntimeManifestError::InvalidInferenceConfig {
2013 field: "inference.api_key".to_string(),
2014 message: "is not valid with transport: process".to_string(),
2015 });
2016 }
2017 if raw.max_tokens.is_some() {
2020 return Err(RuntimeManifestError::InvalidInferenceConfig {
2021 field: "inference.max_tokens".to_string(),
2022 message: "is not valid with transport: process".to_string(),
2023 });
2024 }
2025
2026 let command = required_inference_field(raw.command, "command")?;
2027 let model = optional_trimmed_string(raw.model).unwrap_or_default();
2032
2033 Ok(Some(InferenceConfig {
2034 transport,
2035 endpoint: None,
2036 model,
2037 api_key: None,
2038 driver: None,
2039 command: Some(command),
2040 compaction,
2041 system_prompt,
2042 system_prompt_file,
2043 system_prompt_artifact,
2044 max_turns,
2045 max_task_reopens,
2046 max_tokens: None,
2047 }))
2048 }
2049 other => Err(RuntimeManifestError::InvalidInferenceConfig {
2050 field: "inference.transport".to_string(),
2051 message: format!("unknown value '{other}'"),
2052 }),
2053 }
2054}
2055
2056fn parse_context(
2057 raw: Option<RawContextConfig>,
2058) -> Result<Option<ContextConfig>, RuntimeManifestError> {
2059 let Some(raw) = raw else {
2060 return Ok(None);
2061 };
2062
2063 if let Some(max_tokens) = raw.max_tokens {
2064 if max_tokens == 0 {
2065 return Err(RuntimeManifestError::InvalidInferenceConfig {
2066 field: "context.max_tokens".to_string(),
2067 message: "must be greater than 0".to_string(),
2068 });
2069 }
2070 }
2071
2072 Ok(Some(ContextConfig {
2073 max_tokens: raw.max_tokens,
2074 }))
2075}
2076
2077fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
2078 raw.map(|raw| ObservabilityConfig {
2079 otel_endpoint: raw
2080 .otel_endpoint
2081 .map(|value| value.trim().to_string())
2082 .filter(|value| !value.is_empty()),
2083 eval: raw.eval.map(parse_eval_config),
2084 })
2085}
2086
2087fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
2088 let scorers = raw
2089 .scorers
2090 .into_iter()
2091 .filter_map(|raw| {
2092 let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
2093 match raw.scorer_type.as_str() {
2094 "exit_ok" => Some(ScorerConfig::ExitOk { name }),
2095 "max_turns" => Some(ScorerConfig::MaxTurns {
2096 name,
2097 max: raw.max.unwrap_or(10) as u32,
2098 }),
2099 "max_tokens" => Some(ScorerConfig::MaxTokens {
2100 name,
2101 max: raw.max.unwrap_or(100_000),
2102 }),
2103 "tool_sequence" => Some(ScorerConfig::ToolSequence {
2104 name,
2105 expected: raw.expected.unwrap_or_default(),
2106 }),
2107 "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
2108 other => {
2109 eprintln!("[murmur-artifact] unknown scorer type '{other}' — skipping");
2110 None
2111 }
2112 }
2113 })
2114 .collect();
2115
2116 EvalConfig {
2117 dataset_id: raw.dataset_id,
2118 scorers,
2119 }
2120}
2121
2122fn parse_compaction(
2123 raw: Option<RawCompactionConfig>,
2124) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
2125 let Some(raw) = raw else {
2126 return Ok(None);
2127 };
2128
2129 if let Some(threshold) = raw.threshold {
2130 if threshold <= 0.0 || threshold > 1.0 {
2131 return Err(RuntimeManifestError::InvalidInferenceConfig {
2132 field: "inference.compaction.threshold".to_string(),
2133 message: format!("must be in (0.0, 1.0] but got {threshold}"),
2134 });
2135 }
2136 }
2137
2138 if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
2139 return Err(RuntimeManifestError::InvalidInferenceConfig {
2140 field: "inference.compaction.system_prompt".to_string(),
2141 message: "at most one of inference.compaction.system_prompt, \
2142 inference.compaction.system_prompt_file may be set"
2143 .to_string(),
2144 });
2145 }
2146
2147 Ok(Some(CompactionConfig {
2148 threshold: raw.threshold,
2149 model: raw.model,
2150 system_prompt: raw.system_prompt,
2151 system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
2152 dump_summaries: raw.dump_summaries,
2155 }))
2156}
2157
2158fn optional_trimmed_string(value: Option<String>) -> Option<String> {
2159 value
2160 .map(|s| s.trim().to_string())
2161 .filter(|s| !s.is_empty())
2162}
2163
2164fn required_inference_field(
2165 value: Option<String>,
2166 key: &str,
2167) -> Result<String, RuntimeManifestError> {
2168 value
2169 .map(|value| value.trim().to_string())
2170 .filter(|value| !value.is_empty())
2171 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
2172 field: format!("inference.{key}"),
2173 message: "missing required field".to_string(),
2174 })
2175}
2176
2177fn validate_inference_endpoint(endpoint: &str) -> Result<(), RuntimeManifestError> {
2185 let url =
2186 Url::parse(endpoint).map_err(|source| RuntimeManifestError::InvalidInferenceConfig {
2187 field: "inference.endpoint".to_string(),
2188 message: format!("failed to parse '{endpoint}' as a URL: {source}"),
2189 })?;
2190
2191 match url.scheme() {
2192 "https" => Ok(()),
2193 "http" => {
2194 let host = url.host_str().unwrap_or("");
2195 let is_loopback = host == "localhost"
2196 || host
2197 .parse::<IpAddr>()
2198 .map(|ip| ip.is_loopback())
2199 .unwrap_or(false);
2200
2201 if is_loopback {
2202 Ok(())
2203 } else {
2204 Err(RuntimeManifestError::InvalidInferenceConfig {
2205 field: "inference.endpoint".to_string(),
2206 message: format!(
2207 "'{endpoint}' uses plain http:// with non-loopback host '{host}' — \
2208 use https:// for remote endpoints, or http://localhost or \
2209 a loopback IP literal for local inference"
2210 ),
2211 })
2212 }
2213 }
2214 other => Err(RuntimeManifestError::InvalidInferenceConfig {
2215 field: "inference.endpoint".to_string(),
2216 message: format!(
2217 "unsupported scheme '{other}' in '{endpoint}' — expected http or https"
2218 ),
2219 }),
2220 }
2221}
2222
2223fn parse_inference_driver(
2224 raw: RawInferenceDriver,
2225) -> Result<InferenceDriver, RuntimeManifestError> {
2226 let artifact = raw
2227 .artifact
2228 .map(|value| value.trim().to_string())
2229 .filter(|value| !value.is_empty())
2230 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
2231 field: "inference.driver.artifact".to_string(),
2232 message: "missing required field".to_string(),
2233 })?;
2234
2235 let config = parse_inference_driver_config(raw.config)?;
2236
2237 Ok(InferenceDriver { artifact, config })
2238}
2239
2240fn parse_inference_driver_config(
2241 raw: Option<serde_yaml::Value>,
2242) -> Result<Option<String>, RuntimeManifestError> {
2243 let Some(raw) = raw else {
2244 return Ok(None);
2245 };
2246
2247 if matches!(raw, serde_yaml::Value::Null) {
2248 return Ok(None);
2249 }
2250
2251 if !matches!(raw, serde_yaml::Value::Mapping(_)) {
2252 return Err(RuntimeManifestError::InvalidInferenceConfig {
2253 field: "inference.driver.config".to_string(),
2254 message: "expected mapping/object".to_string(),
2255 });
2256 }
2257
2258 let value =
2259 serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
2260 field: "inference.driver.config".to_string(),
2261 message: format!("failed to convert to JSON: {err}"),
2262 })?;
2263
2264 serde_json::to_string(&value).map(Some).map_err(|err| {
2265 RuntimeManifestError::InvalidInferenceConfig {
2266 field: "inference.driver.config".to_string(),
2267 message: format!("failed to encode JSON: {err}"),
2268 }
2269 })
2270}
2271
2272fn resolve_inference_api_key(value: &str) -> Result<String, RuntimeManifestError> {
2273 if let Some(variable) = parse_env_reference(value) {
2274 return std::env::var(variable).map_err(|_| RuntimeManifestError::MissingInferenceEnvVar {
2275 field: "inference.api_key".to_string(),
2276 reference: value.to_string(),
2277 variable: variable.to_string(),
2278 });
2279 }
2280
2281 Ok(value.to_string())
2282}
2283
2284fn parse_env_reference(value: &str) -> Option<&str> {
2285 if !value.starts_with("${") || !value.ends_with('}') {
2286 return None;
2287 }
2288
2289 let variable = &value[2..value.len() - 1];
2290 is_valid_env_variable(variable).then_some(variable)
2291}
2292
2293fn is_valid_env_variable(value: &str) -> bool {
2294 let mut chars = value.chars();
2295 let Some(first) = chars.next() else {
2296 return false;
2297 };
2298
2299 if !(first == '_' || first.is_ascii_uppercase()) {
2300 return false;
2301 }
2302
2303 chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
2304}
2305
2306#[cfg(test)]
2307mod tests {
2308 use super::*;
2309
2310 #[test]
2311 fn resources_block_is_optional_and_absent_stays_absent() {
2312 let manifest = RuntimeManifest::from_yaml_str(
2313 r#"
2314name: cap
2315version: 0.0.1
2316capabilities:
2317 shell:
2318 allow: [bash]
2319"#,
2320 )
2321 .unwrap();
2322
2323 let caps = manifest.capabilities.expect("shell block must parse");
2327 assert_eq!(caps.resources, None);
2328 }
2329
2330 #[test]
2331 fn resources_fields_parse_and_each_stays_independently_optional() {
2332 let manifest = RuntimeManifest::from_yaml_str(
2333 r#"
2334name: cap
2335version: 0.0.1
2336capabilities:
2337 resources:
2338 max_processes: 32
2339 max_open_files: 16
2340 max_file_size_bytes: 1048576
2341 cpu_seconds: 30
2342 memory_bytes: 268435456
2343 cgroup_memory_bytes: 536870912
2344 cgroup_pids_max: 64
2345 cgroup_cpu_percent: 50
2346 cgroup_io_bytes_per_sec: 1048576
2347 workdir_max_bytes: 2097152
2348"#,
2349 )
2350 .unwrap();
2351
2352 let resources = manifest
2353 .capabilities
2354 .and_then(|caps| caps.resources)
2355 .expect("resources block must parse");
2356 assert_eq!(resources.max_processes, Some(32));
2357 assert_eq!(resources.max_open_files, Some(16));
2358 assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
2359 assert_eq!(resources.cpu_seconds, Some(30));
2360 assert_eq!(resources.memory_bytes, Some(268_435_456));
2361 assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
2362 assert_eq!(resources.cgroup_pids_max, Some(64));
2363 assert_eq!(resources.cgroup_cpu_percent, Some(50));
2364 assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
2365 assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
2366 }
2367
2368 #[test]
2369 fn resources_partial_block_leaves_undeclared_fields_none() {
2370 let manifest = RuntimeManifest::from_yaml_str(
2371 r#"
2372name: cap
2373version: 0.0.1
2374capabilities:
2375 resources:
2376 max_open_files: 16
2377"#,
2378 )
2379 .unwrap();
2380
2381 let resources = manifest
2382 .capabilities
2383 .and_then(|caps| caps.resources)
2384 .expect("resources block must parse");
2385 assert_eq!(resources.max_open_files, Some(16));
2386 assert_eq!(resources.max_processes, None);
2387 assert_eq!(resources.workdir_max_bytes, None);
2388 }
2389
2390 #[test]
2394 fn resources_zero_is_rejected_on_every_field() {
2395 for field in [
2396 "max_processes",
2397 "max_open_files",
2398 "max_file_size_bytes",
2399 "cpu_seconds",
2400 "memory_bytes",
2401 "cgroup_memory_bytes",
2402 "cgroup_pids_max",
2403 "cgroup_cpu_percent",
2404 "cgroup_io_bytes_per_sec",
2405 "workdir_max_bytes",
2406 ] {
2407 let yaml =
2408 format!("name: cap\nversion: 0.0.1\ncapabilities:\n resources:\n {field}: 0\n");
2409 let error =
2410 RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
2411
2412 match error {
2413 RuntimeManifestError::InvalidCapabilities {
2414 field: reported,
2415 message,
2416 } => {
2417 assert_eq!(reported, format!("capabilities.resources.{field}"));
2418 assert_eq!(message, "must be greater than zero");
2419 }
2420 other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
2421 }
2422 }
2423 }
2424
2425 #[test]
2426 fn artifact_runtime_defaults_to_tool() {
2427 let manifest = RuntimeManifest::from_yaml_str(
2428 r#"
2429name: cap
2430version: 0.0.1
2431artifacts:
2432 - name: echo
2433 version: 1.2.3
2434"#,
2435 )
2436 .unwrap();
2437
2438 assert_eq!(manifest.artifacts.len(), 1);
2439 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
2440 }
2441
2442 #[test]
2443 fn artifact_runtime_tool_explicit() {
2444 let manifest = RuntimeManifest::from_yaml_str(
2445 r#"
2446name: cap
2447version: 0.0.1
2448artifacts:
2449 - name: echo
2450 version: 1.2.3
2451 runtime: tool
2452"#,
2453 )
2454 .unwrap();
2455
2456 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
2457 }
2458
2459 #[test]
2460 fn artifact_runtime_wasm_is_error() {
2461 let err = RuntimeManifest::from_yaml_str(
2462 r#"
2463name: cap
2464version: 0.0.1
2465artifacts:
2466 - name: echo
2467 version: 1.2.3
2468 runtime: wasm
2469"#,
2470 )
2471 .unwrap_err();
2472
2473 let msg = err.to_string();
2474 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
2475 assert!(msg.contains("wasm"), "error was: {msg}");
2476 }
2477
2478 #[test]
2479 fn artifact_runtime_native_is_error() {
2480 let err = RuntimeManifest::from_yaml_str(
2481 r#"
2482name: cap
2483version: 0.0.1
2484artifacts:
2485 - name: echo
2486 version: 1.2.3
2487 runtime: native
2488"#,
2489 )
2490 .unwrap_err();
2491
2492 let msg = err.to_string();
2493 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
2494 assert!(msg.contains("native"), "error was: {msg}");
2495 }
2496
2497 #[test]
2498 fn invalid_artifact_reports_index() {
2499 let err = RuntimeManifest::from_yaml_str(
2500 r#"
2501name: cap
2502version: 0.0.1
2503artifacts:
2504 - name: ""
2505 version: 1.2.3
2506"#,
2507 )
2508 .unwrap_err();
2509
2510 let msg = err.to_string();
2511 assert!(msg.contains("index 0"));
2512 assert!(msg.contains("missing required field 'name'"));
2513 }
2514
2515 #[test]
2516 fn capabilities_parse_when_all_fields_present() {
2517 let manifest = RuntimeManifest::from_yaml_str(
2518 r#"
2519name: cap
2520version: 0.0.1
2521artifacts:
2522 - name: echo
2523 version: 1.2.3
2524capabilities:
2525 network:
2526 allow:
2527 - https://api.anthropic.com
2528 - http://127.0.0.1:8080
2529 filesystem:
2530 scope: ./sandbox
2531"#,
2532 )
2533 .unwrap();
2534
2535 let capabilities = manifest.capabilities.expect("capabilities should exist");
2536 assert_eq!(
2537 capabilities.network.unwrap().allow,
2538 vec![
2539 "https://api.anthropic.com".to_string(),
2540 "http://127.0.0.1:8080".to_string()
2541 ]
2542 );
2543 assert_eq!(
2544 capabilities.filesystem.unwrap().scope,
2545 Some("./sandbox".to_string())
2546 );
2547 }
2548
2549 #[test]
2550 fn env_capabilities_parse_allowlist() {
2551 let manifest = RuntimeManifest::from_yaml_str(
2552 r#"
2553name: cap
2554version: 0.0.1
2555artifacts:
2556 - name: echo
2557 version: 1.2.3
2558capabilities:
2559 env:
2560 allow:
2561 - MY_APP_REGION
2562 - GITHUB_TOKEN
2563"#,
2564 )
2565 .unwrap();
2566
2567 let capabilities = manifest.capabilities.expect("capabilities should exist");
2568 assert_eq!(
2569 capabilities.env.unwrap().allow,
2570 vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
2571 );
2572 }
2573
2574 #[test]
2575 fn env_capabilities_absent_when_block_omitted() {
2576 let manifest = RuntimeManifest::from_yaml_str(
2577 r#"
2578name: cap
2579version: 0.0.1
2580artifacts:
2581 - name: echo
2582 version: 1.2.3
2583capabilities:
2584 network:
2585 allow:
2586 - https://api.anthropic.com
2587"#,
2588 )
2589 .unwrap();
2590
2591 assert!(manifest.capabilities.unwrap().env.is_none());
2592 }
2593
2594 #[test]
2597 fn env_capabilities_allow_empty_list_is_accepted() {
2598 let manifest = RuntimeManifest::from_yaml_str(
2599 r#"
2600name: cap
2601version: 0.0.1
2602artifacts:
2603 - name: echo
2604 version: 1.2.3
2605capabilities:
2606 env: {}
2607"#,
2608 )
2609 .unwrap();
2610
2611 assert_eq!(
2612 manifest.capabilities.unwrap().env,
2613 Some(EnvCapabilities { allow: vec![] })
2614 );
2615 }
2616
2617 #[test]
2618 fn missing_capabilities_block_is_backward_compatible() {
2619 let manifest = RuntimeManifest::from_yaml_str(
2620 r#"
2621name: cap
2622version: 0.0.1
2623artifacts:
2624 - name: echo
2625 version: 1.2.3
2626"#,
2627 )
2628 .unwrap();
2629
2630 assert!(manifest.capabilities.is_none());
2631 }
2632
2633 #[test]
2634 fn network_only_capabilities_parse() {
2635 let manifest = RuntimeManifest::from_yaml_str(
2636 r#"
2637name: cap
2638version: 0.0.1
2639artifacts:
2640 - name: echo
2641 version: 1.2.3
2642capabilities:
2643 network:
2644 allow:
2645 - https://allowed.example.com
2646"#,
2647 )
2648 .unwrap();
2649
2650 let capabilities = manifest.capabilities.unwrap();
2651 assert_eq!(
2652 capabilities.network.unwrap().allow,
2653 vec!["https://allowed.example.com".to_string()]
2654 );
2655 assert!(capabilities.filesystem.is_none());
2656 }
2657
2658 #[test]
2659 fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
2660 let manifest = RuntimeManifest::from_yaml_str(
2661 r#"
2662name: cap
2663version: 0.0.1
2664artifacts:
2665 - name: echo
2666 version: 1.2.3
2667capabilities:
2668 network: {}
2669"#,
2670 )
2671 .unwrap();
2672
2673 let capabilities = manifest.capabilities.unwrap();
2674 assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
2675 }
2676
2677 #[test]
2680 fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
2681 let manifest = RuntimeManifest::from_yaml_str(
2682 r#"
2683name: cap
2684version: 0.0.1
2685artifacts:
2686 - name: echo
2687 version: 1.2.3
2688capabilities:
2689 network:
2690 allow:
2691 - https://api.anthropic.com
2692"#,
2693 )
2694 .unwrap();
2695
2696 let network = manifest.capabilities.unwrap().network.unwrap();
2697 assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
2698 assert!(!network.unix_sockets);
2699 }
2700
2701 #[test]
2702 fn network_unix_sockets_parses_explicit_false() {
2703 let manifest = RuntimeManifest::from_yaml_str(
2704 r#"
2705name: cap
2706version: 0.0.1
2707artifacts:
2708 - name: echo
2709 version: 1.2.3
2710capabilities:
2711 network:
2712 unix_sockets: false
2713"#,
2714 )
2715 .unwrap();
2716
2717 assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
2718 }
2719
2720 #[test]
2721 fn network_unix_sockets_parses_explicit_true() {
2722 let manifest = RuntimeManifest::from_yaml_str(
2723 r#"
2724name: cap
2725version: 0.0.1
2726artifacts:
2727 - name: echo
2728 version: 1.2.3
2729capabilities:
2730 network:
2731 unix_sockets: true
2732"#,
2733 )
2734 .unwrap();
2735
2736 let network = manifest.capabilities.unwrap().network.unwrap();
2737 assert!(network.unix_sockets);
2738 assert_eq!(network.allow, Vec::<String>::new());
2740 }
2741
2742 #[test]
2746 fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
2747 let manifest = RuntimeManifest::from_yaml_str(
2748 r#"
2749name: cap
2750version: 0.0.1
2751artifacts:
2752 - name: echo
2753 version: 1.2.3
2754capabilities:
2755 filesystem:
2756 scope: workdir
2757"#,
2758 )
2759 .unwrap();
2760
2761 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
2762 assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
2763 assert!(!filesystem.workdir_exec);
2764 }
2765
2766 #[test]
2771 fn filesystem_block_stays_absent_when_undeclared() {
2772 let manifest = RuntimeManifest::from_yaml_str(
2773 r#"
2774name: cap
2775version: 0.0.1
2776artifacts:
2777 - name: echo
2778 version: 1.2.3
2779capabilities:
2780 network:
2781 allow:
2782 - https://api.anthropic.com
2783"#,
2784 )
2785 .unwrap();
2786
2787 assert!(manifest.capabilities.unwrap().filesystem.is_none());
2788 }
2789
2790 #[test]
2791 fn filesystem_workdir_exec_parses_explicit_false() {
2792 let manifest = RuntimeManifest::from_yaml_str(
2793 r#"
2794name: cap
2795version: 0.0.1
2796artifacts:
2797 - name: echo
2798 version: 1.2.3
2799capabilities:
2800 filesystem:
2801 workdir_exec: false
2802"#,
2803 )
2804 .unwrap();
2805
2806 assert!(
2807 !manifest
2808 .capabilities
2809 .unwrap()
2810 .filesystem
2811 .unwrap()
2812 .workdir_exec
2813 );
2814 }
2815
2816 #[test]
2817 fn filesystem_workdir_exec_parses_explicit_true() {
2818 let manifest = RuntimeManifest::from_yaml_str(
2819 r#"
2820name: cap
2821version: 0.0.1
2822artifacts:
2823 - name: echo
2824 version: 1.2.3
2825capabilities:
2826 filesystem:
2827 workdir_exec: true
2828"#,
2829 )
2830 .unwrap();
2831
2832 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
2833 assert!(filesystem.workdir_exec);
2834 assert_eq!(filesystem.scope, None);
2837 }
2838
2839 #[test]
2840 fn network_internal_port_parses() {
2841 let manifest = RuntimeManifest::from_yaml_str(
2842 r#"
2843name: worker
2844version: 0.1.0
2845artifacts: []
2846
2847network:
2848 internal_port: 58172
2849"#,
2850 )
2851 .unwrap();
2852
2853 assert_eq!(
2854 manifest.network.unwrap().internal_port,
2855 Some(58172),
2856 "network.internal_port should parse to Some(58172)"
2857 );
2858 }
2859
2860 #[test]
2861 fn network_internal_port_absent_when_omitted() {
2862 let manifest = RuntimeManifest::from_yaml_str(
2863 r#"
2864name: worker
2865version: 0.1.0
2866artifacts: []
2867"#,
2868 )
2869 .unwrap();
2870
2871 assert!(manifest.network.is_none());
2872 }
2873
2874 #[test]
2875 fn shell_capabilities_parse_with_allow_and_env_fields() {
2876 let manifest = RuntimeManifest::from_yaml_str(
2877 r#"
2878name: cap
2879version: 0.0.1
2880artifacts: []
2881capabilities:
2882 shell:
2883 allow:
2884 - bash
2885 - git
2886 strip_env:
2887 - "*_TOKEN"
2888 - SECRET_KEY
2889 baseline_env:
2890 - SSH_AUTH_SOCK
2891"#,
2892 )
2893 .unwrap();
2894
2895 let shell = manifest
2896 .capabilities
2897 .unwrap()
2898 .shell
2899 .expect("shell capabilities should be present");
2900 assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
2901 assert_eq!(
2902 shell.strip_env,
2903 Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
2904 );
2905 assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
2906 }
2907
2908 #[test]
2909 fn shell_capabilities_require_non_empty_allowlist() {
2910 let err = RuntimeManifest::from_yaml_str(
2911 r#"
2912name: cap
2913version: 0.0.1
2914artifacts: []
2915capabilities:
2916 shell:
2917 allow: []
2918"#,
2919 )
2920 .unwrap_err();
2921
2922 let msg = err.to_string();
2923 assert!(msg.contains("capabilities.shell.allow"));
2924 assert!(msg.contains("at least one"));
2925 }
2926
2927 #[test]
2928 fn interpreter_runtime_parses_accepted_shape() {
2929 let manifest = RuntimeManifest::from_yaml_str(
2930 r#"
2931name: cap
2932version: 0.0.1
2933artifacts: []
2934capabilities:
2935 shell:
2936 allow:
2937 - python3
2938 interpreter_runtime:
2939 - binary: python3
2940 dirs:
2941 - path: /usr/lib/python3.11
2942 list_dir: true
2943 - path: /usr/lib/python3.11/lib-dynload
2944 list_dir: false
2945"#,
2946 )
2947 .unwrap();
2948
2949 let shell = manifest.capabilities.unwrap().shell.unwrap();
2950 assert_eq!(
2951 shell.interpreter_runtime,
2952 vec![InterpreterRuntimeGrant {
2953 binary: "python3".to_string(),
2954 dirs: vec![
2955 InterpreterRuntimeDir {
2956 path: "/usr/lib/python3.11".to_string(),
2957 list_dir: true,
2958 },
2959 InterpreterRuntimeDir {
2960 path: "/usr/lib/python3.11/lib-dynload".to_string(),
2961 list_dir: false,
2962 },
2963 ],
2964 }]
2965 );
2966 }
2967
2968 #[test]
2969 fn interpreter_runtime_defaults_to_empty_when_absent() {
2970 let manifest = RuntimeManifest::from_yaml_str(
2971 r#"
2972name: cap
2973version: 0.0.1
2974artifacts: []
2975capabilities:
2976 shell:
2977 allow:
2978 - python3
2979"#,
2980 )
2981 .unwrap();
2982
2983 let shell = manifest.capabilities.unwrap().shell.unwrap();
2984 assert!(shell.interpreter_runtime.is_empty());
2985 }
2986
2987 fn interpreter_runtime_reject(grant_yaml: &str) -> String {
2990 let yaml = format!(
2991 r#"
2992name: cap
2993version: 0.0.1
2994artifacts: []
2995capabilities:
2996 shell:
2997 allow:
2998 - python3
2999 interpreter_runtime:
3000{grant_yaml}
3001"#,
3002 );
3003 RuntimeManifest::from_yaml_str(&yaml)
3004 .expect_err("grant should be rejected")
3005 .to_string()
3006 }
3007
3008 #[test]
3009 fn interpreter_runtime_rejects_binary_not_in_allow() {
3010 let msg = interpreter_runtime_reject(
3011 " - binary: ruby\n dirs:\n - path: /usr/lib/ruby\n list_dir: true\n",
3012 );
3013 assert!(
3014 msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
3015 "{msg}"
3016 );
3017 assert!(msg.contains("ruby"), "{msg}");
3018 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
3019 }
3020
3021 #[test]
3022 fn interpreter_runtime_rejects_relative_path() {
3023 let msg = interpreter_runtime_reject(
3024 " - binary: python3\n dirs:\n - path: usr/lib/python3.11\n list_dir: true\n",
3025 );
3026 assert!(
3027 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
3028 "{msg}"
3029 );
3030 assert!(msg.contains("absolute"), "{msg}");
3031 }
3032
3033 #[test]
3034 fn interpreter_runtime_rejects_missing_list_dir() {
3035 let msg = interpreter_runtime_reject(
3036 " - binary: python3\n dirs:\n - path: /usr/lib/python3.11\n",
3037 );
3038 assert!(
3039 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
3040 "{msg}"
3041 );
3042 assert!(msg.contains("explicitly"), "{msg}");
3043 }
3044
3045 #[test]
3046 fn interpreter_runtime_rejects_empty_dirs() {
3047 let msg = interpreter_runtime_reject(" - binary: python3\n dirs: []\n");
3048 assert!(
3049 msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
3050 "{msg}"
3051 );
3052 assert!(msg.contains("at least one"), "{msg}");
3053 }
3054
3055 #[test]
3056 fn staged_runtime_parses_accepted_shape() {
3057 let manifest = RuntimeManifest::from_yaml_str(
3058 r#"
3059name: cap
3060version: 0.0.1
3061artifacts: []
3062capabilities:
3063 containment: sealed
3064 shell:
3065 allow:
3066 - python3
3067 - bash
3068 staged_runtime:
3069 - binary: python3
3070 source_path: /opt/testbed/conda/envs/django__django
3071 pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
3072"#,
3073 )
3074 .unwrap();
3075
3076 let shell = manifest.capabilities.unwrap().shell.unwrap();
3077 assert_eq!(
3078 shell.staged_runtime,
3079 vec![StagedRuntimeGrant {
3080 binary: "python3".to_string(),
3081 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
3082 pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
3083 }]
3084 );
3085 assert!(shell.interpreter_runtime.is_empty());
3088 }
3089
3090 #[test]
3091 fn staged_runtime_defaults_to_empty_when_absent() {
3092 let manifest = RuntimeManifest::from_yaml_str(
3093 r#"
3094name: cap
3095version: 0.0.1
3096artifacts: []
3097capabilities:
3098 shell:
3099 allow:
3100 - python3
3101"#,
3102 )
3103 .unwrap();
3104
3105 let shell = manifest.capabilities.unwrap().shell.unwrap();
3106 assert!(shell.staged_runtime.is_empty());
3107 }
3108
3109 fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
3113 let yaml = format!(
3114 r#"
3115name: cap
3116version: 0.0.1
3117artifacts: []
3118capabilities:
3119 shell:
3120 allow:
3121 - python3
3122{extra} staged_runtime:
3123{grant_yaml}
3124"#,
3125 );
3126 RuntimeManifest::from_yaml_str(&yaml)
3127 .expect_err("grant should be rejected")
3128 .to_string()
3129 }
3130
3131 #[test]
3132 fn staged_runtime_rejects_binary_not_in_allow() {
3133 let msg = staged_runtime_reject(
3134 " - binary: ruby\n source_path: /opt/ruby\n pin: ruby-3.2.2\n",
3135 "",
3136 );
3137 assert!(
3138 msg.contains("capabilities.shell.staged_runtime[0].binary"),
3139 "{msg}"
3140 );
3141 assert!(msg.contains("ruby"), "{msg}");
3142 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
3143 }
3144
3145 #[test]
3146 fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
3147 let msg = staged_runtime_reject(
3148 " - binary: python3\n source_path: /opt/py\n pin: cpython-3.9.19\n",
3149 " interpreter_runtime:\n - binary: python3\n dirs:\n - path: /usr/lib/python3.9\n list_dir: true\n",
3150 );
3151 assert!(
3152 msg.contains("capabilities.shell.staged_runtime[0].binary"),
3153 "{msg}"
3154 );
3155 assert!(msg.contains("python3"), "{msg}");
3156 assert!(msg.contains("interpreter_runtime"), "{msg}");
3157 assert!(msg.contains("mutually exclusive"), "{msg}");
3158 }
3159
3160 #[test]
3161 fn staged_runtime_rejects_relative_source_path() {
3162 let msg = staged_runtime_reject(
3163 " - binary: python3\n source_path: opt/testbed/conda\n pin: cpython-3.9.19\n",
3164 "",
3165 );
3166 assert!(
3167 msg.contains("capabilities.shell.staged_runtime[0].source_path"),
3168 "{msg}"
3169 );
3170 assert!(msg.contains("absolute"), "{msg}");
3171 }
3172
3173 #[test]
3174 fn staged_runtime_rejects_missing_or_empty_pin() {
3175 for grant in [
3178 " - binary: python3\n source_path: /opt/testbed/conda\n",
3179 " - binary: python3\n source_path: /opt/testbed/conda\n pin: \" \"\n",
3180 ] {
3181 let msg = staged_runtime_reject(grant, "");
3182 assert!(
3183 msg.contains("capabilities.shell.staged_runtime[0].pin"),
3184 "{msg}"
3185 );
3186 assert!(msg.contains("never inferred"), "{msg}");
3187 }
3188 }
3189
3190 #[test]
3191 fn parses_inference_block() {
3192 let manifest = RuntimeManifest::from_yaml_str(
3193 r#"
3194name: cap
3195version: 0.0.1
3196artifacts: []
3197inference:
3198 transport: http
3199 endpoint: http://127.0.0.1:8080
3200 model: test-model
3201 api_key: literal-key
3202 driver:
3203 artifact: murmur-driver-anthropic
3204 config:
3205 temperature: 0.2
3206 headers:
3207 x-test: true
3208"#,
3209 )
3210 .unwrap();
3211
3212 let inference = manifest.inference.expect("inference should exist");
3213 assert_eq!(inference.transport, "http");
3214 assert_eq!(
3215 inference.endpoint,
3216 Some("http://127.0.0.1:8080".to_string())
3217 );
3218 assert_eq!(inference.model, "test-model");
3219 assert_eq!(inference.api_key, Some("literal-key".to_string()));
3220 let driver = inference.driver.as_ref().expect("driver should be present");
3221 assert_eq!(driver.artifact, "murmur-driver-anthropic");
3222 assert_eq!(
3223 driver.config,
3224 Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
3225 );
3226 assert!(inference.system_prompt.is_none());
3227 assert!(inference.system_prompt_file.is_none());
3228 }
3229
3230 #[test]
3231 fn https_endpoint_accepted() {
3232 let manifest = RuntimeManifest::from_yaml_str(
3233 r#"
3234name: cap
3235version: 0.0.1
3236artifacts: []
3237inference:
3238 transport: http
3239 endpoint: https://api.anthropic.com
3240 model: test-model
3241 driver:
3242 artifact: murmur-driver-anthropic
3243"#,
3244 )
3245 .unwrap();
3246
3247 assert_eq!(
3248 manifest.inference.unwrap().endpoint,
3249 Some("https://api.anthropic.com".to_string())
3250 );
3251 }
3252
3253 #[test]
3254 fn http_localhost_endpoint_accepted() {
3255 let manifest = RuntimeManifest::from_yaml_str(
3256 r#"
3257name: cap
3258version: 0.0.1
3259artifacts: []
3260inference:
3261 transport: http
3262 endpoint: http://localhost:11434
3263 model: test-model
3264 driver:
3265 artifact: murmur-driver-anthropic
3266"#,
3267 )
3268 .unwrap();
3269
3270 assert_eq!(
3271 manifest.inference.unwrap().endpoint,
3272 Some("http://localhost:11434".to_string())
3273 );
3274 }
3275
3276 #[test]
3277 fn http_loopback_ip_endpoint_accepted() {
3278 let manifest = RuntimeManifest::from_yaml_str(
3279 r#"
3280name: cap
3281version: 0.0.1
3282artifacts: []
3283inference:
3284 transport: http
3285 endpoint: http://127.0.0.1:11434
3286 model: test-model
3287 driver:
3288 artifact: murmur-driver-anthropic
3289"#,
3290 )
3291 .unwrap();
3292
3293 assert_eq!(
3294 manifest.inference.unwrap().endpoint,
3295 Some("http://127.0.0.1:11434".to_string())
3296 );
3297 }
3298
3299 #[test]
3300 fn http_non_localhost_endpoint_rejected() {
3301 let err = RuntimeManifest::from_yaml_str(
3302 r#"
3303name: cap
3304version: 0.0.1
3305artifacts: []
3306inference:
3307 transport: http
3308 endpoint: http://api.attacker.example.com
3309 model: test-model
3310 driver:
3311 artifact: murmur-driver-anthropic
3312"#,
3313 )
3314 .unwrap_err();
3315
3316 let msg = err.to_string();
3317 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
3318 assert!(msg.contains("api.attacker.example.com"), "error was: {msg}");
3319 }
3320
3321 #[test]
3322 fn schemeless_endpoint_rejected() {
3323 let err = RuntimeManifest::from_yaml_str(
3324 r#"
3325name: cap
3326version: 0.0.1
3327artifacts: []
3328inference:
3329 transport: http
3330 endpoint: api.anthropic.com
3331 model: test-model
3332 driver:
3333 artifact: murmur-driver-anthropic
3334"#,
3335 )
3336 .unwrap_err();
3337
3338 let msg = err.to_string();
3339 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
3340 assert!(msg.contains("failed to parse"), "error was: {msg}");
3341 }
3342
3343 #[test]
3344 fn malformed_endpoint_rejected() {
3345 let err = RuntimeManifest::from_yaml_str(
3346 r#"
3347name: cap
3348version: 0.0.1
3349artifacts: []
3350inference:
3351 transport: http
3352 endpoint: "not a url"
3353 model: test-model
3354 driver:
3355 artifact: murmur-driver-anthropic
3356"#,
3357 )
3358 .unwrap_err();
3359
3360 let msg = err.to_string();
3361 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
3362 assert!(msg.contains("failed to parse"), "error was: {msg}");
3363 }
3364
3365 #[test]
3366 fn unsupported_scheme_endpoint_rejected() {
3367 let err = RuntimeManifest::from_yaml_str(
3368 r#"
3369name: cap
3370version: 0.0.1
3371artifacts: []
3372inference:
3373 transport: http
3374 endpoint: ftp://example.com
3375 model: test-model
3376 driver:
3377 artifact: murmur-driver-anthropic
3378"#,
3379 )
3380 .unwrap_err();
3381
3382 let msg = err.to_string();
3383 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
3384 assert!(msg.contains("unsupported scheme 'ftp'"), "error was: {msg}");
3385 }
3386
3387 #[test]
3388 fn parses_inline_system_prompt() {
3389 let manifest = RuntimeManifest::from_yaml_str(
3390 r#"
3391name: cap
3392version: 0.0.1
3393artifacts: []
3394inference:
3395 endpoint: http://127.0.0.1:8080
3396 model: test-model
3397 system_prompt: "Always begin with CONFIRMED:"
3398 driver:
3399 artifact: murmur-driver-anthropic
3400"#,
3401 )
3402 .unwrap();
3403
3404 let inference = manifest.inference.unwrap();
3405 assert_eq!(
3406 inference.system_prompt,
3407 Some("Always begin with CONFIRMED:".to_string())
3408 );
3409 assert!(inference.system_prompt_file.is_none());
3410 }
3411
3412 #[test]
3413 fn parses_compaction_system_prompt() {
3414 let manifest = RuntimeManifest::from_yaml_str(
3415 r#"
3416name: cap
3417version: 0.0.1
3418artifacts: []
3419inference:
3420 endpoint: http://127.0.0.1:8080
3421 model: test-model
3422 compaction:
3423 model: compaction-model
3424 system_prompt: "task = X, currently editing Y, already tried Z."
3425 driver:
3426 artifact: murmur-driver-anthropic
3427"#,
3428 )
3429 .unwrap();
3430
3431 let compaction = manifest.inference.unwrap().compaction.unwrap();
3432 assert_eq!(
3433 compaction.system_prompt,
3434 Some("task = X, currently editing Y, already tried Z.".to_string())
3435 );
3436 assert_eq!(compaction.model, Some("compaction-model".to_string()));
3437 }
3438
3439 #[test]
3442 fn compaction_system_prompt_absent_stays_none() {
3443 let manifest = RuntimeManifest::from_yaml_str(
3444 r#"
3445name: cap
3446version: 0.0.1
3447artifacts: []
3448inference:
3449 endpoint: http://127.0.0.1:8080
3450 model: test-model
3451 compaction:
3452 model: compaction-model
3453 driver:
3454 artifact: murmur-driver-anthropic
3455"#,
3456 )
3457 .unwrap();
3458
3459 let compaction = manifest.inference.unwrap().compaction.unwrap();
3460 assert!(compaction.system_prompt.is_none());
3461 assert!(compaction.system_prompt_file.is_none());
3462 assert_eq!(compaction.model, Some("compaction-model".to_string()));
3463 }
3464
3465 #[test]
3469 fn parses_compaction_system_prompt_file() {
3470 let manifest = RuntimeManifest::from_yaml_str(
3471 r#"
3472name: cap
3473version: 0.0.1
3474artifacts: []
3475inference:
3476 endpoint: http://127.0.0.1:8080
3477 model: test-model
3478 compaction:
3479 model: compaction-model
3480 system_prompt_file: "compaction-instructions.md"
3481 driver:
3482 artifact: murmur-driver-anthropic
3483"#,
3484 )
3485 .unwrap();
3486
3487 let compaction = manifest.inference.unwrap().compaction.unwrap();
3488 assert_eq!(
3489 compaction.system_prompt_file,
3490 Some("compaction-instructions.md".to_string())
3491 );
3492 assert!(compaction.system_prompt.is_none());
3493 assert_eq!(compaction.model, Some("compaction-model".to_string()));
3494 }
3495
3496 #[test]
3499 fn compaction_system_prompt_file_independent_of_model() {
3500 let manifest = RuntimeManifest::from_yaml_str(
3501 r#"
3502name: cap
3503version: 0.0.1
3504artifacts: []
3505inference:
3506 endpoint: http://127.0.0.1:8080
3507 model: test-model
3508 compaction:
3509 threshold: 0.5
3510 system_prompt_file: "compaction-instructions.md"
3511 driver:
3512 artifact: murmur-driver-anthropic
3513"#,
3514 )
3515 .unwrap();
3516
3517 let compaction = manifest.inference.unwrap().compaction.unwrap();
3518 assert_eq!(
3519 compaction.system_prompt_file,
3520 Some("compaction-instructions.md".to_string())
3521 );
3522 assert!(compaction.model.is_none());
3523 assert_eq!(compaction.threshold, Some(0.5));
3524 }
3525
3526 #[test]
3530 fn parses_compaction_dump_summaries() {
3531 let parse = |line: &str| {
3532 RuntimeManifest::from_yaml_str(&format!(
3533 r#"
3534name: cap
3535version: 0.0.1
3536artifacts: []
3537inference:
3538 endpoint: http://127.0.0.1:8080
3539 model: test-model
3540 compaction:
3541 model: compaction-model
3542{line} driver:
3543 artifact: murmur-driver-anthropic
3544"#
3545 ))
3546 .unwrap()
3547 .inference
3548 .unwrap()
3549 .compaction
3550 .unwrap()
3551 };
3552
3553 assert_eq!(
3554 parse(" dump_summaries: true\n").dump_summaries,
3555 Some(true)
3556 );
3557 assert_eq!(
3558 parse(" dump_summaries: false\n").dump_summaries,
3559 Some(false)
3560 );
3561 assert_eq!(parse("").dump_summaries, None);
3562 }
3563
3564 #[test]
3568 fn compaction_dump_summaries_does_not_interact_with_other_fields() {
3569 let compaction = RuntimeManifest::from_yaml_str(
3570 r#"
3571name: cap
3572version: 0.0.1
3573artifacts: []
3574inference:
3575 endpoint: http://127.0.0.1:8080
3576 model: test-model
3577 compaction:
3578 threshold: 0.5
3579 model: compaction-model
3580 system_prompt_file: "compaction-instructions.md"
3581 dump_summaries: true
3582 driver:
3583 artifact: murmur-driver-anthropic
3584"#,
3585 )
3586 .unwrap()
3587 .inference
3588 .unwrap()
3589 .compaction
3590 .unwrap();
3591
3592 assert_eq!(compaction.dump_summaries, Some(true));
3593 assert_eq!(compaction.threshold, Some(0.5));
3594 assert_eq!(compaction.model, Some("compaction-model".to_string()));
3595 assert_eq!(
3596 compaction.system_prompt_file,
3597 Some("compaction-instructions.md".to_string())
3598 );
3599 assert!(compaction.system_prompt.is_none());
3600
3601 let err = RuntimeManifest::from_yaml_str(
3603 r#"
3604name: cap
3605version: 0.0.1
3606artifacts: []
3607inference:
3608 endpoint: http://127.0.0.1:8080
3609 model: test-model
3610 compaction:
3611 dump_summaries: true
3612 system_prompt: "inline"
3613 system_prompt_file: "compaction-instructions.md"
3614 driver:
3615 artifact: murmur-driver-anthropic
3616"#,
3617 )
3618 .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
3619 assert!(matches!(
3620 err,
3621 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
3622 if field == "inference.compaction.system_prompt"
3623 ));
3624
3625 let err = RuntimeManifest::from_yaml_str(
3627 r#"
3628name: cap
3629version: 0.0.1
3630artifacts: []
3631inference:
3632 endpoint: http://127.0.0.1:8080
3633 model: test-model
3634 compaction:
3635 dump_summaries: true
3636 threshold: 1.5
3637 driver:
3638 artifact: murmur-driver-anthropic
3639"#,
3640 )
3641 .expect_err("dump_summaries must not suppress the threshold range check");
3642 assert!(matches!(
3643 err,
3644 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
3645 if field == "inference.compaction.threshold"
3646 ));
3647 }
3648
3649 #[test]
3653 fn compaction_prompt_sources_are_mutually_exclusive() {
3654 let err = RuntimeManifest::from_yaml_str(
3655 r#"
3656name: cap
3657version: 0.0.1
3658artifacts: []
3659inference:
3660 endpoint: http://127.0.0.1:8080
3661 model: test-model
3662 compaction:
3663 system_prompt: "inline"
3664 system_prompt_file: "compaction-instructions.md"
3665 driver:
3666 artifact: murmur-driver-anthropic
3667"#,
3668 )
3669 .expect_err("both compaction prompt sources set must fail");
3670
3671 match err {
3672 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
3673 assert_eq!(field, "inference.compaction.system_prompt");
3674 assert_eq!(
3675 message,
3676 "at most one of inference.compaction.system_prompt, \
3677 inference.compaction.system_prompt_file may be set"
3678 );
3679 }
3680 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
3681 }
3682 }
3683
3684 #[test]
3688 fn top_level_and_compaction_prompt_sources_do_not_collide() {
3689 let manifest = RuntimeManifest::from_yaml_str(
3690 r#"
3691name: cap
3692version: 0.0.1
3693artifacts: []
3694inference:
3695 endpoint: http://127.0.0.1:8080
3696 model: test-model
3697 system_prompt: "be helpful"
3698 compaction:
3699 system_prompt_file: "compaction-instructions.md"
3700 driver:
3701 artifact: murmur-driver-anthropic
3702"#,
3703 )
3704 .unwrap();
3705
3706 let inference = manifest.inference.unwrap();
3707 assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
3708 assert_eq!(
3709 inference.compaction.unwrap().system_prompt_file,
3710 Some("compaction-instructions.md".to_string())
3711 );
3712 }
3713
3714 #[test]
3715 fn parses_system_prompt_file() {
3716 let manifest = RuntimeManifest::from_yaml_str(
3717 r#"
3718name: cap
3719version: 0.0.1
3720artifacts: []
3721inference:
3722 endpoint: http://127.0.0.1:8080
3723 model: test-model
3724 system_prompt_file: "conventions.md"
3725 driver:
3726 artifact: murmur-driver-anthropic
3727"#,
3728 )
3729 .unwrap();
3730
3731 let inference = manifest.inference.unwrap();
3732 assert_eq!(
3733 inference.system_prompt_file,
3734 Some("conventions.md".to_string())
3735 );
3736 assert!(inference.system_prompt.is_none());
3737 }
3738
3739 #[test]
3740 fn rejects_both_inline_and_file_system_prompt() {
3741 let err = RuntimeManifest::from_yaml_str(
3742 r#"
3743name: cap
3744version: 0.0.1
3745artifacts: []
3746inference:
3747 endpoint: http://127.0.0.1:8080
3748 model: test-model
3749 system_prompt: "inline"
3750 system_prompt_file: "conventions.md"
3751 driver:
3752 artifact: murmur-driver-anthropic
3753"#,
3754 )
3755 .unwrap_err();
3756
3757 let msg = err.to_string();
3758 assert!(msg.contains("inference.system_prompt"));
3759 assert!(msg.contains("system_prompt_file"));
3760 }
3761
3762 #[test]
3763 fn resolves_api_key_env_reference() {
3764 let key = "MURMUR_TEST_INFERENCE_KEY_RESOLVE";
3765 std::env::set_var(key, "resolved-secret");
3766
3767 let manifest = RuntimeManifest::from_yaml_str(&format!(
3768 "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"
3769 ))
3770 .unwrap();
3771
3772 assert_eq!(
3773 manifest.inference.unwrap().api_key,
3774 Some("resolved-secret".to_string())
3775 );
3776
3777 std::env::remove_var(key);
3778 }
3779
3780 #[test]
3781 fn missing_api_key_env_reference_reports_clear_error() {
3782 let key = "MURMUR_TEST_INFERENCE_KEY_MISSING";
3783 std::env::remove_var(key);
3784
3785 let err = RuntimeManifest::from_yaml_str(&format!(
3786 "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"
3787 ))
3788 .unwrap_err();
3789
3790 let msg = err.to_string();
3791 assert!(msg.contains("inference.api_key"));
3792 assert!(msg.contains(&format!("${{{key}}}")));
3793 assert!(msg.contains("environment variable is not set"));
3794 }
3795
3796 #[test]
3797 fn accepts_legacy_inference_provider_field() {
3798 let manifest = RuntimeManifest::from_yaml_str(
3799 r#"
3800name: cap
3801version: 0.0.1
3802artifacts: []
3803inference:
3804 transport: http
3805 endpoint: http://127.0.0.1:8080
3806 model: test-model
3807 provider:
3808 artifact: murmur-driver-openai
3809"#,
3810 )
3811 .unwrap();
3812
3813 assert_eq!(
3814 manifest
3815 .inference
3816 .unwrap()
3817 .driver
3818 .as_ref()
3819 .unwrap()
3820 .artifact,
3821 "murmur-driver-openai"
3822 );
3823 }
3824
3825 #[test]
3826 fn prefers_driver_field_over_legacy_provider_field() {
3827 let manifest = RuntimeManifest::from_yaml_str(
3828 r#"
3829name: cap
3830version: 0.0.1
3831artifacts: []
3832inference:
3833 transport: http
3834 endpoint: http://127.0.0.1:8080
3835 model: test-model
3836 driver:
3837 artifact: murmur-driver-anthropic
3838 provider:
3839 artifact: murmur-driver-openai
3840"#,
3841 )
3842 .unwrap();
3843
3844 assert_eq!(
3845 manifest
3846 .inference
3847 .unwrap()
3848 .driver
3849 .as_ref()
3850 .unwrap()
3851 .artifact,
3852 "murmur-driver-anthropic"
3853 );
3854 }
3855
3856 #[test]
3857 fn inference_requires_driver_artifact() {
3858 let err = RuntimeManifest::from_yaml_str(
3859 r#"
3860name: cap
3861version: 0.0.1
3862artifacts: []
3863inference:
3864 transport: http
3865 endpoint: http://127.0.0.1:8080
3866 model: test-model
3867"#,
3868 )
3869 .unwrap_err();
3870
3871 assert!(err.to_string().contains("inference.driver"));
3872 }
3873
3874 #[test]
3875 fn test_runtime_driver_deserializes() {
3876 let manifest = RuntimeManifest::from_yaml_str(
3877 r#"
3878name: cap
3879version: 0.0.1
3880artifacts:
3881 - name: my-provider
3882 version: 1.0.0
3883 runtime: driver
3884"#,
3885 )
3886 .unwrap();
3887
3888 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
3889 }
3890
3891 #[test]
3892 fn test_runtime_unknown_is_error() {
3893 let err = RuntimeManifest::from_yaml_str(
3894 r#"
3895name: cap
3896version: 0.0.1
3897artifacts:
3898 - name: foo
3899 version: 1.0.0
3900 runtime: unknown_type
3901"#,
3902 )
3903 .unwrap_err();
3904
3905 let msg = err.to_string();
3906 assert!(msg.contains("runtime"), "error should mention 'runtime'");
3907 assert!(
3908 msg.contains("unknown_type"),
3909 "error should identify the bad value"
3910 );
3911 }
3912
3913 #[test]
3914 fn test_runtime_all_variants_roundtrip() {
3915 for variant in [
3916 ArtifactRuntime::Tool,
3917 ArtifactRuntime::Driver,
3918 ArtifactRuntime::Hook,
3919 ArtifactRuntime::Skill,
3920 ] {
3921 let json = serde_json::to_string(&variant).unwrap();
3922 let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
3923 assert_eq!(variant, back);
3924 }
3925 }
3926
3927 #[test]
3928 fn skill_source_without_version_defaults_to_local() {
3929 let manifest = RuntimeManifest::from_yaml_str(
3930 r#"
3931name: cap
3932version: 0.0.1
3933artifacts:
3934 - name: my-skill
3935 source: ./skills/my-skill/skill.md
3936 runtime: skill
3937"#,
3938 )
3939 .unwrap();
3940
3941 assert_eq!(manifest.artifacts.len(), 1);
3942 let artifact = &manifest.artifacts[0];
3943 assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
3944 assert_eq!(
3945 artifact.source.as_deref(),
3946 Some("./skills/my-skill/skill.md")
3947 );
3948 assert_eq!(artifact.version, "local");
3949 }
3950
3951 #[test]
3952 fn skill_source_directory_path_is_accepted() {
3953 let manifest = RuntimeManifest::from_yaml_str(
3954 r#"
3955name: cap
3956version: 0.0.1
3957artifacts:
3958 - name: my-skill
3959 source: ./skills/my-skill/
3960 runtime: skill
3961"#,
3962 )
3963 .unwrap();
3964
3965 assert_eq!(
3966 manifest.artifacts[0].source.as_deref(),
3967 Some("./skills/my-skill/")
3968 );
3969 assert_eq!(manifest.artifacts[0].version, "local");
3970 }
3971
3972 #[test]
3973 fn skill_source_with_explicit_version_substitutes_local() {
3974 let manifest = RuntimeManifest::from_yaml_str(
3976 r#"
3977name: cap
3978version: 0.0.1
3979artifacts:
3980 - name: my-skill
3981 source: ./skills/my-skill/skill.md
3982 version: 9.9.9
3983 runtime: skill
3984"#,
3985 )
3986 .unwrap();
3987
3988 assert_eq!(manifest.artifacts[0].version, "local");
3989 }
3990
3991 #[test]
3992 fn local_source_true_allows_source_on_non_skill_runtime() {
3993 for runtime in ["tool", "driver", "hook"] {
3994 let yaml = format!(
3995 r#"
3996name: cap
3997version: 0.0.1
3998artifacts:
3999 - name: my-thing
4000 source: ./local/path
4001 local_source: true
4002 runtime: {runtime}
4003"#
4004 );
4005 let manifest = RuntimeManifest::from_yaml_str(&yaml)
4006 .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
4007 let artifact = &manifest.artifacts[0];
4008 assert!(artifact.local_source, "runtime {runtime}");
4009 assert_eq!(artifact.source.as_deref(), Some("./local/path"));
4010 assert_eq!(artifact.version, "local", "runtime {runtime}");
4011 }
4012 }
4013
4014 #[test]
4015 fn local_source_false_on_skill_with_source_is_error() {
4016 let err = RuntimeManifest::from_yaml_str(
4019 r#"
4020name: cap
4021version: 0.0.1
4022artifacts:
4023 - name: my-skill
4024 source: ./skills/my-skill
4025 local_source: false
4026 runtime: skill
4027"#,
4028 )
4029 .unwrap_err();
4030 let msg = err.to_string();
4031 assert!(msg.contains("'local_source: true'"), "error was: {msg}");
4032 assert!(msg.contains("my-skill"), "error was: {msg}");
4033 }
4034
4035 #[test]
4036 fn local_source_defaults_from_role_when_absent() {
4037 let manifest = RuntimeManifest::from_yaml_str(
4038 r#"
4039name: cap
4040version: 0.0.1
4041artifacts:
4042 - name: my-skill
4043 source: ./skills/my-skill
4044 runtime: skill
4045 - name: my-tool
4046 version: 1.0.0
4047 runtime: tool
4048"#,
4049 )
4050 .unwrap();
4051 assert!(manifest.artifacts[0].local_source, "skill defaults to true");
4052 assert!(
4053 !manifest.artifacts[1].local_source,
4054 "tool defaults to false"
4055 );
4056 }
4057
4058 #[test]
4059 fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
4060 let manifest = RuntimeManifest::from_yaml_str(
4061 r#"
4062name: cap
4063version: 0.0.1
4064artifacts:
4065 - name: my-tool
4066 version: 1.0.0
4067 runtime: tool
4068 prompt_payload: true
4069inference:
4070 endpoint: http://127.0.0.1:8080
4071 model: test-model
4072 system_prompt_artifact: my-tool
4073 driver:
4074 artifact: murmur-driver-anthropic
4075"#,
4076 )
4077 .unwrap();
4078 assert!(manifest.artifacts[0].prompt_payload);
4079 }
4080
4081 #[test]
4082 fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
4083 let err = RuntimeManifest::from_yaml_str(
4084 r#"
4085name: cap
4086version: 0.0.1
4087artifacts:
4088 - name: my-skill
4089 version: 1.0.0
4090 runtime: skill
4091 prompt_payload: false
4092inference:
4093 endpoint: http://127.0.0.1:8080
4094 model: test-model
4095 system_prompt_artifact: my-skill
4096 driver:
4097 artifact: murmur-driver-anthropic
4098"#,
4099 )
4100 .unwrap_err();
4101 let msg = err.to_string();
4102 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
4103 }
4104
4105 #[test]
4106 fn prompt_payload_defaults_from_role_when_absent() {
4107 let manifest = RuntimeManifest::from_yaml_str(
4108 r#"
4109name: cap
4110version: 0.0.1
4111artifacts:
4112 - name: my-skill
4113 version: 1.0.0
4114 runtime: skill
4115 - name: my-tool
4116 version: 1.0.0
4117 runtime: tool
4118"#,
4119 )
4120 .unwrap();
4121 assert!(
4122 manifest.artifacts[0].prompt_payload,
4123 "skill defaults to true"
4124 );
4125 assert!(
4126 !manifest.artifacts[1].prompt_payload,
4127 "tool defaults to false"
4128 );
4129 }
4130
4131 #[test]
4132 fn source_on_non_skill_runtime_is_error() {
4133 for runtime in ["tool", "driver", "hook"] {
4134 let yaml = format!(
4135 r#"
4136name: cap
4137version: 0.0.1
4138artifacts:
4139 - name: my-thing
4140 source: ./local/path
4141 runtime: {runtime}
4142"#
4143 );
4144 let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
4145 let msg = err.to_string();
4146 assert!(
4147 msg.contains("'local_source: true'"),
4148 "runtime {runtime}: error was: {msg}"
4149 );
4150 assert!(
4151 msg.contains("my-thing"),
4152 "runtime {runtime}: error was: {msg}"
4153 );
4154 }
4155 }
4156
4157 #[test]
4158 fn registry_skill_still_requires_version() {
4159 let err = RuntimeManifest::from_yaml_str(
4160 r#"
4161name: cap
4162version: 0.0.1
4163artifacts:
4164 - name: my-skill
4165 runtime: skill
4166"#,
4167 )
4168 .unwrap_err();
4169
4170 let msg = err.to_string();
4171 assert!(
4172 msg.contains("missing required field 'version'"),
4173 "error was: {msg}"
4174 );
4175 }
4176
4177 #[test]
4178 fn rejects_unknown_inference_transport() {
4179 let err = RuntimeManifest::from_yaml_str(
4180 r#"
4181name: cap
4182version: 0.0.1
4183artifacts: []
4184inference:
4185 transport: grpc
4186 endpoint: ignored
4187 model: test-model
4188 driver:
4189 artifact: murmur-driver-anthropic
4190"#,
4191 )
4192 .unwrap_err();
4193
4194 let msg = err.to_string();
4195 assert!(msg.contains("inference.transport"), "error was: {msg}");
4196 assert!(msg.contains("unknown value"), "error was: {msg}");
4197 assert!(msg.contains("grpc"), "error was: {msg}");
4198 }
4199
4200 #[test]
4201 fn parses_process_transport() {
4202 let manifest = RuntimeManifest::from_yaml_str(
4203 r#"
4204name: cap
4205version: 0.0.1
4206artifacts: []
4207inference:
4208 transport: process
4209 command: claude
4210 model: claude-haiku-4-5-20251001
4211 max_turns: 20
4212"#,
4213 )
4214 .unwrap();
4215
4216 let inference = manifest.inference.expect("inference should exist");
4217 assert_eq!(inference.transport, "process");
4218 assert_eq!(inference.command, Some("claude".to_string()));
4219 assert_eq!(inference.model, "claude-haiku-4-5-20251001");
4220 assert_eq!(inference.max_turns, 20);
4221 assert!(inference.driver.is_none());
4222 assert!(inference.endpoint.is_none());
4223 assert!(inference.api_key.is_none());
4224 }
4225
4226 #[test]
4227 fn process_transport_requires_command() {
4228 let err = RuntimeManifest::from_yaml_str(
4229 r#"
4230name: cap
4231version: 0.0.1
4232artifacts: []
4233inference:
4234 transport: process
4235 model: claude-haiku-4-5-20251001
4236"#,
4237 )
4238 .unwrap_err();
4239
4240 let msg = err.to_string();
4241 assert!(msg.contains("inference.command"), "error was: {msg}");
4242 }
4243
4244 #[test]
4245 fn process_transport_allows_absent_model() {
4246 let manifest = RuntimeManifest::from_yaml_str(
4249 r#"
4250name: cap
4251version: 0.0.1
4252artifacts: []
4253inference:
4254 transport: process
4255 command: codex
4256"#,
4257 )
4258 .expect("process transport should parse without a model");
4259 let inference = manifest.inference.expect("inference present");
4260 assert_eq!(inference.transport, "process");
4261 assert_eq!(inference.command.as_deref(), Some("codex"));
4262 assert_eq!(
4263 inference.model, "",
4264 "absent model resolves to empty (provider default)"
4265 );
4266 }
4267
4268 #[test]
4269 fn process_transport_rejects_driver_artifact() {
4270 let err = RuntimeManifest::from_yaml_str(
4271 r#"
4272name: cap
4273version: 0.0.1
4274artifacts: []
4275inference:
4276 transport: process
4277 command: claude
4278 model: claude-haiku-4-5-20251001
4279 driver:
4280 artifact: murmur-driver-anthropic
4281"#,
4282 )
4283 .unwrap_err();
4284
4285 let msg = err.to_string();
4286 assert!(
4287 msg.contains("inference.driver.artifact"),
4288 "error was: {msg}"
4289 );
4290 assert!(
4291 msg.contains("not valid with transport: process"),
4292 "error was: {msg}"
4293 );
4294 }
4295
4296 #[test]
4297 fn process_transport_rejects_endpoint() {
4298 let err = RuntimeManifest::from_yaml_str(
4299 r#"
4300name: cap
4301version: 0.0.1
4302artifacts: []
4303inference:
4304 transport: process
4305 command: claude
4306 model: claude-haiku-4-5-20251001
4307 endpoint: http://localhost:8080
4308"#,
4309 )
4310 .unwrap_err();
4311
4312 let msg = err.to_string();
4313 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
4314 assert!(
4315 msg.contains("not valid with transport: process"),
4316 "error was: {msg}"
4317 );
4318 }
4319
4320 #[test]
4321 fn process_transport_rejects_api_key() {
4322 let key = ["sk-", "ant-", "secret"].concat();
4325 let err = RuntimeManifest::from_yaml_str(&format!(
4326 r#"
4327name: cap
4328version: 0.0.1
4329artifacts: []
4330inference:
4331 transport: process
4332 command: claude
4333 model: claude-haiku-4-5-20251001
4334 api_key: {key}
4335"#
4336 ))
4337 .unwrap_err();
4338
4339 let msg = err.to_string();
4340 assert!(msg.contains("inference.api_key"), "error was: {msg}");
4341 assert!(
4342 msg.contains("not valid with transport: process"),
4343 "error was: {msg}"
4344 );
4345 }
4346
4347 #[test]
4348 fn process_transport_rejects_max_tokens() {
4349 let err = RuntimeManifest::from_yaml_str(
4352 r#"
4353name: cap
4354version: 0.0.1
4355artifacts: []
4356inference:
4357 transport: process
4358 command: claude
4359 model: claude-haiku-4-5-20251001
4360 max_tokens: 4096
4361"#,
4362 )
4363 .unwrap_err();
4364
4365 let msg = err.to_string();
4366 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
4367 assert!(
4368 msg.contains("not valid with transport: process"),
4369 "error was: {msg}"
4370 );
4371 }
4372
4373 #[test]
4374 fn inference_max_tokens_round_trips() {
4375 let manifest = RuntimeManifest::from_yaml_str(
4376 r#"
4377name: cap
4378version: 0.0.1
4379artifacts: []
4380inference:
4381 transport: http
4382 endpoint: https://api.anthropic.com
4383 model: claude-opus-4-5
4384 max_tokens: 4096
4385 driver:
4386 artifact: murmur-driver-anthropic
4387"#,
4388 )
4389 .unwrap();
4390
4391 let inference = manifest.inference.unwrap();
4392 assert_eq!(inference.max_tokens, Some(4096));
4393 }
4394
4395 #[test]
4396 fn inference_max_tokens_absent_is_none() {
4397 let manifest = RuntimeManifest::from_yaml_str(
4400 r#"
4401name: cap
4402version: 0.0.1
4403artifacts: []
4404inference:
4405 transport: http
4406 endpoint: https://api.anthropic.com
4407 model: claude-opus-4-5
4408 driver:
4409 artifact: murmur-driver-anthropic
4410"#,
4411 )
4412 .unwrap();
4413
4414 assert_eq!(manifest.inference.unwrap().max_tokens, None);
4415 }
4416
4417 #[test]
4418 fn inference_max_tokens_zero_is_rejected() {
4419 let err = RuntimeManifest::from_yaml_str(
4420 r#"
4421name: cap
4422version: 0.0.1
4423artifacts: []
4424inference:
4425 transport: http
4426 endpoint: https://api.anthropic.com
4427 model: claude-opus-4-5
4428 max_tokens: 0
4429 driver:
4430 artifact: murmur-driver-anthropic
4431"#,
4432 )
4433 .unwrap_err();
4434
4435 let msg = err.to_string();
4436 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
4437 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
4438 }
4439
4440 #[test]
4441 fn inference_max_tokens_large_value_is_accepted() {
4442 let manifest = RuntimeManifest::from_yaml_str(
4445 r#"
4446name: cap
4447version: 0.0.1
4448artifacts: []
4449inference:
4450 transport: http
4451 endpoint: https://api.anthropic.com
4452 model: claude-opus-4-5
4453 max_tokens: 999999
4454 driver:
4455 artifact: murmur-driver-anthropic
4456"#,
4457 )
4458 .unwrap();
4459
4460 assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
4461 }
4462
4463 #[test]
4464 fn inference_and_context_max_tokens_are_independent() {
4465 let manifest = RuntimeManifest::from_yaml_str(
4468 r#"
4469name: cap
4470version: 0.0.1
4471artifacts: []
4472inference:
4473 transport: http
4474 endpoint: https://api.anthropic.com
4475 model: claude-opus-4-5
4476 max_tokens: 4096
4477 driver:
4478 artifact: murmur-driver-anthropic
4479context:
4480 max_tokens: 200000
4481"#,
4482 )
4483 .unwrap();
4484
4485 assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
4486 assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
4487 }
4488
4489 #[test]
4490 fn hook_config_defaults_when_fields_absent() {
4491 let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
4492 assert_eq!(config.binding, HookBinding::All);
4493 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
4494 assert_eq!(config.commit_policy, HookCommitPolicy::None);
4495 }
4496
4497 #[test]
4498 fn hook_config_parses_all_fields() {
4499 let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
4500 let config = parse_hook_config_from_yaml(yaml).unwrap();
4501 assert_eq!(config.binding, HookBinding::OnCompaction);
4502 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
4503 assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
4504 }
4505
4506 #[test]
4507 fn hook_config_parses_on_task_start() {
4508 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
4509 let config = parse_hook_config_from_yaml(yaml).unwrap();
4510 assert_eq!(config.binding, HookBinding::OnTaskStart);
4511 }
4512
4513 #[test]
4514 fn hook_config_parses_on_task_end() {
4515 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
4516 let config = parse_hook_config_from_yaml(yaml).unwrap();
4517 assert_eq!(config.binding, HookBinding::OnTaskEnd);
4518 }
4519
4520 #[test]
4521 fn hook_config_unknown_binding_lists_task_events() {
4522 let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
4523 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
4524 assert!(err.contains("on-task-start"), "error was: {err}");
4525 assert!(err.contains("on-task-end"), "error was: {err}");
4526 }
4527
4528 #[test]
4529 fn hook_config_async_with_commit_is_rejected() {
4530 let yaml =
4531 "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
4532 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
4533 assert!(err.contains("async-with-commit"), "error was: {err}");
4534 }
4535
4536 #[test]
4537 fn hook_config_on_stage_async_is_rejected() {
4538 let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
4539 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
4540 assert!(err.contains("on-stage"), "error was: {err}");
4541 assert!(err.contains("blocking"), "error was: {err}");
4542 }
4543
4544 #[test]
4545 fn hook_config_async_none_is_valid() {
4546 let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
4547 let config = parse_hook_config_from_yaml(yaml).unwrap();
4548 assert_eq!(config.execution_mode, HookExecutionMode::Async);
4549 assert_eq!(config.commit_policy, HookCommitPolicy::None);
4550 }
4551
4552 #[test]
4553 fn hook_config_parses_reopen_task_commit_policy() {
4554 let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
4555 let config = parse_hook_config_from_yaml(yaml).unwrap();
4556 assert_eq!(config.binding, HookBinding::OnTaskEnd);
4557 assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
4558 }
4559
4560 #[test]
4561 fn hook_config_unknown_commit_policy_lists_reopen_task() {
4562 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
4563 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
4564 assert!(err.contains("reopen-task"), "error was: {err}");
4565 }
4566
4567 #[test]
4570 fn hook_config_async_reopen_task_is_rejected() {
4571 let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
4572 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
4573 assert!(err.contains("async-with-commit"), "error was: {err}");
4574 assert!(err.contains("reopen-task"), "error was: {err}");
4575 }
4576
4577 #[test]
4583 fn hook_config_non_committing_binding_rejects_any_commit_policy() {
4584 for binding in [
4585 "on-session-start",
4586 "on-task-start",
4587 "on-tool-call",
4588 "on-shell",
4589 "on-session-end",
4590 ] {
4591 let yaml = format!(
4592 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
4593 );
4594 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
4595 assert!(err.contains(binding), "error was: {err}");
4596 assert!(err.contains("replace-context"), "error was: {err}");
4597 assert!(
4598 err.contains("honors commit_policy 'none'"),
4599 "error was: {err}"
4600 );
4601 }
4602 }
4603
4604 #[test]
4607 fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
4608 for (binding, declared, honored) in [
4609 ("on-stage", "reopen-task", "write-manifests"),
4610 ("on-compaction", "write-manifests", "replace-context"),
4611 ("on-task-end", "replace-context", "reopen-task"),
4612 ] {
4613 let yaml = format!(
4614 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
4615 );
4616 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
4617 assert!(err.contains(binding), "error was: {err}");
4618 assert!(
4619 err.contains(&format!("commit_policy '{declared}' is not valid")),
4620 "error was: {err}"
4621 );
4622 assert!(
4623 err.contains(&format!("honors commit_policy '{honored}'")),
4624 "error was: {err}"
4625 );
4626 }
4627 }
4628
4629 #[test]
4632 fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
4633 for declared in ["replace-context", "write-manifests", "reopen-task"] {
4634 let yaml = format!(
4635 "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
4636 );
4637 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
4638 assert!(err.contains("on-inference"), "error was: {err}");
4639 assert!(err.contains(declared), "error was: {err}");
4640 assert!(
4641 err.contains("honors commit_policy 'none'"),
4642 "error was: {err}"
4643 );
4644 assert!(err.contains("artifact"), "error was: {err}");
4645 }
4646 }
4647
4648 #[test]
4651 fn hook_config_matching_binding_and_commit_policy_is_valid() {
4652 for (binding, policy, expected) in [
4653 (
4654 "on-stage",
4655 "write-manifests",
4656 HookCommitPolicy::WriteManifests,
4657 ),
4658 (
4659 "on-compaction",
4660 "replace-context",
4661 HookCommitPolicy::ReplaceContext,
4662 ),
4663 ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
4664 ] {
4665 let yaml =
4666 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
4667 let config = parse_hook_config_from_yaml(&yaml).unwrap();
4668 assert_eq!(config.commit_policy, expected);
4669 }
4670 }
4671
4672 #[test]
4675 fn hook_config_commit_policy_none_is_valid_for_every_binding() {
4676 for binding in [
4677 "on-stage",
4678 "on-session-start",
4679 "on-task-start",
4680 "on-inference",
4681 "on-tool-call",
4682 "on-shell",
4683 "on-compaction",
4684 "on-task-end",
4685 "on-session-end",
4686 ] {
4687 let yaml =
4688 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
4689 let config = parse_hook_config_from_yaml(&yaml).unwrap();
4690 assert_eq!(config.commit_policy, HookCommitPolicy::None);
4691 }
4692 }
4693
4694 #[test]
4698 fn hook_config_omitted_binding_accepts_every_commit_policy() {
4699 for (policy, expected) in [
4700 ("replace-context", HookCommitPolicy::ReplaceContext),
4701 ("write-manifests", HookCommitPolicy::WriteManifests),
4702 ("reopen-task", HookCommitPolicy::ReopenTask),
4703 ("none", HookCommitPolicy::None),
4704 ] {
4705 let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
4706 let config = parse_hook_config_from_yaml(&yaml).unwrap();
4707 assert_eq!(config.binding, HookBinding::All);
4708 assert_eq!(config.commit_policy, expected);
4709 }
4710 }
4711
4712 #[test]
4717 fn hook_config_shipped_default_artifact_manifests_still_parse() {
4718 let shipped: [(&str, &str, HookConfig); 8] = [
4719 (
4720 "murmur-hook-compact",
4721 "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
4722 HookConfig {
4723 binding: HookBinding::OnCompaction,
4724 execution_mode: HookExecutionMode::Blocking,
4725 commit_policy: HookCommitPolicy::ReplaceContext,
4726 },
4727 ),
4728 (
4729 "murmur-hook-shell-desc",
4730 "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
4731 HookConfig {
4732 binding: HookBinding::OnStage,
4733 execution_mode: HookExecutionMode::Blocking,
4734 commit_policy: HookCommitPolicy::WriteManifests,
4735 },
4736 ),
4737 (
4738 "murmur-hook-regression-verifier",
4740 "execution_mode: blocking\ncommit_policy: reopen-task\n",
4741 HookConfig {
4742 binding: HookBinding::All,
4743 execution_mode: HookExecutionMode::Blocking,
4744 commit_policy: HookCommitPolicy::ReopenTask,
4745 },
4746 ),
4747 (
4748 "murmur-hook-memory-jsonl",
4749 "execution_mode: blocking\ncommit_policy: replace-context\n",
4750 HookConfig {
4751 binding: HookBinding::All,
4752 execution_mode: HookExecutionMode::Blocking,
4753 commit_policy: HookCommitPolicy::ReplaceContext,
4754 },
4755 ),
4756 (
4757 "murmur-hook-debug",
4758 "execution_mode: async\ncommit_policy: none\n",
4759 HookConfig {
4760 binding: HookBinding::All,
4761 execution_mode: HookExecutionMode::Async,
4762 commit_policy: HookCommitPolicy::None,
4763 },
4764 ),
4765 (
4766 "murmur-hook-diff-summary",
4767 "execution_mode: blocking\ncommit_policy: none\n",
4768 HookConfig {
4769 binding: HookBinding::All,
4770 execution_mode: HookExecutionMode::Blocking,
4771 commit_policy: HookCommitPolicy::None,
4772 },
4773 ),
4774 ("murmur-hook-eval", "", HookConfig::default()),
4775 ("murmur-hook-grafana", "", HookConfig::default()),
4776 ];
4777
4778 for (name, fields, expected) in shipped {
4779 let yaml = format!("name: {name}\nruntime: hook\n{fields}");
4780 let config = parse_hook_config_from_yaml(&yaml)
4781 .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
4782 assert_eq!(config, expected, "shipped hook {name}");
4783 }
4784 }
4785
4786 #[test]
4790 fn commit_policy_for_binding_matches_the_declarable_pairs() {
4791 assert_eq!(
4792 commit_policy_for_binding(&HookBinding::OnStage),
4793 Some(HookCommitPolicy::WriteManifests)
4794 );
4795 assert_eq!(
4796 commit_policy_for_binding(&HookBinding::OnCompaction),
4797 Some(HookCommitPolicy::ReplaceContext)
4798 );
4799 assert_eq!(
4800 commit_policy_for_binding(&HookBinding::OnTaskEnd),
4801 Some(HookCommitPolicy::ReopenTask)
4802 );
4803 for binding in [
4806 HookBinding::OnSessionStart,
4807 HookBinding::OnTaskStart,
4808 HookBinding::OnInference,
4809 HookBinding::OnToolCall,
4810 HookBinding::OnShell,
4811 HookBinding::OnSessionEnd,
4812 HookBinding::All,
4813 ] {
4814 assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
4815 }
4816 }
4817
4818 #[test]
4819 fn parse_tool_implementation_defaults_to_wasm() {
4820 let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
4821 assert_eq!(impl_, ArtifactImplementation::Wasm);
4822 }
4823
4824 #[test]
4825 fn parse_tool_implementation_native() {
4826 let impl_ = parse_tool_implementation_from_yaml(
4827 "name: my-tool\nruntime: tool\nimplementation: native\n",
4828 );
4829 assert_eq!(impl_, ArtifactImplementation::Native);
4830 }
4831
4832 #[test]
4833 fn mur_version_parses_correctly() {
4834 let manifest = RuntimeManifest::from_yaml_str(
4835 "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"0.4.5\"\n",
4836 )
4837 .unwrap();
4838 assert_eq!(manifest.mur_version, Some("0.4.5".to_string()));
4839 }
4840
4841 #[test]
4842 fn mur_version_absent_is_none() {
4843 let manifest =
4844 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
4845 assert_eq!(manifest.mur_version, None);
4846 }
4847
4848 #[test]
4849 fn mur_version_empty_string_is_none() {
4850 let manifest = RuntimeManifest::from_yaml_str(
4851 "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"\"\n",
4852 )
4853 .unwrap();
4854 assert_eq!(manifest.mur_version, None);
4855 }
4856
4857 #[test]
4858 fn inference_max_turns_defaults_to_10() {
4859 let manifest = RuntimeManifest::from_yaml_str(
4860 "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",
4861 ).unwrap();
4862 assert_eq!(manifest.inference.unwrap().max_turns, 10);
4863 }
4864
4865 #[test]
4866 fn inference_max_turns_explicit_value() {
4867 let manifest = RuntimeManifest::from_yaml_str(
4868 "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",
4869 ).unwrap();
4870 assert_eq!(manifest.inference.unwrap().max_turns, 20);
4871 }
4872
4873 #[test]
4874 fn inference_max_turns_zero_is_rejected() {
4875 let err = RuntimeManifest::from_yaml_str(
4876 "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",
4877 ).unwrap_err();
4878 let msg = err.to_string();
4879 assert!(msg.contains("inference.max_turns"), "error was: {msg}");
4880 assert!(msg.contains("greater than 0"), "error was: {msg}");
4881 }
4882
4883 #[test]
4884 fn inference_max_task_reopens_defaults_to_1() {
4885 let manifest = RuntimeManifest::from_yaml_str(
4886 "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",
4887 ).unwrap();
4888 assert_eq!(manifest.inference.unwrap().max_task_reopens, 1);
4889 }
4890
4891 #[test]
4892 fn inference_max_task_reopens_explicit_value() {
4893 let manifest = RuntimeManifest::from_yaml_str(
4894 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n endpoint: http://127.0.0.1:8080\n model: test-model\n max_task_reopens: 3\n driver:\n artifact: murmur-driver-anthropic\n",
4895 ).unwrap();
4896 assert_eq!(manifest.inference.unwrap().max_task_reopens, 3);
4897 }
4898
4899 #[test]
4901 fn inference_max_task_reopens_zero_is_accepted() {
4902 let manifest = RuntimeManifest::from_yaml_str(
4903 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n endpoint: http://127.0.0.1:8080\n model: test-model\n max_task_reopens: 0\n driver:\n artifact: murmur-driver-anthropic\n",
4904 ).unwrap();
4905 assert_eq!(manifest.inference.unwrap().max_task_reopens, 0);
4906 }
4907
4908 #[test]
4910 fn inference_max_task_reopens_process_transport() {
4911 let manifest = RuntimeManifest::from_yaml_str(
4912 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n max_task_reopens: 2\n",
4913 ).unwrap();
4914 assert_eq!(manifest.inference.unwrap().max_task_reopens, 2);
4915 }
4916
4917 #[test]
4918 fn skill_is_llm_visible() {
4919 assert!(ArtifactRuntime::Skill.is_llm_visible());
4920 }
4921
4922 #[test]
4923 fn driver_and_hook_are_not_llm_visible() {
4924 assert!(!ArtifactRuntime::Driver.is_llm_visible());
4925 assert!(!ArtifactRuntime::Hook.is_llm_visible());
4926 }
4927
4928 #[test]
4929 fn parses_system_prompt_artifact() {
4930 let manifest = RuntimeManifest::from_yaml_str(
4931 r#"
4932name: cap
4933version: 0.0.1
4934artifacts:
4935 - name: my-skill
4936 version: 1.0.0
4937 runtime: skill
4938inference:
4939 endpoint: http://127.0.0.1:8080
4940 model: test-model
4941 system_prompt_artifact: my-skill
4942 driver:
4943 artifact: murmur-driver-anthropic
4944"#,
4945 )
4946 .unwrap();
4947
4948 let inference = manifest.inference.unwrap();
4949 assert_eq!(
4950 inference.system_prompt_artifact,
4951 Some("my-skill".to_string())
4952 );
4953 assert!(inference.system_prompt.is_none());
4954 assert!(inference.system_prompt_file.is_none());
4955 }
4956
4957 #[test]
4958 fn rejects_system_prompt_artifact_with_inline_prompt() {
4959 let err = RuntimeManifest::from_yaml_str(
4960 r#"
4961name: cap
4962version: 0.0.1
4963artifacts:
4964 - name: my-skill
4965 version: 1.0.0
4966 runtime: skill
4967inference:
4968 endpoint: http://127.0.0.1:8080
4969 model: test-model
4970 system_prompt: "inline"
4971 system_prompt_artifact: my-skill
4972 driver:
4973 artifact: murmur-driver-anthropic
4974"#,
4975 )
4976 .unwrap_err();
4977
4978 let msg = err.to_string();
4979 assert!(msg.contains("at most one"), "error was: {msg}");
4980 }
4981
4982 #[test]
4983 fn rejects_system_prompt_artifact_naming_non_skill() {
4984 let err = RuntimeManifest::from_yaml_str(
4985 r#"
4986name: cap
4987version: 0.0.1
4988artifacts:
4989 - name: my-tool
4990 version: 1.0.0
4991 runtime: tool
4992inference:
4993 endpoint: http://127.0.0.1:8080
4994 model: test-model
4995 system_prompt_artifact: my-tool
4996 driver:
4997 artifact: murmur-driver-anthropic
4998"#,
4999 )
5000 .unwrap_err();
5001
5002 let msg = err.to_string();
5003 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
5004 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
5005 }
5006
5007 #[test]
5008 fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
5009 let err = RuntimeManifest::from_yaml_str(
5010 r#"
5011name: cap
5012version: 0.0.1
5013artifacts: []
5014inference:
5015 endpoint: http://127.0.0.1:8080
5016 model: test-model
5017 system_prompt_artifact: missing-skill
5018 driver:
5019 artifact: murmur-driver-anthropic
5020"#,
5021 )
5022 .unwrap_err();
5023
5024 let msg = err.to_string();
5025 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
5026 assert!(msg.contains("not declared"), "error was: {msg}");
5027 }
5028
5029 #[test]
5030 fn rejects_all_three_system_prompt_sources() {
5031 let err = RuntimeManifest::from_yaml_str(
5032 r#"
5033name: cap
5034version: 0.0.1
5035artifacts:
5036 - name: my-skill
5037 version: 1.0.0
5038 runtime: skill
5039inference:
5040 endpoint: http://127.0.0.1:8080
5041 model: test-model
5042 system_prompt: "inline"
5043 system_prompt_file: "conventions.md"
5044 system_prompt_artifact: my-skill
5045 driver:
5046 artifact: murmur-driver-anthropic
5047"#,
5048 )
5049 .unwrap_err();
5050
5051 let msg = err.to_string();
5052 assert!(msg.contains("at most one"), "error was: {msg}");
5053 }
5054
5055 #[test]
5058 fn hook_artifact_capabilities_parse_network_and_filesystem() {
5059 let manifest = RuntimeManifest::from_yaml_str(
5060 r#"
5061name: cap
5062version: 0.0.1
5063artifacts:
5064 - name: telemetry-hook
5065 version: 1.0.0
5066 runtime: hook
5067 capabilities:
5068 network:
5069 allow:
5070 - https://telemetry.example.com
5071 filesystem:
5072 scope: hook-state
5073"#,
5074 )
5075 .unwrap();
5076
5077 let caps = manifest.artifacts[0]
5078 .capabilities
5079 .as_ref()
5080 .expect("hook entry carries a capability grant");
5081 assert_eq!(
5082 caps.network.as_ref().unwrap().allow,
5083 vec!["https://telemetry.example.com".to_string()]
5084 );
5085 assert_eq!(
5086 caps.filesystem.as_ref().unwrap().scope,
5087 Some("hook-state".to_string())
5088 );
5089 }
5090
5091 #[test]
5094 fn hook_artifact_without_capabilities_is_none() {
5095 let manifest = RuntimeManifest::from_yaml_str(
5096 r#"
5097name: cap
5098version: 0.0.1
5099artifacts:
5100 - name: quiet-hook
5101 version: 1.0.0
5102 runtime: hook
5103"#,
5104 )
5105 .unwrap();
5106
5107 assert!(manifest.artifacts[0].capabilities.is_none());
5108 }
5109
5110 #[test]
5113 fn tool_and_driver_artifact_capabilities_parse() {
5114 for role in ["tool", "driver"] {
5115 let yaml = format!(
5116 r#"
5117name: cap
5118version: 0.0.1
5119artifacts:
5120 - name: scoped
5121 version: 1.0.0
5122 runtime: {role}
5123 capabilities:
5124 network:
5125 allow:
5126 - https://api.example.com
5127 filesystem:
5128 scope: cache
5129"#
5130 );
5131 let manifest = RuntimeManifest::from_yaml_str(&yaml)
5132 .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
5133
5134 let caps = manifest.artifacts[0]
5135 .capabilities
5136 .as_ref()
5137 .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
5138 assert_eq!(
5139 caps.network.as_ref().unwrap().allow,
5140 vec!["https://api.example.com".to_string()]
5141 );
5142 assert_eq!(
5143 caps.filesystem.as_ref().unwrap().scope,
5144 Some("cache".to_string())
5145 );
5146 }
5147 }
5148
5149 #[test]
5152 fn tool_artifact_without_capabilities_is_none() {
5153 let manifest = RuntimeManifest::from_yaml_str(
5154 r#"
5155name: cap
5156version: 0.0.1
5157artifacts:
5158 - name: plain-tool
5159 version: 1.0.0
5160 runtime: tool
5161"#,
5162 )
5163 .unwrap();
5164
5165 assert!(manifest.artifacts[0].capabilities.is_none());
5166 }
5167
5168 #[test]
5171 fn skill_artifact_capabilities_are_rejected() {
5172 let yaml = r#"
5173name: cap
5174version: 0.0.1
5175artifacts:
5176 - name: sneaky
5177 version: 1.0.0
5178 runtime: skill
5179 capabilities:
5180 network:
5181 allow:
5182 - https://evil.example.com
5183"#;
5184 let err = match RuntimeManifest::from_yaml_str(yaml) {
5185 Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
5186 Err(err) => err,
5187 };
5188 let msg = err.to_string();
5189 assert!(msg.contains("sneaky"), "error was: {msg}");
5190 assert!(
5191 msg.contains(
5192 "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
5193 entries"
5194 ),
5195 "error was: {msg}"
5196 );
5197 assert!(msg.contains("runtime: skill"), "error was: {msg}");
5198 }
5199
5200 #[test]
5204 fn hook_on_overflow_block_parses() {
5205 let manifest = RuntimeManifest::from_yaml_str(
5206 r#"
5207name: cap
5208version: 0.0.1
5209artifacts:
5210 - name: murmur-hook-grafana
5211 version: 1.0.0
5212 runtime: hook
5213 on_overflow: block
5214"#,
5215 )
5216 .unwrap();
5217
5218 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
5219 }
5220
5221 #[test]
5224 fn hook_on_overflow_defaults_to_drop() {
5225 let manifest = RuntimeManifest::from_yaml_str(
5226 r#"
5227name: cap
5228version: 0.0.1
5229artifacts:
5230 - name: murmur-hook-grafana
5231 version: 1.0.0
5232 runtime: hook
5233"#,
5234 )
5235 .unwrap();
5236
5237 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
5238 assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
5239 }
5240
5241 #[test]
5244 fn on_overflow_on_a_non_hook_artifact_is_rejected() {
5245 let yaml = r#"
5246name: cap
5247version: 0.0.1
5248artifacts:
5249 - name: git
5250 version: 1.0.0
5251 runtime: tool
5252 on_overflow: block
5253"#;
5254 let err = match RuntimeManifest::from_yaml_str(yaml) {
5255 Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
5256 Err(err) => err,
5257 };
5258 assert!(matches!(
5259 err,
5260 RuntimeManifestError::InvalidArtifact { index: 0, .. }
5261 ));
5262 let msg = err.to_string();
5263 assert!(msg.contains("git"), "error was: {msg}");
5264 assert!(
5265 msg.contains("only recognized on 'runtime: hook' entries"),
5266 "error was: {msg}"
5267 );
5268 }
5269
5270 #[test]
5272 fn unknown_on_overflow_value_is_rejected() {
5273 let yaml = r#"
5274name: cap
5275version: 0.0.1
5276artifacts:
5277 - name: murmur-hook-grafana
5278 version: 1.0.0
5279 runtime: hook
5280 on_overflow: spill
5281"#;
5282 let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
5283 let msg = err.to_string();
5284 assert!(msg.contains("spill"), "error was: {msg}");
5285 assert!(msg.contains("expected: drop, block"), "error was: {msg}");
5286 }
5287
5288 #[test]
5291 fn on_overflow_is_accepted_on_any_hook_entry() {
5292 let manifest = RuntimeManifest::from_yaml_str(
5293 r#"
5294name: cap
5295version: 0.0.1
5296artifacts:
5297 - name: murmur-hook-eval
5298 version: 1.0.0
5299 runtime: hook
5300 on_overflow: drop
5301 capabilities:
5302 network:
5303 allow:
5304 - https://telemetry.example.com
5305"#,
5306 )
5307 .unwrap();
5308
5309 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
5310 assert!(manifest.artifacts[0].capabilities.is_some());
5311 }
5312
5313 #[test]
5316 fn capsule_wide_and_hook_capabilities_coexist() {
5317 let manifest = RuntimeManifest::from_yaml_str(
5318 r#"
5319name: cap
5320version: 0.0.1
5321artifacts:
5322 - name: telemetry-hook
5323 version: 1.0.0
5324 runtime: hook
5325 capabilities:
5326 network:
5327 allow:
5328 - https://telemetry.example.com
5329capabilities:
5330 network:
5331 allow:
5332 - https://api.anthropic.com
5333"#,
5334 )
5335 .unwrap();
5336
5337 assert_eq!(
5338 manifest.capabilities.unwrap().network.unwrap().allow,
5339 vec!["https://api.anthropic.com".to_string()]
5340 );
5341 assert_eq!(
5342 manifest.artifacts[0]
5343 .capabilities
5344 .as_ref()
5345 .unwrap()
5346 .network
5347 .as_ref()
5348 .unwrap()
5349 .allow,
5350 vec!["https://telemetry.example.com".to_string()]
5351 );
5352 }
5353
5354 #[test]
5358 fn hook_own_manifest_capabilities_are_not_read_into_config() {
5359 let config = parse_hook_config_from_yaml(
5360 r#"
5361name: telemetry-hook
5362version: 1.0.0
5363binding: on-inference
5364execution_mode: blocking
5365capabilities:
5366 network:
5367 allow:
5368 - https://evil.example.com
5369 filesystem:
5370 scope: /
5371"#,
5372 )
5373 .expect("a self-declared capabilities block is ignored, not an error");
5374
5375 assert_eq!(
5376 config,
5377 HookConfig {
5378 binding: HookBinding::OnInference,
5379 execution_mode: HookExecutionMode::Blocking,
5380 commit_policy: HookCommitPolicy::None,
5381 }
5382 );
5383 }
5384
5385 #[test]
5388 fn containment_classes_order_weakest_to_strongest() {
5389 assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
5390 assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
5391 assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
5392 assert_eq!(
5393 ContainmentClass::ALL.iter().copied().max(),
5394 Some(ContainmentClass::Sealed)
5395 );
5396 }
5397
5398 #[test]
5399 fn containment_class_defaults_to_advisory() {
5400 assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
5401 }
5402
5403 #[test]
5404 fn containment_class_round_trips_through_string() {
5405 for class in ContainmentClass::ALL {
5406 assert_eq!(
5407 class.to_string().parse::<ContainmentClass>(),
5408 Ok(class),
5409 "{class} must survive Display -> FromStr"
5410 );
5411 }
5412 assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
5413 assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
5414 assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
5415 }
5416
5417 #[test]
5418 fn containment_class_rejects_unknown_names() {
5419 let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
5420 assert_eq!(err.value, "paranoid");
5421 assert_eq!(
5422 err.to_string(),
5423 "must be one of: advisory, scoped, sealed; got 'paranoid'"
5424 );
5425 assert!("Sealed".parse::<ContainmentClass>().is_err());
5427 }
5428
5429 #[test]
5430 fn containment_class_serializes_as_its_wire_name() {
5431 assert_eq!(
5432 serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
5433 "\"scoped\""
5434 );
5435 assert_eq!(
5436 serde_yaml::from_str::<ContainmentClass>("sealed").unwrap(),
5437 ContainmentClass::Sealed
5438 );
5439 }
5440
5441 #[test]
5442 fn effective_floor_is_advisory_when_nothing_is_declared() {
5443 assert_eq!(
5444 effective_containment_floor(None, None, None),
5445 ContainmentClass::Advisory
5446 );
5447 }
5448
5449 #[test]
5450 fn effective_floor_takes_the_strongest_across_every_presence_combination() {
5451 use ContainmentClass::{Advisory, Scoped, Sealed};
5452
5453 assert_eq!(
5455 effective_containment_floor(Some(Scoped), None, None),
5456 Scoped
5457 );
5458 assert_eq!(
5459 effective_containment_floor(None, Some(Scoped), None),
5460 Scoped
5461 );
5462 assert_eq!(
5463 effective_containment_floor(None, None, Some(Scoped)),
5464 Scoped
5465 );
5466 assert_eq!(
5467 effective_containment_floor(Some(Sealed), None, None),
5468 Sealed
5469 );
5470 assert_eq!(
5471 effective_containment_floor(None, Some(Sealed), None),
5472 Sealed
5473 );
5474 assert_eq!(
5475 effective_containment_floor(None, None, Some(Sealed)),
5476 Sealed
5477 );
5478
5479 assert_eq!(
5482 effective_containment_floor(Some(Advisory), Some(Sealed), None),
5483 Sealed
5484 );
5485 assert_eq!(
5486 effective_containment_floor(Some(Sealed), Some(Advisory), None),
5487 Sealed
5488 );
5489 assert_eq!(
5490 effective_containment_floor(Some(Advisory), None, Some(Scoped)),
5491 Scoped
5492 );
5493 assert_eq!(
5494 effective_containment_floor(Some(Scoped), None, Some(Advisory)),
5495 Scoped
5496 );
5497 assert_eq!(
5498 effective_containment_floor(None, Some(Advisory), Some(Scoped)),
5499 Scoped
5500 );
5501 assert_eq!(
5502 effective_containment_floor(None, Some(Scoped), Some(Advisory)),
5503 Scoped
5504 );
5505
5506 assert_eq!(
5508 effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
5509 Sealed
5510 );
5511 assert_eq!(
5512 effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
5513 Sealed
5514 );
5515 assert_eq!(
5516 effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
5517 Sealed
5518 );
5519 assert_eq!(
5520 effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
5521 Scoped
5522 );
5523 assert_eq!(
5524 effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
5525 Advisory
5526 );
5527 }
5528
5529 #[test]
5530 fn manifest_parses_each_declared_containment_class() {
5531 for class in ContainmentClass::ALL {
5532 let manifest = RuntimeManifest::from_yaml_str(&format!(
5533 r#"
5534name: cap
5535version: 0.0.1
5536capabilities:
5537 containment: {class}
5538"#
5539 ))
5540 .expect("a valid containment class parses");
5541
5542 assert_eq!(
5543 manifest.capabilities.unwrap().containment,
5544 Some(class),
5545 "capabilities.containment: {class}"
5546 );
5547 }
5548 }
5549
5550 #[test]
5551 fn manifest_without_containment_key_declares_nothing() {
5552 let manifest = RuntimeManifest::from_yaml_str(
5554 r#"
5555name: cap
5556version: 0.0.1
5557"#,
5558 )
5559 .unwrap();
5560 assert!(manifest.capabilities.is_none());
5561
5562 let manifest = RuntimeManifest::from_yaml_str(
5565 r#"
5566name: cap
5567version: 0.0.1
5568capabilities:
5569 network:
5570 allow:
5571 - https://api.example.com
5572"#,
5573 )
5574 .unwrap();
5575 assert_eq!(manifest.capabilities.unwrap().containment, None);
5576 }
5577
5578 #[test]
5579 fn manifest_rejects_an_unknown_containment_class() {
5580 let err = RuntimeManifest::from_yaml_str(
5581 r#"
5582name: cap
5583version: 0.0.1
5584capabilities:
5585 containment: paranoid
5586"#,
5587 )
5588 .expect_err("an unknown containment class is rejected at parse time");
5589
5590 match err {
5591 RuntimeManifestError::InvalidCapabilities { field, message } => {
5592 assert_eq!(field, "capabilities.containment");
5593 assert_eq!(
5594 message,
5595 "must be one of: advisory, scoped, sealed; got 'paranoid'"
5596 );
5597 }
5598 other => panic!("expected InvalidCapabilities, got {other:?}"),
5599 }
5600 }
5601}