1use std::{fs, net::IpAddr, path::Path};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use url::Url;
6
7use crate::{
8 manifest_path::MANIFEST_FILENAME,
9 trace_capture::{resolve_trace_capture, TraceCapture},
10 unknown_manifest_keys::{nearest_known_key, UnknownManifestKey},
11};
12
13#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum TaskAcceptance {
18 None,
19 #[default]
20 Single,
21 Queue,
22}
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum AfterTask {
27 #[default]
28 Exit,
29 Sleep,
30}
31
32#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
33#[serde(rename_all = "snake_case")]
34pub enum ConversationMode {
35 #[default]
36 Stateless,
37 Threaded,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct LifecycleConfig {
42 pub task_acceptance: TaskAcceptance,
43 pub after_task: AfterTask,
44 pub queue_depth: usize,
45 pub input_timeout_secs: Option<u64>,
48 pub conversation_mode: ConversationMode,
52 pub max_task_reopens: u32,
58 pub shell_grace_secs: u64,
64 pub delegation_deadline_secs: u64,
78}
79
80impl LifecycleConfig {
81 pub fn can_receive_background_tasks(&self) -> bool {
89 self.task_acceptance == TaskAcceptance::Queue && self.after_task == AfterTask::Sleep
90 }
91}
92
93impl Default for LifecycleConfig {
94 fn default() -> Self {
95 Self {
96 task_acceptance: TaskAcceptance::Single,
97 after_task: AfterTask::Exit,
98 queue_depth: 1,
99 input_timeout_secs: None,
100 conversation_mode: ConversationMode::Stateless,
101 max_task_reopens: 1,
102 shell_grace_secs: 10,
103 delegation_deadline_secs: 600,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Default)]
111pub struct LifecycleOverride {
112 pub task_acceptance: Option<TaskAcceptance>,
113 pub after_task: Option<AfterTask>,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
134#[serde(rename_all = "lowercase")]
135pub enum ArtifactRuntime {
136 Tool,
137 Driver,
138 Hook,
139 Skill,
140}
141
142impl ArtifactRuntime {
143 pub fn is_llm_visible(&self) -> bool {
149 match self {
150 ArtifactRuntime::Tool => true,
151 ArtifactRuntime::Skill => true,
152 ArtifactRuntime::Driver => false,
153 ArtifactRuntime::Hook => false,
154 }
155 }
156
157 pub fn as_str(&self) -> &'static str {
158 match self {
159 ArtifactRuntime::Tool => "tool",
160 ArtifactRuntime::Driver => "driver",
161 ArtifactRuntime::Hook => "hook",
162 ArtifactRuntime::Skill => "skill",
163 }
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
172pub enum ArtifactImplementation {
173 Wasm,
174 Native,
175}
176
177pub fn read_tool_implementation(
180 artifact_manifest_path: &Path,
181) -> Result<ArtifactImplementation, RuntimeManifestError> {
182 let content =
183 fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
184 path: artifact_manifest_path.display().to_string(),
185 source,
186 })?;
187 Ok(parse_tool_implementation_from_yaml(&content))
188}
189
190pub fn parse_tool_implementation_from_yaml(yaml: &str) -> ArtifactImplementation {
193 serde_yaml::from_str::<serde_yaml::Value>(yaml)
194 .map(|v| {
195 v.as_mapping()
196 .and_then(|m| m.get(serde_yaml::Value::String("implementation".to_string())))
197 .and_then(serde_yaml::Value::as_str)
198 .map(|s| match s {
199 "native" => ArtifactImplementation::Native,
200 _ => ArtifactImplementation::Wasm,
201 })
202 .unwrap_or(ArtifactImplementation::Wasm)
203 })
204 .unwrap_or(ArtifactImplementation::Wasm)
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum HookBinding {
215 OnStage,
217 OnSessionStart,
219 OnTaskStart,
221 OnInference,
223 OnToolCall,
225 OnShell,
227 OnCompaction,
229 OnTaskEnd,
231 OnSessionEnd,
233 All,
235}
236
237impl HookBinding {
238 #[must_use]
243 pub fn as_str(&self) -> &'static str {
244 match self {
245 Self::OnStage => "on-stage",
246 Self::OnSessionStart => "on-session-start",
247 Self::OnTaskStart => "on-task-start",
248 Self::OnInference => "on-inference",
249 Self::OnToolCall => "on-tool-call",
250 Self::OnShell => "on-shell",
251 Self::OnCompaction => "on-compaction",
252 Self::OnTaskEnd => "on-task-end",
253 Self::OnSessionEnd => "on-session-end",
254 Self::All => "all",
255 }
256 }
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
264pub enum HookExecutionMode {
265 Blocking,
267 Async,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum HookCommitPolicy {
277 None,
279 ReplaceContext,
281 WriteManifests,
283 ReopenTask,
286 SeedContext,
290 Deny,
298}
299
300impl HookCommitPolicy {
301 #[must_use]
303 pub fn as_str(&self) -> &'static str {
304 match self {
305 Self::None => "none",
306 Self::ReplaceContext => "replace-context",
307 Self::WriteManifests => "write-manifests",
308 Self::ReopenTask => "reopen-task",
309 Self::SeedContext => "seed-context",
310 Self::Deny => "deny",
311 }
312 }
313}
314
315#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
325#[serde(rename_all = "snake_case")]
326pub enum HookOverflowPolicy {
327 #[default]
331 Drop,
332 Block,
335}
336
337impl HookOverflowPolicy {
338 #[must_use]
340 pub fn as_str(&self) -> &'static str {
341 match self {
342 Self::Drop => "drop",
343 Self::Block => "block",
344 }
345 }
346}
347
348#[derive(Debug, Clone, PartialEq, Eq)]
350pub struct HookConfig {
351 pub binding: HookBinding,
352 pub execution_mode: HookExecutionMode,
353 pub commit_policy: HookCommitPolicy,
354}
355
356impl Default for HookConfig {
357 fn default() -> Self {
358 Self {
359 binding: HookBinding::All,
360 execution_mode: HookExecutionMode::Blocking,
361 commit_policy: HookCommitPolicy::None,
362 }
363 }
364}
365
366#[must_use]
390pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
391 match binding {
392 HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
393 HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
394 HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
395 HookBinding::OnTaskStart => Some(HookCommitPolicy::SeedContext),
396 HookBinding::OnToolCall | HookBinding::OnShell => Some(HookCommitPolicy::Deny),
397 HookBinding::OnSessionStart
398 | HookBinding::OnInference
399 | HookBinding::OnSessionEnd
400 | HookBinding::All => None,
401 }
402}
403
404pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
406 let content =
407 fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
408 path: artifact_manifest_path.display().to_string(),
409 source,
410 })?;
411 parse_hook_config_from_yaml(&content).map_err(|msg| {
412 RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
413 })
414}
415
416pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
426 let v: serde_yaml::Value =
427 serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
428
429 let binding = v
430 .as_mapping()
431 .and_then(|m| m.get(serde_yaml::Value::String("binding".to_string())))
432 .and_then(serde_yaml::Value::as_str)
433 .map(|s| match s {
434 "on-stage" => Ok(HookBinding::OnStage),
435 "on-session-start" => Ok(HookBinding::OnSessionStart),
436 "on-task-start" => Ok(HookBinding::OnTaskStart),
437 "on-inference" => Ok(HookBinding::OnInference),
438 "on-tool-call" => Ok(HookBinding::OnToolCall),
439 "on-shell" => Ok(HookBinding::OnShell),
440 "on-compaction" => Ok(HookBinding::OnCompaction),
441 "on-task-end" => Ok(HookBinding::OnTaskEnd),
442 "on-session-end" => Ok(HookBinding::OnSessionEnd),
443 other => Err(format!(
444 "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
445 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
446 )),
447 })
448 .transpose()?
449 .unwrap_or(HookBinding::All);
450
451 let execution_mode = v
452 .as_mapping()
453 .and_then(|m| m.get(serde_yaml::Value::String("execution_mode".to_string())))
454 .and_then(serde_yaml::Value::as_str)
455 .map(|s| match s {
456 "blocking" => Ok(HookExecutionMode::Blocking),
457 "async" => Ok(HookExecutionMode::Async),
458 other => Err(format!(
459 "unknown execution_mode '{other}'; expected: blocking, async"
460 )),
461 })
462 .transpose()?
463 .unwrap_or(HookExecutionMode::Blocking);
464
465 let commit_policy = v
466 .as_mapping()
467 .and_then(|m| m.get(serde_yaml::Value::String("commit_policy".to_string())))
468 .and_then(serde_yaml::Value::as_str)
469 .map(|s| match s {
470 "none" => Ok(HookCommitPolicy::None),
471 "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
472 "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
473 "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
474 "seed-context" => Ok(HookCommitPolicy::SeedContext),
475 "deny" => Ok(HookCommitPolicy::Deny),
476 other => Err(format!(
477 "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
478 reopen-task, seed-context, deny"
479 )),
480 })
481 .transpose()?
482 .unwrap_or(HookCommitPolicy::None);
483
484 if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
486 return Err(format!(
487 "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
488 (got '{}')",
489 commit_policy.as_str()
490 ));
491 }
492 if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
493 return Err(
494 "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
495 'on-stage'"
496 .to_string(),
497 );
498 }
499 if binding == HookBinding::All && commit_policy == HookCommitPolicy::Deny {
503 return Err(
504 "commit_policy 'deny' requires an explicit binding: 'on-shell' or 'on-tool-call'; \
505 a hook with no binding: is dispatched at every event, including decision points \
506 it was not written to decide"
507 .to_string(),
508 );
509 }
510 if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
515 let honored = commit_policy_for_binding(&binding);
516 if honored.as_ref() != Some(&commit_policy) {
517 let note = if binding == HookBinding::OnInference {
518 " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
519 } else {
520 ""
521 };
522 return Err(format!(
523 "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
524 commit_policy '{}'{note}",
525 commit_policy.as_str(),
526 binding.as_str(),
527 binding.as_str(),
528 honored.as_ref().map_or("none", HookCommitPolicy::as_str)
529 ));
530 }
531 }
532
533 Ok(HookConfig {
534 binding,
535 execution_mode,
536 commit_policy,
537 })
538}
539
540#[derive(Debug, Clone, PartialEq, Eq)]
541pub struct RuntimeManifest {
542 pub name: String,
543 pub version: String,
544 pub artifacts: Vec<RuntimeArtifact>,
545 pub capabilities: Option<Capabilities>,
546 pub inference: Option<InferenceConfig>,
547 pub context: Option<ContextConfig>,
548 pub observability: Option<ObservabilityConfig>,
549 pub trace: Option<TraceConfig>,
550 pub network: Option<NetworkConfig>,
551 pub lifecycle: Option<LifecycleConfig>,
552 pub exports: Option<Exports>,
555 pub mur_version: Option<String>,
560 pub unknown_keys: Vec<UnknownManifestKey>,
570}
571
572impl RuntimeManifest {
573 pub fn effective_lifecycle(&self) -> LifecycleConfig {
574 self.lifecycle.clone().unwrap_or_default()
575 }
576}
577
578#[derive(Debug, Clone, PartialEq, Eq)]
579pub struct NetworkConfig {
580 pub internal_port: Option<u16>,
582}
583
584pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
588
589#[derive(Debug, Clone, PartialEq, Eq)]
597pub struct Exports {
598 pub files: Option<FileExport>,
601 pub peer_files: Option<PeerFilesExport>,
606}
607
608#[derive(Debug, Clone, PartialEq, Eq)]
610pub struct FileExport {
611 pub root: String,
618 pub mode: ExportMode,
622 pub max_bytes: u64,
626}
627
628pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
631
632pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
641
642pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
644
645#[derive(Debug, Clone, PartialEq, Eq)]
652pub struct PeerFilesExport {
653 pub root: String,
657 pub max_ttl_secs: Option<u64>,
664 pub max_bytes: u64,
668}
669
670impl PeerFilesExport {
671 pub fn effective_max_ttl_secs(&self) -> u64 {
677 self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
678 }
679}
680
681#[derive(Debug, Clone, PartialEq, Eq)]
689pub struct PeerFetchCapabilities {
690 pub allow: Vec<String>,
694}
695
696pub const DURATION_ACCEPTED_FORM: &str =
699 "must be a duration: an integer, optionally suffixed s/m/h/d";
700
701pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
707 let trimmed = input.trim();
708 let (digits, multiplier) = match trimmed.strip_suffix('s') {
709 Some(digits) => (digits, 1u64),
710 None => match trimmed.strip_suffix('m') {
711 Some(digits) => (digits, 60),
712 None => match trimmed.strip_suffix('h') {
713 Some(digits) => (digits, 3600),
714 None => match trimmed.strip_suffix('d') {
715 Some(digits) => (digits, 86_400),
716 None => (trimmed, 1),
717 },
718 },
719 },
720 };
721 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
724 return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
725 }
726 digits
727 .parse::<u64>()
728 .ok()
729 .and_then(|value| value.checked_mul(multiplier))
730 .ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
731}
732
733#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
734pub enum ExportMode {
735 #[serde(rename = "read-only")]
736 ReadOnly,
737}
738
739impl ExportMode {
740 pub fn as_str(self) -> &'static str {
741 match self {
742 ExportMode::ReadOnly => "read-only",
743 }
744 }
745}
746
747impl std::fmt::Display for ExportMode {
748 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
749 f.write_str(self.as_str())
750 }
751}
752
753pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
756
757pub fn parse_byte_size(input: &str) -> Result<u64, String> {
763 let trimmed = input.trim();
764 let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
765 Some(digits) => (digits, 1024u64),
766 None => match trimmed.strip_suffix("Mi") {
767 Some(digits) => (digits, 1024 * 1024),
768 None => match trimmed.strip_suffix("Gi") {
769 Some(digits) => (digits, 1024 * 1024 * 1024),
770 None => (trimmed, 1),
771 },
772 },
773 };
774 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
777 return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
778 }
779 digits
780 .parse::<u64>()
781 .ok()
782 .and_then(|value| value.checked_mul(multiplier))
783 .ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
784}
785
786#[derive(Debug, Clone, PartialEq, Eq)]
787pub struct RuntimeArtifact {
788 pub name: String,
789 pub version: String,
790 pub runtime: ArtifactRuntime,
791 pub source: Option<String>,
797 pub local_source: bool,
802 pub prompt_payload: bool,
807 pub capabilities: Option<Capabilities>,
826 pub on_overflow: HookOverflowPolicy,
831 pub config: Option<serde_yaml::Value>,
848}
849
850#[derive(Debug, Clone, PartialEq, Eq)]
851pub struct InferenceDriver {
852 pub artifact: String,
853 pub config: Option<String>,
854}
855
856#[derive(Clone, PartialEq, Eq)]
859pub enum ApiKeyReference {
860 Environment(String),
863 Literal(String),
865}
866
867impl std::fmt::Debug for ApiKeyReference {
868 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
869 match self {
870 Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
871 Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
872 }
873 }
874}
875
876#[derive(Debug, Clone, PartialEq, Eq)]
877pub struct InferenceConfig {
878 pub transport: String,
879 pub endpoint: Option<String>,
881 pub model: String,
882 pub api_key: Option<ApiKeyReference>,
883 pub driver: Option<InferenceDriver>,
885 pub command: Option<String>,
887 pub compaction: Option<CompactionConfig>,
888 pub system_prompt: Option<String>,
889 pub system_prompt_file: Option<String>,
890 pub system_prompt_artifact: Option<String>,
894 pub max_turns: u32,
897 pub max_tokens: Option<u32>,
904 pub max_session_tokens: Option<u64>,
909}
910
911#[derive(Debug, Clone, PartialEq)]
912pub struct CompactionConfig {
913 pub threshold: Option<f32>,
915 pub model: Option<String>,
917 pub system_prompt: Option<String>,
920 pub system_prompt_file: Option<String>,
924 pub dump_summaries: Option<bool>,
928}
929
930impl Eq for CompactionConfig {}
932
933pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
937
938pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
942
943#[derive(Debug, Clone, PartialEq)]
944pub struct ContextConfig {
945 pub max_tokens: Option<u32>,
947 pub record: bool,
951 pub record_store: Option<String>,
955 pub seed_budget: f32,
960 pub seed_overflow_margin: f32,
965 pub retain: Option<ContextRetainConfig>,
968}
969
970impl Eq for ContextConfig {}
972
973#[derive(Debug, Clone, PartialEq, Eq)]
974pub struct ObservabilityConfig {
975 pub otel_endpoint: Option<String>,
976 pub eval: Option<EvalConfig>,
977}
978
979#[derive(Debug, Clone, Copy, PartialEq, Eq)]
980pub struct TraceConfig {
981 pub capture: TraceCapture,
985 pub retain: Option<TraceRetainConfig>,
990}
991
992#[derive(Debug, Clone, Copy, PartialEq, Eq)]
998pub struct TraceRetainConfig {
999 pub max_sessions: Option<u32>,
1003 pub max_age_secs: Option<u64>,
1006}
1007
1008#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1014pub struct ContextRetainConfig {
1015 pub max_messages: Option<u32>,
1018 pub max_age_secs: Option<u64>,
1021}
1022
1023#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1024pub struct EvalConfig {
1025 #[serde(skip_serializing_if = "Option::is_none")]
1026 pub dataset_id: Option<String>,
1027 pub scorers: Vec<ScorerConfig>,
1028}
1029
1030#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1031#[serde(tag = "type", rename_all = "snake_case")]
1032pub enum ScorerConfig {
1033 ExitOk { name: String },
1034 MaxTurns { name: String, max: u32 },
1035 MaxTokens { name: String, max: u64 },
1036 ToolSequence { name: String, expected: Vec<String> },
1037 LlmJudge { name: String },
1038}
1039
1040#[derive(Debug, Clone, PartialEq, Eq)]
1041pub struct NetworkCapabilities {
1042 pub allow: Vec<String>,
1043 pub unix_sockets: bool,
1051}
1052
1053#[derive(Debug, Clone, PartialEq, Eq)]
1054pub struct FilesystemCapabilities {
1055 pub scope: Option<String>,
1056 pub workdir_exec: bool,
1071 pub read_only: Vec<String>,
1078}
1079
1080#[derive(Debug, Clone, PartialEq, Eq)]
1085pub struct TaskIoCapabilities {
1086 pub read: bool,
1090}
1091
1092#[derive(Debug, Clone, PartialEq, Eq)]
1097pub struct ConversationCapabilities {
1098 pub read: bool,
1101}
1102
1103#[derive(Debug, Clone, PartialEq, Eq)]
1107pub struct PlanCapabilities {
1108 pub submit: bool,
1111}
1112
1113#[derive(Debug, Clone, PartialEq, Eq)]
1114pub struct ShellCapabilities {
1115 pub allow: Vec<String>,
1116 pub strip_env: Option<Vec<String>>,
1117 pub baseline_env: Option<Vec<String>>,
1118 pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1125 pub staged_runtime: Vec<StagedRuntimeGrant>,
1131}
1132
1133#[derive(Debug, Clone, PartialEq, Eq)]
1138pub struct InterpreterRuntimeGrant {
1139 pub binary: String,
1143 pub dirs: Vec<InterpreterRuntimeDir>,
1146}
1147
1148#[derive(Debug, Clone, PartialEq, Eq)]
1150pub struct InterpreterRuntimeDir {
1151 pub path: String,
1153 pub list_dir: bool,
1158}
1159
1160#[derive(Debug, Clone, PartialEq, Eq)]
1177pub struct StagedRuntimeGrant {
1178 pub binary: String,
1182 pub source_path: String,
1186 pub pin: String,
1191}
1192
1193#[derive(Debug, Clone, PartialEq, Eq)]
1194pub struct SpawnCapabilities {
1195 pub allow: Vec<String>,
1196}
1197
1198#[derive(Debug, Clone, PartialEq, Eq, Default)]
1203pub struct EnvCapabilities {
1204 pub allow: Vec<String>,
1205}
1206
1207#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1215pub struct ResourceLimits {
1216 pub memory_bytes: Option<usize>,
1218 pub table_elements: Option<usize>,
1220 pub instances: Option<usize>,
1222 pub deadline_seconds: Option<u64>,
1224}
1225
1226#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1240pub struct ResourceCapabilities {
1241 pub max_processes: Option<u64>,
1243 pub max_open_files: Option<u64>,
1245 pub max_file_size_bytes: Option<u64>,
1247 pub cpu_seconds: Option<u64>,
1249 pub memory_bytes: Option<u64>,
1252 pub cgroup_memory_bytes: Option<u64>,
1254 pub cgroup_pids_max: Option<u64>,
1256 pub cgroup_cpu_percent: Option<u32>,
1258 pub cgroup_io_bytes_per_sec: Option<u64>,
1261 pub workdir_max_bytes: Option<u64>,
1263}
1264
1265#[derive(Debug, Clone, PartialEq, Eq, Default)]
1272pub struct StateCapabilities {
1273 pub store: Option<String>,
1281}
1282
1283#[derive(
1296 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1297)]
1298#[serde(rename_all = "snake_case")]
1299pub enum ContainmentClass {
1300 #[default]
1304 Advisory,
1305 Scoped,
1309 Sealed,
1317}
1318
1319impl ContainmentClass {
1320 pub fn as_str(self) -> &'static str {
1323 match self {
1324 Self::Advisory => "advisory",
1325 Self::Scoped => "scoped",
1326 Self::Sealed => "sealed",
1327 }
1328 }
1329
1330 pub const ALL: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1332}
1333
1334impl std::fmt::Display for ContainmentClass {
1335 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1336 f.write_str(self.as_str())
1337 }
1338}
1339
1340#[derive(Debug, Clone, PartialEq, Eq)]
1342pub struct ParseContainmentClassError {
1343 pub value: String,
1344}
1345
1346impl std::fmt::Display for ParseContainmentClassError {
1347 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1348 write!(
1349 f,
1350 "must be one of: advisory, scoped, sealed; got '{}'",
1351 self.value
1352 )
1353 }
1354}
1355
1356impl std::error::Error for ParseContainmentClassError {}
1357
1358impl std::str::FromStr for ContainmentClass {
1359 type Err = ParseContainmentClassError;
1360
1361 fn from_str(value: &str) -> Result<Self, Self::Err> {
1362 match value.trim() {
1363 "advisory" => Ok(Self::Advisory),
1364 "scoped" => Ok(Self::Scoped),
1365 "sealed" => Ok(Self::Sealed),
1366 other => Err(ParseContainmentClassError {
1367 value: other.to_string(),
1368 }),
1369 }
1370 }
1371}
1372
1373pub fn effective_containment_floor(
1379 workspace_default: Option<ContainmentClass>,
1380 manifest_declared: Option<ContainmentClass>,
1381 cli_flag: Option<ContainmentClass>,
1382) -> ContainmentClass {
1383 [workspace_default, manifest_declared, cli_flag]
1384 .into_iter()
1385 .flatten()
1386 .max()
1387 .unwrap_or_default()
1388}
1389
1390#[derive(Debug, Clone, PartialEq, Eq)]
1391pub struct Capabilities {
1392 pub network: Option<NetworkCapabilities>,
1393 pub peer_fetch: Option<PeerFetchCapabilities>,
1396 pub filesystem: Option<FilesystemCapabilities>,
1397 pub shell: Option<ShellCapabilities>,
1398 pub spawn: Option<SpawnCapabilities>,
1399 pub env: Option<EnvCapabilities>,
1400 pub limits: Option<ResourceLimits>,
1401 pub resources: Option<ResourceCapabilities>,
1402 pub state: Option<StateCapabilities>,
1405 pub task_io: Option<TaskIoCapabilities>,
1408 pub conversation: Option<ConversationCapabilities>,
1411 pub plan: Option<PlanCapabilities>,
1414 pub containment: Option<ContainmentClass>,
1418}
1419
1420#[derive(Debug, Error)]
1421pub enum RuntimeManifestError {
1422 #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
1423 NotFound(String),
1424 #[error("{0}")]
1425 YamlSyntax(String),
1426 #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
1427 MissingField { field: String },
1428 #[error(
1429 "{}: invalid artifact declaration at index {index}: {message}",
1430 MANIFEST_FILENAME
1431 )]
1432 InvalidArtifact { index: usize, message: String },
1433 #[error(
1434 "{}: invalid inference config for '{field}': {message}",
1435 MANIFEST_FILENAME
1436 )]
1437 InvalidInferenceConfig { field: String, message: String },
1438 #[error(
1439 "{}: invalid capability config for '{field}': {message}",
1440 MANIFEST_FILENAME
1441 )]
1442 InvalidCapabilities { field: String, message: String },
1443 #[error(
1444 "{}: invalid exports config for '{field}': {message}",
1445 MANIFEST_FILENAME
1446 )]
1447 InvalidExports { field: String, message: String },
1448 #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
1449 InvalidTraceConfig { field: String, message: String },
1450 #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
1451 Io {
1452 path: String,
1453 #[source]
1454 source: std::io::Error,
1455 },
1456}
1457
1458#[derive(Debug, Deserialize)]
1459struct RawRuntimeManifest {
1460 name: Option<String>,
1461 version: Option<String>,
1462 #[serde(default)]
1463 artifacts: Vec<RawArtifact>,
1464 #[serde(default)]
1465 capabilities: Option<RawCapabilities>,
1466 #[serde(default)]
1467 inference: Option<RawInferenceConfig>,
1468 #[serde(default)]
1469 context: Option<RawContextConfig>,
1470 #[serde(default)]
1471 observability: Option<RawObservabilityConfig>,
1472 #[serde(default)]
1473 trace: Option<RawTraceConfig>,
1474 #[serde(default)]
1475 network: Option<RawNetworkConfig>,
1476 #[serde(default)]
1477 lifecycle: Option<RawLifecycleConfig>,
1478 #[serde(default)]
1479 exports: Option<RawExports>,
1480 #[serde(default)]
1481 mur_version: Option<String>,
1482 #[serde(default, deserialize_with = "deserialize_present")]
1486 config: Option<serde_yaml::Value>,
1487 #[serde(default)]
1492 #[allow(dead_code)]
1493 runtime: Option<serde_yaml::Value>,
1494 #[serde(default)]
1498 #[allow(dead_code)]
1499 implementation: Option<serde_yaml::Value>,
1500 #[serde(default)]
1504 #[allow(dead_code)]
1505 execution: Option<serde_yaml::Value>,
1506 #[serde(default)]
1509 #[allow(dead_code)]
1510 requires_files: Option<serde_yaml::Value>,
1511 #[serde(flatten)]
1512 unknown: UnknownKeys,
1513}
1514
1515#[derive(Debug, Deserialize)]
1516struct RawExports {
1517 #[serde(default)]
1518 files: Option<RawFileExport>,
1519 #[serde(default)]
1520 peer_files: Option<RawPeerFilesExport>,
1521 #[serde(flatten)]
1522 unknown: UnknownKeys,
1523}
1524
1525#[derive(Debug, Deserialize)]
1526struct RawPeerFilesExport {
1527 #[serde(default)]
1528 root: Option<String>,
1529 #[serde(default)]
1533 max_ttl: Option<serde_yaml::Value>,
1534 #[serde(default)]
1535 max_bytes: Option<serde_yaml::Value>,
1536 #[serde(flatten)]
1537 unknown: UnknownKeys,
1538}
1539
1540#[derive(Debug, Deserialize)]
1541struct RawFileExport {
1542 #[serde(default)]
1543 root: Option<String>,
1544 #[serde(default)]
1545 mode: Option<String>,
1546 #[serde(default)]
1551 max_bytes: Option<serde_yaml::Value>,
1552 #[serde(flatten)]
1553 unknown: UnknownKeys,
1554}
1555
1556#[derive(Debug, Deserialize)]
1557struct RawLifecycleConfig {
1558 #[serde(default)]
1559 task_acceptance: Option<TaskAcceptance>,
1560 #[serde(default)]
1561 after_task: Option<AfterTask>,
1562 #[serde(default)]
1563 queue_depth: Option<usize>,
1564 #[serde(default)]
1565 input_timeout_secs: Option<u64>,
1566 #[serde(default)]
1567 conversation: Option<ConversationMode>,
1568 #[serde(default)]
1569 max_task_reopens: Option<u32>,
1570 #[serde(default)]
1571 shell_grace_secs: Option<u64>,
1572 #[serde(default)]
1573 delegation_deadline_secs: Option<u64>,
1574 #[serde(flatten)]
1575 unknown: UnknownKeys,
1576}
1577
1578#[derive(Debug, Deserialize)]
1579struct RawNetworkConfig {
1580 internal_port: Option<u16>,
1581 #[serde(flatten)]
1582 unknown: UnknownKeys,
1583}
1584
1585#[derive(Debug, Deserialize)]
1586struct RawContextConfig {
1587 max_tokens: Option<u32>,
1588 #[serde(default)]
1591 record: Option<String>,
1592 #[serde(default)]
1593 record_store: Option<String>,
1594 #[serde(default)]
1595 seed_budget: Option<f32>,
1596 #[serde(default)]
1597 seed_overflow_margin: Option<f32>,
1598 #[serde(default, deserialize_with = "present_yaml_value")]
1601 retain: Option<serde_yaml::Value>,
1602 #[serde(flatten)]
1603 unknown: UnknownKeys,
1604}
1605
1606#[derive(Debug, Deserialize)]
1607struct RawObservabilityConfig {
1608 #[serde(default)]
1609 otel_endpoint: Option<String>,
1610 #[serde(default)]
1611 eval: Option<RawEvalConfig>,
1612 #[serde(flatten)]
1613 unknown: UnknownKeys,
1614}
1615
1616#[derive(Debug, Deserialize)]
1617struct RawTraceConfig {
1618 #[serde(default)]
1622 capture: Option<String>,
1623 #[serde(default)]
1626 include_tool_output: Option<bool>,
1627 #[serde(default, deserialize_with = "present_yaml_value")]
1629 retain: Option<serde_yaml::Value>,
1630 #[serde(flatten)]
1631 unknown: UnknownKeys,
1632}
1633
1634#[derive(Debug, Deserialize)]
1635struct RawEvalConfig {
1636 #[serde(default)]
1637 dataset_id: Option<String>,
1638 #[serde(default)]
1639 scorers: Vec<RawScorerConfig>,
1640 #[serde(flatten)]
1641 unknown: UnknownKeys,
1642}
1643
1644#[derive(Debug, Deserialize)]
1645struct RawScorerConfig {
1646 #[serde(rename = "type")]
1647 scorer_type: String,
1648 name: Option<String>,
1649 max: Option<u64>,
1650 expected: Option<Vec<String>>,
1651 #[serde(flatten)]
1652 unknown: UnknownKeys,
1653}
1654
1655#[derive(Debug, Deserialize)]
1656struct RawArtifact {
1657 name: Option<String>,
1658 version: Option<String>,
1659 runtime: Option<String>,
1660 #[serde(default)]
1661 source: Option<String>,
1662 #[serde(default)]
1663 local_source: Option<bool>,
1664 #[serde(default)]
1665 prompt_payload: Option<bool>,
1666 #[serde(default)]
1667 capabilities: Option<RawCapabilities>,
1668 #[serde(default)]
1669 on_overflow: Option<String>,
1670 #[serde(default, deserialize_with = "deserialize_present")]
1675 config: Option<serde_yaml::Value>,
1676 #[serde(flatten)]
1677 unknown: UnknownKeys,
1678}
1679
1680fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
1686where
1687 T: Deserialize<'de>,
1688 D: serde::Deserializer<'de>,
1689{
1690 T::deserialize(deserializer).map(Some)
1691}
1692
1693#[derive(Debug, Deserialize)]
1694struct RawCapabilities {
1695 #[serde(default)]
1696 network: Option<RawNetworkCapabilities>,
1697 #[serde(default)]
1698 peer_fetch: Option<RawPeerFetchCapabilities>,
1699 #[serde(default)]
1700 filesystem: Option<RawFilesystemCapabilities>,
1701 #[serde(default)]
1702 shell: Option<RawShellCapabilities>,
1703 #[serde(default)]
1704 spawn: Option<RawSpawnCapabilities>,
1705 #[serde(default)]
1706 env: Option<RawEnvCapabilities>,
1707 #[serde(default)]
1708 limits: Option<RawResourceLimits>,
1709 #[serde(default)]
1710 resources: Option<RawResourceCapabilities>,
1711 #[serde(default)]
1712 state: Option<RawStateCapabilities>,
1713 #[serde(default)]
1714 task_io: Option<RawTaskIoCapabilities>,
1715 #[serde(default)]
1716 conversation: Option<RawConversationCapabilities>,
1717 #[serde(default)]
1718 plan: Option<RawPlanCapabilities>,
1719 #[serde(default)]
1723 containment: Option<String>,
1724 #[serde(flatten)]
1725 unknown: UnknownKeys,
1726}
1727
1728#[derive(Debug, Deserialize)]
1729struct RawTaskIoCapabilities {
1730 #[serde(default)]
1733 read: Option<bool>,
1734 #[serde(flatten)]
1735 unknown: UnknownKeys,
1736}
1737
1738#[derive(Debug, Deserialize)]
1739struct RawConversationCapabilities {
1740 #[serde(default)]
1743 read: Option<bool>,
1744 #[serde(flatten)]
1745 unknown: UnknownKeys,
1746}
1747
1748#[derive(Debug, Deserialize)]
1749struct RawPlanCapabilities {
1750 #[serde(default)]
1753 submit: Option<bool>,
1754 #[serde(flatten)]
1755 unknown: UnknownKeys,
1756}
1757
1758#[derive(Debug, Deserialize)]
1759struct RawSpawnCapabilities {
1760 #[serde(default)]
1761 allow: Vec<String>,
1762 #[serde(flatten)]
1763 unknown: UnknownKeys,
1764}
1765
1766#[derive(Debug, Deserialize)]
1767struct RawEnvCapabilities {
1768 #[serde(default)]
1769 allow: Vec<String>,
1770 #[serde(flatten)]
1771 unknown: UnknownKeys,
1772}
1773
1774#[derive(Debug, Deserialize)]
1775struct RawResourceLimits {
1776 #[serde(default)]
1777 memory_bytes: Option<usize>,
1778 #[serde(default)]
1779 table_elements: Option<usize>,
1780 #[serde(default)]
1781 instances: Option<usize>,
1782 #[serde(default)]
1783 deadline_seconds: Option<u64>,
1784 #[serde(flatten)]
1785 unknown: UnknownKeys,
1786}
1787
1788#[derive(Debug, Deserialize)]
1789struct RawStateCapabilities {
1790 #[serde(default)]
1791 store: Option<String>,
1792 #[serde(flatten)]
1793 unknown: UnknownKeys,
1794}
1795
1796#[derive(Debug, Deserialize)]
1797struct RawResourceCapabilities {
1798 #[serde(default)]
1799 max_processes: Option<u64>,
1800 #[serde(default)]
1801 max_open_files: Option<u64>,
1802 #[serde(default)]
1803 max_file_size_bytes: Option<u64>,
1804 #[serde(default)]
1805 cpu_seconds: Option<u64>,
1806 #[serde(default)]
1807 memory_bytes: Option<u64>,
1808 #[serde(default)]
1809 cgroup_memory_bytes: Option<u64>,
1810 #[serde(default)]
1811 cgroup_pids_max: Option<u64>,
1812 #[serde(default)]
1813 cgroup_cpu_percent: Option<u32>,
1814 #[serde(default)]
1815 cgroup_io_bytes_per_sec: Option<u64>,
1816 #[serde(default)]
1817 workdir_max_bytes: Option<u64>,
1818 #[serde(flatten)]
1819 unknown: UnknownKeys,
1820}
1821
1822#[derive(Debug, Deserialize)]
1823struct RawNetworkCapabilities {
1824 #[serde(default)]
1825 allow: Vec<String>,
1826 #[serde(default)]
1827 unix_sockets: bool,
1828 #[serde(flatten)]
1829 unknown: UnknownKeys,
1830}
1831
1832#[derive(Debug, Deserialize)]
1833struct RawPeerFetchCapabilities {
1834 #[serde(default)]
1835 allow: Vec<String>,
1836 #[serde(flatten)]
1837 unknown: UnknownKeys,
1838}
1839
1840#[derive(Debug, Deserialize)]
1841struct RawFilesystemCapabilities {
1842 #[serde(default)]
1843 scope: Option<String>,
1844 #[serde(default)]
1848 workdir_exec: bool,
1849 #[serde(default)]
1852 read_only: Vec<String>,
1853 #[serde(flatten)]
1854 unknown: UnknownKeys,
1855}
1856
1857#[derive(Debug, Deserialize)]
1858struct RawShellCapabilities {
1859 #[serde(default)]
1860 allow: Vec<String>,
1861 #[serde(default)]
1862 strip_env: Option<Vec<String>>,
1863 #[serde(default)]
1864 baseline_env: Option<Vec<String>>,
1865 #[serde(default)]
1866 interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
1867 #[serde(default)]
1868 staged_runtime: Vec<RawStagedRuntimeGrant>,
1869 #[serde(flatten)]
1870 unknown: UnknownKeys,
1871}
1872
1873#[derive(Debug, Deserialize)]
1874struct RawInterpreterRuntimeGrant {
1875 #[serde(default)]
1876 binary: Option<String>,
1877 #[serde(default)]
1878 dirs: Vec<RawInterpreterRuntimeDir>,
1879 #[serde(flatten)]
1880 unknown: UnknownKeys,
1881}
1882
1883#[derive(Debug, Deserialize)]
1884struct RawInterpreterRuntimeDir {
1885 #[serde(default)]
1886 path: Option<String>,
1887 #[serde(default)]
1891 list_dir: Option<bool>,
1892 #[serde(flatten)]
1893 unknown: UnknownKeys,
1894}
1895
1896#[derive(Debug, Deserialize)]
1897struct RawStagedRuntimeGrant {
1898 #[serde(default)]
1899 binary: Option<String>,
1900 #[serde(default)]
1901 source_path: Option<String>,
1902 #[serde(default)]
1906 pin: Option<String>,
1907 #[serde(flatten)]
1908 unknown: UnknownKeys,
1909}
1910
1911#[derive(Debug, Deserialize)]
1912struct RawInferenceConfig {
1913 transport: Option<String>,
1914 endpoint: Option<String>,
1915 model: Option<String>,
1916 #[serde(default)]
1917 api_key: Option<String>,
1918 #[serde(default)]
1919 command: Option<String>,
1920 #[serde(default)]
1921 system_prompt: Option<String>,
1922 #[serde(default)]
1923 system_prompt_file: Option<String>,
1924 #[serde(default)]
1925 system_prompt_artifact: Option<String>,
1926 #[serde(default)]
1927 driver: Option<RawInferenceDriver>,
1928 #[serde(default)]
1929 provider: Option<RawInferenceDriver>,
1930 #[serde(default)]
1931 compaction: Option<RawCompactionConfig>,
1932 #[serde(default)]
1933 max_turns: Option<u32>,
1934 #[serde(default)]
1937 max_task_reopens: Option<u32>,
1938 #[serde(default)]
1939 max_tokens: Option<u32>,
1940 #[serde(default)]
1941 max_session_tokens: Option<u64>,
1942 #[serde(flatten)]
1943 unknown: UnknownKeys,
1944}
1945
1946#[derive(Debug, Deserialize)]
1947struct RawCompactionConfig {
1948 threshold: Option<f32>,
1949 model: Option<String>,
1950 system_prompt: Option<String>,
1951 system_prompt_file: Option<String>,
1952 dump_summaries: Option<bool>,
1953 #[serde(flatten)]
1954 unknown: UnknownKeys,
1955}
1956
1957#[derive(Debug, Deserialize)]
1958struct RawInferenceDriver {
1959 artifact: Option<String>,
1960 #[serde(default)]
1961 config: Option<serde_yaml::Value>,
1962 #[serde(flatten)]
1963 unknown: UnknownKeys,
1964}
1965
1966type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
1973
1974trait RawBlock {
1981 const KNOWN_KEYS: &'static [&'static str];
1985
1986 fn unknown_keys(&self) -> &UnknownKeys;
1987
1988 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
1996 let _ = (path, out);
1997 }
1998}
1999
2000impl RawBlock for RawRuntimeManifest {
2001 const KNOWN_KEYS: &'static [&'static str] = &[
2002 "name",
2003 "version",
2004 "artifacts",
2005 "capabilities",
2006 "inference",
2007 "context",
2008 "observability",
2009 "trace",
2010 "network",
2011 "lifecycle",
2012 "exports",
2013 "mur_version",
2014 "config",
2015 "runtime",
2016 "implementation",
2017 "execution",
2018 "requires_files",
2019 ];
2020 fn unknown_keys(&self) -> &UnknownKeys {
2021 &self.unknown
2022 }
2023
2024 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2025 let artifacts = child_path(path, "artifacts");
2026 for (index, artifact) in self.artifacts.iter().enumerate() {
2027 collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2028 }
2029 if let Some(capabilities) = &self.capabilities {
2030 collect_block(capabilities, &child_path(path, "capabilities"), out);
2031 }
2032 if let Some(inference) = &self.inference {
2033 collect_block(inference, &child_path(path, "inference"), out);
2034 }
2035 if let Some(context) = &self.context {
2036 collect_block(context, &child_path(path, "context"), out);
2037 }
2038 if let Some(observability) = &self.observability {
2039 collect_block(observability, &child_path(path, "observability"), out);
2040 }
2041 if let Some(trace) = &self.trace {
2042 collect_block(trace, &child_path(path, "trace"), out);
2043 }
2044 if let Some(network) = &self.network {
2045 collect_block(network, &child_path(path, "network"), out);
2046 }
2047 if let Some(lifecycle) = &self.lifecycle {
2048 collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2049 }
2050 if let Some(exports) = &self.exports {
2051 collect_block(exports, &child_path(path, "exports"), out);
2052 }
2053 }
2054}
2055
2056impl RawBlock for RawExports {
2057 const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files"];
2058 fn unknown_keys(&self) -> &UnknownKeys {
2059 &self.unknown
2060 }
2061
2062 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2063 if let Some(files) = &self.files {
2064 collect_block(files, &child_path(path, "files"), out);
2065 }
2066 if let Some(peer_files) = &self.peer_files {
2067 collect_block(peer_files, &child_path(path, "peer_files"), out);
2068 }
2069 }
2070}
2071
2072impl RawBlock for RawPeerFilesExport {
2073 const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2074 fn unknown_keys(&self) -> &UnknownKeys {
2075 &self.unknown
2076 }
2077}
2078
2079impl RawBlock for RawFileExport {
2080 const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2081 fn unknown_keys(&self) -> &UnknownKeys {
2082 &self.unknown
2083 }
2084}
2085
2086impl RawBlock for RawLifecycleConfig {
2087 const KNOWN_KEYS: &'static [&'static str] = &[
2088 "task_acceptance",
2089 "after_task",
2090 "queue_depth",
2091 "input_timeout_secs",
2092 "conversation",
2093 "max_task_reopens",
2094 "shell_grace_secs",
2095 "delegation_deadline_secs",
2096 ];
2097 fn unknown_keys(&self) -> &UnknownKeys {
2098 &self.unknown
2099 }
2100}
2101
2102impl RawBlock for RawNetworkConfig {
2103 const KNOWN_KEYS: &'static [&'static str] = &["internal_port"];
2104 fn unknown_keys(&self) -> &UnknownKeys {
2105 &self.unknown
2106 }
2107}
2108
2109impl RawBlock for RawContextConfig {
2110 const KNOWN_KEYS: &'static [&'static str] = &[
2111 "max_tokens",
2112 "record",
2113 "record_store",
2114 "seed_budget",
2115 "seed_overflow_margin",
2116 "retain",
2117 ];
2118 fn unknown_keys(&self) -> &UnknownKeys {
2119 &self.unknown
2120 }
2121}
2122
2123impl RawBlock for RawObservabilityConfig {
2124 const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
2125 fn unknown_keys(&self) -> &UnknownKeys {
2126 &self.unknown
2127 }
2128
2129 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2130 if let Some(eval) = &self.eval {
2131 collect_block(eval, &child_path(path, "eval"), out);
2132 }
2133 }
2134}
2135
2136impl RawBlock for RawTraceConfig {
2137 const KNOWN_KEYS: &'static [&'static str] = &["capture", "include_tool_output", "retain"];
2138 fn unknown_keys(&self) -> &UnknownKeys {
2139 &self.unknown
2140 }
2141}
2142
2143impl RawBlock for RawEvalConfig {
2144 const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
2145 fn unknown_keys(&self) -> &UnknownKeys {
2146 &self.unknown
2147 }
2148
2149 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2150 let scorers = child_path(path, "scorers");
2151 for (index, scorer) in self.scorers.iter().enumerate() {
2152 collect_block(scorer, &format!("{scorers}[{index}]"), out);
2153 }
2154 }
2155}
2156
2157impl RawBlock for RawScorerConfig {
2158 const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
2159 fn unknown_keys(&self) -> &UnknownKeys {
2160 &self.unknown
2161 }
2162}
2163
2164impl RawBlock for RawArtifact {
2165 const KNOWN_KEYS: &'static [&'static str] = &[
2166 "name",
2167 "version",
2168 "runtime",
2169 "source",
2170 "local_source",
2171 "prompt_payload",
2172 "capabilities",
2173 "on_overflow",
2174 "config",
2175 ];
2176 fn unknown_keys(&self) -> &UnknownKeys {
2177 &self.unknown
2178 }
2179
2180 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2181 if let Some(capabilities) = &self.capabilities {
2182 collect_block(capabilities, &child_path(path, "capabilities"), out);
2183 }
2184 }
2185}
2186
2187impl RawBlock for RawCapabilities {
2188 const KNOWN_KEYS: &'static [&'static str] = &[
2189 "network",
2190 "peer_fetch",
2191 "filesystem",
2192 "shell",
2193 "spawn",
2194 "env",
2195 "limits",
2196 "resources",
2197 "state",
2198 "task_io",
2199 "conversation",
2200 "plan",
2201 "containment",
2202 ];
2203 fn unknown_keys(&self) -> &UnknownKeys {
2204 &self.unknown
2205 }
2206
2207 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2211 if let Some(network) = &self.network {
2212 collect_block(network, &child_path(path, "network"), out);
2213 }
2214 if let Some(peer_fetch) = &self.peer_fetch {
2215 collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
2216 }
2217 if let Some(filesystem) = &self.filesystem {
2218 collect_block(filesystem, &child_path(path, "filesystem"), out);
2219 }
2220 if let Some(shell) = &self.shell {
2221 collect_block(shell, &child_path(path, "shell"), out);
2222 }
2223 if let Some(spawn) = &self.spawn {
2224 collect_block(spawn, &child_path(path, "spawn"), out);
2225 }
2226 if let Some(env) = &self.env {
2227 collect_block(env, &child_path(path, "env"), out);
2228 }
2229 if let Some(limits) = &self.limits {
2230 collect_block(limits, &child_path(path, "limits"), out);
2231 }
2232 if let Some(resources) = &self.resources {
2233 collect_block(resources, &child_path(path, "resources"), out);
2234 }
2235 if let Some(state) = &self.state {
2236 collect_block(state, &child_path(path, "state"), out);
2237 }
2238 if let Some(task_io) = &self.task_io {
2239 collect_block(task_io, &child_path(path, "task_io"), out);
2240 }
2241 if let Some(conversation) = &self.conversation {
2242 collect_block(conversation, &child_path(path, "conversation"), out);
2243 }
2244 if let Some(plan) = &self.plan {
2245 collect_block(plan, &child_path(path, "plan"), out);
2246 }
2247 }
2248}
2249
2250impl RawBlock for RawTaskIoCapabilities {
2251 const KNOWN_KEYS: &'static [&'static str] = &["read"];
2252 fn unknown_keys(&self) -> &UnknownKeys {
2253 &self.unknown
2254 }
2255}
2256
2257impl RawBlock for RawConversationCapabilities {
2258 const KNOWN_KEYS: &'static [&'static str] = &["read"];
2259 fn unknown_keys(&self) -> &UnknownKeys {
2260 &self.unknown
2261 }
2262}
2263
2264impl RawBlock for RawPlanCapabilities {
2265 const KNOWN_KEYS: &'static [&'static str] = &["submit"];
2266 fn unknown_keys(&self) -> &UnknownKeys {
2267 &self.unknown
2268 }
2269}
2270
2271impl RawBlock for RawSpawnCapabilities {
2272 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2273 fn unknown_keys(&self) -> &UnknownKeys {
2274 &self.unknown
2275 }
2276}
2277
2278impl RawBlock for RawEnvCapabilities {
2279 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2280 fn unknown_keys(&self) -> &UnknownKeys {
2281 &self.unknown
2282 }
2283}
2284
2285impl RawBlock for RawResourceLimits {
2286 const KNOWN_KEYS: &'static [&'static str] = &[
2287 "memory_bytes",
2288 "table_elements",
2289 "instances",
2290 "deadline_seconds",
2291 ];
2292 fn unknown_keys(&self) -> &UnknownKeys {
2293 &self.unknown
2294 }
2295}
2296
2297impl RawBlock for RawStateCapabilities {
2298 const KNOWN_KEYS: &'static [&'static str] = &["store"];
2299 fn unknown_keys(&self) -> &UnknownKeys {
2300 &self.unknown
2301 }
2302}
2303
2304impl RawBlock for RawResourceCapabilities {
2305 const KNOWN_KEYS: &'static [&'static str] = &[
2306 "max_processes",
2307 "max_open_files",
2308 "max_file_size_bytes",
2309 "cpu_seconds",
2310 "memory_bytes",
2311 "cgroup_memory_bytes",
2312 "cgroup_pids_max",
2313 "cgroup_cpu_percent",
2314 "cgroup_io_bytes_per_sec",
2315 "workdir_max_bytes",
2316 ];
2317 fn unknown_keys(&self) -> &UnknownKeys {
2318 &self.unknown
2319 }
2320}
2321
2322impl RawBlock for RawNetworkCapabilities {
2323 const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
2324 fn unknown_keys(&self) -> &UnknownKeys {
2325 &self.unknown
2326 }
2327}
2328
2329impl RawBlock for RawPeerFetchCapabilities {
2330 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2331 fn unknown_keys(&self) -> &UnknownKeys {
2332 &self.unknown
2333 }
2334}
2335
2336impl RawBlock for RawFilesystemCapabilities {
2337 const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only"];
2338 fn unknown_keys(&self) -> &UnknownKeys {
2339 &self.unknown
2340 }
2341}
2342
2343impl RawBlock for RawShellCapabilities {
2344 const KNOWN_KEYS: &'static [&'static str] = &[
2345 "allow",
2346 "strip_env",
2347 "baseline_env",
2348 "interpreter_runtime",
2349 "staged_runtime",
2350 ];
2351 fn unknown_keys(&self) -> &UnknownKeys {
2352 &self.unknown
2353 }
2354
2355 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2356 let interpreter_runtime = child_path(path, "interpreter_runtime");
2357 for (index, grant) in self.interpreter_runtime.iter().enumerate() {
2358 collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
2359 }
2360 let staged_runtime = child_path(path, "staged_runtime");
2361 for (index, grant) in self.staged_runtime.iter().enumerate() {
2362 collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
2363 }
2364 }
2365}
2366
2367impl RawBlock for RawInterpreterRuntimeGrant {
2368 const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
2369 fn unknown_keys(&self) -> &UnknownKeys {
2370 &self.unknown
2371 }
2372
2373 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2374 let dirs = child_path(path, "dirs");
2375 for (index, dir) in self.dirs.iter().enumerate() {
2376 collect_block(dir, &format!("{dirs}[{index}]"), out);
2377 }
2378 }
2379}
2380
2381impl RawBlock for RawInterpreterRuntimeDir {
2382 const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
2383 fn unknown_keys(&self) -> &UnknownKeys {
2384 &self.unknown
2385 }
2386}
2387
2388impl RawBlock for RawStagedRuntimeGrant {
2389 const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
2390 fn unknown_keys(&self) -> &UnknownKeys {
2391 &self.unknown
2392 }
2393}
2394
2395impl RawBlock for RawInferenceConfig {
2396 const KNOWN_KEYS: &'static [&'static str] = &[
2397 "transport",
2398 "endpoint",
2399 "model",
2400 "api_key",
2401 "command",
2402 "system_prompt",
2403 "system_prompt_file",
2404 "system_prompt_artifact",
2405 "driver",
2406 "provider",
2407 "compaction",
2408 "max_turns",
2409 "max_task_reopens",
2410 "max_tokens",
2411 "max_session_tokens",
2412 ];
2413 fn unknown_keys(&self) -> &UnknownKeys {
2414 &self.unknown
2415 }
2416
2417 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2418 if let Some(driver) = &self.driver {
2419 collect_block(driver, &child_path(path, "driver"), out);
2420 }
2421 if let Some(provider) = &self.provider {
2422 collect_block(provider, &child_path(path, "provider"), out);
2423 }
2424 if let Some(compaction) = &self.compaction {
2425 collect_block(compaction, &child_path(path, "compaction"), out);
2426 }
2427 }
2428}
2429
2430impl RawBlock for RawCompactionConfig {
2431 const KNOWN_KEYS: &'static [&'static str] = &[
2432 "threshold",
2433 "model",
2434 "system_prompt",
2435 "system_prompt_file",
2436 "dump_summaries",
2437 ];
2438 fn unknown_keys(&self) -> &UnknownKeys {
2439 &self.unknown
2440 }
2441}
2442
2443impl RawBlock for RawInferenceDriver {
2444 const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
2445 fn unknown_keys(&self) -> &UnknownKeys {
2446 &self.unknown
2447 }
2448}
2449
2450fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
2457 let mut out = Vec::new();
2458 collect_block(raw, "", &mut out);
2459 out
2460}
2461
2462fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
2468 for key in block.unknown_keys().keys() {
2469 out.push(UnknownManifestKey {
2470 key: key.clone(),
2471 block_path: path.to_string(),
2472 nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
2473 });
2474 }
2475 block.walk_children(path, out);
2476}
2477
2478fn child_path(parent: &str, field: &str) -> String {
2482 if parent.is_empty() {
2483 return field.to_string();
2484 }
2485 format!("{parent}.{field}")
2486}
2487
2488pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
2495 fs::read_to_string(path).map_err(|source| {
2496 if source.kind() == std::io::ErrorKind::NotFound {
2497 return RuntimeManifestError::NotFound(path.display().to_string());
2498 }
2499
2500 RuntimeManifestError::Io {
2501 path: path.display().to_string(),
2502 source,
2503 }
2504 })
2505}
2506
2507#[must_use = "validated runtime manifest is required before run"]
2508pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
2509 let content = read_runtime_manifest_text(path)?;
2510
2511 RuntimeManifest::from_yaml_str(&content)
2512}
2513
2514#[derive(Debug, Clone, PartialEq, Eq)]
2516pub struct ReferencedEnvVariable {
2517 pub field: &'static str,
2519 pub variable: String,
2521}
2522
2523#[derive(Deserialize)]
2531struct ReferencedManifestFields {
2532 #[serde(default)]
2533 inference: Option<ReferencedInferenceFields>,
2534}
2535
2536#[derive(Deserialize)]
2537struct ReferencedInferenceFields {
2538 #[serde(default)]
2539 api_key: Option<String>,
2540}
2541
2542pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
2550 let Ok(raw) = serde_yaml::from_str::<ReferencedManifestFields>(manifest_yaml) else {
2551 return Vec::new();
2552 };
2553
2554 let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
2555 if let Some(api_key) = raw.inference.and_then(|inference| inference.api_key) {
2556 if let Some(variable) = parse_env_reference(api_key.trim()) {
2557 referenced.push(ReferencedEnvVariable {
2558 field: "inference.api_key",
2559 variable: variable.to_string(),
2560 });
2561 }
2562 }
2563
2564 referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(b.field)));
2565 referenced.dedup();
2566 referenced
2567}
2568
2569#[derive(Clone, Copy, PartialEq, Eq)]
2575enum SecretResolution {
2576 Resolve,
2578 Skip,
2581}
2582
2583impl RuntimeManifest {
2584 pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
2585 Self::parse(input, SecretResolution::Resolve)
2586 }
2587
2588 pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
2598 Self::parse(input, SecretResolution::Skip)
2599 }
2600
2601 fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
2602 let raw: RawRuntimeManifest = serde_yaml::from_str(input).map_err(|err| {
2603 if let Some(location) = err.location() {
2604 RuntimeManifestError::YamlSyntax(format!(
2605 "{}: YAML syntax error at line {}, column {}: {}",
2606 MANIFEST_FILENAME,
2607 location.line(),
2608 location.column(),
2609 err
2610 ))
2611 } else {
2612 RuntimeManifestError::YamlSyntax(format!(
2613 "{MANIFEST_FILENAME}: YAML syntax error: {err}"
2614 ))
2615 }
2616 })?;
2617
2618 let unknown_keys = collect_unknown_keys(&raw);
2621
2622 let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
2623 RuntimeManifestError::MissingField {
2624 field: "name".to_string(),
2625 }
2626 })?;
2627
2628 let version = raw
2629 .version
2630 .filter(|s| !s.trim().is_empty())
2631 .ok_or_else(|| RuntimeManifestError::MissingField {
2632 field: "version".to_string(),
2633 })?;
2634
2635 let artifacts = raw
2636 .artifacts
2637 .into_iter()
2638 .enumerate()
2639 .map(|(index, artifact)| {
2640 let name = artifact
2641 .name
2642 .filter(|s| !s.trim().is_empty())
2643 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
2644 index,
2645 message: "missing required field 'name'".to_string(),
2646 })?;
2647
2648 let source = artifact.source.filter(|s| !s.trim().is_empty());
2649
2650 let runtime = match artifact.runtime.as_deref() {
2651 None | Some("tool") => ArtifactRuntime::Tool,
2652 Some("wasm") | Some("native") => {
2653 return Err(RuntimeManifestError::InvalidArtifact {
2654 index,
2655 message: format!(
2656 "use 'runtime: tool'; implementation is declared in the \
2657 artifact's own manifest (got '{}')",
2658 artifact.runtime.as_deref().unwrap_or("")
2659 ),
2660 });
2661 }
2662 Some("driver") => ArtifactRuntime::Driver,
2663 Some("hook") => ArtifactRuntime::Hook,
2664 Some("skill") => ArtifactRuntime::Skill,
2665 Some(other) => {
2666 return Err(RuntimeManifestError::InvalidArtifact {
2667 index,
2668 message: format!(
2669 "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
2670 ),
2671 });
2672 }
2673 };
2674
2675 let local_source = artifact
2679 .local_source
2680 .unwrap_or(runtime == ArtifactRuntime::Skill);
2681 let prompt_payload = artifact
2682 .prompt_payload
2683 .unwrap_or(runtime == ArtifactRuntime::Skill);
2684
2685 if source.is_some() && !local_source {
2687 return Err(RuntimeManifestError::InvalidArtifact {
2688 index,
2689 message: format!(
2690 "artifact '{name}' declares 'source:' but does not declare \
2691 'local_source: true' (runtime: {})",
2692 runtime.as_str()
2693 ),
2694 });
2695 }
2696
2697 let version = if source.is_some() {
2700 if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
2701 eprintln!(
2702 "warning: artifact '{name}' declares both 'source:' and \
2703 'version: {explicit}'; version is ignored for local-source skills \
2704 (using 'local')"
2705 );
2706 }
2707 "local".to_string()
2708 } else {
2709 artifact
2710 .version
2711 .filter(|s| !s.trim().is_empty())
2712 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
2713 index,
2714 message: "missing required field 'version'".to_string(),
2715 })?
2716 };
2717
2718 let capabilities = match artifact.capabilities {
2724 None => None,
2725 Some(raw_caps) => {
2726 if runtime == ArtifactRuntime::Skill {
2727 return Err(RuntimeManifestError::InvalidArtifact {
2728 index,
2729 message: format!(
2730 "artifact '{name}' declares per-artifact 'capabilities:' but \
2731 has 'runtime: {}'; per-artifact capabilities are only \
2732 recognized on 'runtime: hook', 'runtime: tool', and \
2733 'runtime: driver' entries — use the capsule-wide top-level \
2734 'capabilities:' block instead",
2735 runtime.as_str()
2736 ),
2737 });
2738 }
2739 if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
2745 return Err(RuntimeManifestError::InvalidArtifact {
2746 index,
2747 message: format!(
2748 "artifact '{name}' declares 'capabilities.task_io:' but has \
2749 'runtime: {}'; the key grants the murmur:task-io/read host \
2750 import and is only recognized on 'runtime: hook' entries",
2751 runtime.as_str()
2752 ),
2753 });
2754 }
2755 if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
2759 return Err(RuntimeManifestError::InvalidArtifact {
2760 index,
2761 message: format!(
2762 "artifact '{name}' declares 'capabilities.conversation:' but \
2763 has 'runtime: {}'; the key grants the \
2764 murmur:conversation/read host import and is only recognized \
2765 on 'runtime: hook' entries",
2766 runtime.as_str()
2767 ),
2768 });
2769 }
2770 parse_capabilities(Some(raw_caps))?
2771 }
2772 };
2773
2774 let on_overflow = match artifact.on_overflow.as_deref() {
2780 None => HookOverflowPolicy::default(),
2781 Some(raw) => {
2782 if runtime != ArtifactRuntime::Hook {
2783 return Err(RuntimeManifestError::InvalidArtifact {
2784 index,
2785 message: format!(
2786 "artifact '{name}' declares 'on_overflow:' but has \
2787 'runtime: {}'; the key governs an async hook's job queue \
2788 and is only recognized on 'runtime: hook' entries",
2789 runtime.as_str()
2790 ),
2791 });
2792 }
2793 match raw {
2794 "drop" => HookOverflowPolicy::Drop,
2795 "block" => HookOverflowPolicy::Block,
2796 other => {
2797 return Err(RuntimeManifestError::InvalidArtifact {
2798 index,
2799 message: format!(
2800 "artifact '{name}' declares unknown on_overflow \
2801 '{other}'; expected: drop, block"
2802 ),
2803 });
2804 }
2805 }
2806 }
2807 };
2808
2809 if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
2816 return Err(RuntimeManifestError::InvalidArtifact {
2817 index,
2818 message: format!(
2819 "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
2820 is recognized only on 'runtime: hook', 'runtime: tool', and \
2821 'runtime: driver' entries",
2822 runtime.as_str()
2823 ),
2824 });
2825 }
2826
2827 Ok(RuntimeArtifact {
2828 name,
2829 version,
2830 runtime,
2831 source,
2832 local_source,
2833 prompt_payload,
2834 capabilities,
2835 on_overflow,
2836 config: artifact.config,
2837 })
2838 })
2839 .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
2840
2841 if raw
2845 .capabilities
2846 .as_ref()
2847 .is_some_and(|caps| caps.task_io.is_some())
2848 {
2849 return Err(RuntimeManifestError::InvalidCapabilities {
2850 field: "capabilities.task_io".to_string(),
2851 message: "is recognized only on 'runtime: hook' artifact entries, not in the \
2852 capsule-wide capabilities block — the grant is per-hook"
2853 .to_string(),
2854 });
2855 }
2856 if raw.config.is_some() {
2861 return Err(RuntimeManifestError::InvalidCapabilities {
2862 field: "config".to_string(),
2863 message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
2864 'runtime: driver' entry that reads it — there is no capsule-wide \
2865 config block"
2866 .to_string(),
2867 });
2868 }
2869 let capabilities = parse_capabilities(raw.capabilities)?;
2870 let inference = parse_inference(raw.inference, secrets)?;
2871
2872 if let Some(ref sp_art) = inference
2875 .as_ref()
2876 .and_then(|i| i.system_prompt_artifact.clone())
2877 {
2878 let matching = artifacts.iter().find(|a| &a.name == sp_art);
2879 match matching {
2880 None => {
2881 return Err(RuntimeManifestError::InvalidInferenceConfig {
2882 field: "inference.system_prompt_artifact".to_string(),
2883 message: format!("artifact '{sp_art}' is not declared in artifacts:"),
2884 });
2885 }
2886 Some(a) if !a.prompt_payload => {
2887 return Err(RuntimeManifestError::InvalidInferenceConfig {
2888 field: "inference.system_prompt_artifact".to_string(),
2889 message: format!(
2890 "artifact '{sp_art}' (runtime: {}) does not declare \
2891 'prompt_payload: true', which system_prompt_artifact requires",
2892 a.runtime.as_str()
2893 ),
2894 });
2895 }
2896 Some(_) => {} }
2898 }
2899 let context = parse_context(raw.context)?;
2900 let observability = parse_observability(raw.observability);
2901 let trace = raw
2902 .trace
2903 .map(|raw_trace| {
2904 let capture = resolve_trace_capture(
2905 raw_trace.capture.as_deref(),
2906 raw_trace.include_tool_output,
2907 )?;
2908 let retain = parse_trace_retain(raw_trace.retain)?;
2909 Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
2910 })
2911 .transpose()?;
2912 let network = raw.network.map(|n| NetworkConfig {
2913 internal_port: n.internal_port,
2914 });
2915 let lifecycle = raw.lifecycle.map(|raw_lc| {
2916 let defaults = LifecycleConfig::default();
2917 LifecycleConfig {
2918 task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
2919 after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
2920 queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
2921 input_timeout_secs: raw_lc.input_timeout_secs,
2922 conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
2923 max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
2924 shell_grace_secs: raw_lc.shell_grace_secs.unwrap_or(defaults.shell_grace_secs),
2925 delegation_deadline_secs: raw_lc
2926 .delegation_deadline_secs
2927 .unwrap_or(defaults.delegation_deadline_secs),
2928 }
2929 });
2930
2931 let exports = parse_exports(raw.exports)?;
2932
2933 Ok(Self {
2934 name,
2935 version,
2936 artifacts,
2937 capabilities,
2938 inference,
2939 context,
2940 observability,
2941 trace,
2942 network,
2943 lifecycle,
2944 exports,
2945 mur_version: raw.mur_version.filter(|s| !s.trim().is_empty()),
2946 unknown_keys,
2947 })
2948 }
2949}
2950
2951fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
2958 let Some(raw_exports) = raw else {
2959 return Ok(None);
2960 };
2961 let files = raw_exports.files.map(parse_file_export).transpose()?;
2962 let peer_files = raw_exports
2963 .peer_files
2964 .map(parse_peer_files_export)
2965 .transpose()?;
2966 Ok(Some(Exports { files, peer_files }))
2967}
2968
2969fn parse_peer_files_export(
2977 raw: RawPeerFilesExport,
2978) -> Result<PeerFilesExport, RuntimeManifestError> {
2979 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
2980 field: field.to_string(),
2981 message,
2982 };
2983
2984 let root = raw
2985 .root
2986 .map(|root| root.trim().to_string())
2987 .filter(|root| !root.is_empty())
2988 .ok_or_else(|| {
2989 invalid(
2990 "exports.peer_files.root",
2991 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
2992 )
2993 })?;
2994 check_export_root_shape("exports.peer_files.root", &root)?;
2995
2996 let max_ttl_secs = match raw.max_ttl {
2997 None => None,
2998 Some(value) => {
2999 let text = scalar_text(&value).ok_or_else(|| {
3000 invalid(
3001 "exports.peer_files.max_ttl",
3002 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
3003 )
3004 })?;
3005 let parsed = parse_duration_secs(&text)
3006 .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
3007 if parsed == 0 {
3008 return Err(invalid(
3009 "exports.peer_files.max_ttl",
3010 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
3011 ));
3012 }
3013 Some(parsed)
3014 }
3015 };
3016
3017 let max_bytes = match raw.max_bytes {
3018 None => DEFAULT_PEER_FILES_MAX_BYTES,
3019 Some(value) => {
3020 let text = scalar_text(&value).ok_or_else(|| {
3021 invalid(
3022 "exports.peer_files.max_bytes",
3023 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
3024 )
3025 })?;
3026 let parsed = parse_byte_size(&text)
3027 .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
3028 if parsed == 0 {
3029 return Err(invalid(
3030 "exports.peer_files.max_bytes",
3031 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
3032 ));
3033 }
3034 parsed
3035 }
3036 };
3037
3038 Ok(PeerFilesExport {
3039 root,
3040 max_ttl_secs,
3041 max_bytes,
3042 })
3043}
3044
3045fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_yaml::Value>, D::Error>
3053where
3054 D: serde::Deserializer<'de>,
3055{
3056 serde_yaml::Value::deserialize(deserializer).map(Some)
3057}
3058
3059fn scalar_text(value: &serde_yaml::Value) -> Option<String> {
3060 match value {
3061 serde_yaml::Value::String(text) => Some(text.clone()),
3062 serde_yaml::Value::Number(number) => Some(number.to_string()),
3063 _ => None,
3064 }
3065}
3066
3067fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
3071 for component in Path::new(root).components() {
3072 match component {
3073 std::path::Component::Normal(_) | std::path::Component::CurDir => {}
3074 std::path::Component::ParentDir => {
3075 return Err(RuntimeManifestError::InvalidExports {
3076 field: field.to_string(),
3077 message: format!(
3078 "'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}"
3079 ),
3080 });
3081 }
3082 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
3083 return Err(RuntimeManifestError::InvalidExports {
3084 field: field.to_string(),
3085 message: format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}"),
3086 });
3087 }
3088 }
3089 }
3090 Ok(())
3091}
3092
3093fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
3094 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
3095 field: field.to_string(),
3096 message,
3097 };
3098
3099 let root = raw
3100 .root
3101 .map(|root| root.trim().to_string())
3102 .filter(|root| !root.is_empty())
3103 .ok_or_else(|| {
3104 invalid(
3105 "exports.files.root",
3106 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
3107 )
3108 })?;
3109 check_export_root_shape("exports.files.root", &root)?;
3110
3111 let mode = match raw.mode.as_deref().map(str::trim) {
3112 None | Some("") => {
3113 return Err(invalid(
3114 "exports.files.mode",
3115 "is required and must be 'read-only'".to_string(),
3116 ));
3117 }
3118 Some("read-only") => ExportMode::ReadOnly,
3119 Some(other) => {
3120 return Err(invalid(
3121 "exports.files.mode",
3122 format!("'{other}' must be 'read-only'"),
3123 ));
3124 }
3125 };
3126
3127 let max_bytes = match raw.max_bytes {
3128 None => DEFAULT_EXPORT_MAX_BYTES,
3129 Some(value) => {
3130 let Some(text) = scalar_text(&value) else {
3131 return Err(invalid(
3132 "exports.files.max_bytes",
3133 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
3134 ));
3135 };
3136 let parsed = parse_byte_size(&text)
3137 .map_err(|message| invalid("exports.files.max_bytes", message))?;
3138 if parsed == 0 {
3139 return Err(invalid(
3140 "exports.files.max_bytes",
3141 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
3142 ));
3143 }
3144 parsed
3145 }
3146 };
3147
3148 Ok(FileExport {
3149 root,
3150 mode,
3151 max_bytes,
3152 })
3153}
3154
3155const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
3158
3159pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
3161
3162fn check_network_destination_shape(entry: &str) -> Result<(), String> {
3169 let trimmed = entry.trim();
3170 if trimmed.is_empty() {
3171 return Err(format!(
3172 "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3173 ));
3174 }
3175
3176 if trimmed.contains("://") {
3177 let url = Url::parse(trimmed).map_err(|error| {
3178 format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
3179 })?;
3180 let scheme = url.scheme().to_ascii_lowercase();
3181 if scheme != "http" && scheme != "https" {
3182 return Err(format!(
3183 "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
3184 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3185 ));
3186 }
3187 if url.host_str().is_none_or(str::is_empty) {
3188 return Err(format!(
3189 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3190 ));
3191 }
3192 if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
3193 return Err(format!(
3194 "'{entry}' must not include a path, query or fragment; \
3195 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3196 ));
3197 }
3198 return Ok(());
3199 }
3200
3201 if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
3202 return Err(format!(
3203 "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3204 ));
3205 }
3206 if let Some((host, port)) = trimmed.rsplit_once(':') {
3208 if !host.ends_with(']') {
3209 if host.is_empty() {
3210 return Err(format!(
3211 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3212 ));
3213 }
3214 if port.parse::<u16>().is_err() {
3215 return Err(format!(
3216 "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3217 ));
3218 }
3219 }
3220 }
3221 Ok(())
3222}
3223
3224fn parse_capabilities(
3225 raw: Option<RawCapabilities>,
3226) -> Result<Option<Capabilities>, RuntimeManifestError> {
3227 let Some(raw_caps) = raw else {
3228 return Ok(None);
3229 };
3230
3231 let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
3232 allow: raw_network.allow,
3233 unix_sockets: raw_network.unix_sockets,
3234 });
3235
3236 let peer_fetch = raw_caps
3237 .peer_fetch
3238 .map(|raw_peer_fetch| {
3239 if raw_peer_fetch.allow.is_empty() {
3240 return Err(RuntimeManifestError::InvalidCapabilities {
3241 field: "capabilities.peer_fetch.allow".to_string(),
3242 message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
3243 });
3244 }
3245 for entry in &raw_peer_fetch.allow {
3246 check_network_destination_shape(entry).map_err(|message| {
3247 RuntimeManifestError::InvalidCapabilities {
3248 field: "capabilities.peer_fetch.allow".to_string(),
3249 message,
3250 }
3251 })?;
3252 }
3253 Ok(PeerFetchCapabilities {
3254 allow: raw_peer_fetch.allow,
3255 })
3256 })
3257 .transpose()?;
3258
3259 let filesystem = raw_caps
3260 .filesystem
3261 .map(|raw_filesystem| FilesystemCapabilities {
3262 scope: raw_filesystem.scope.and_then(|scope| {
3263 let trimmed = scope.trim();
3264 (!trimmed.is_empty()).then(|| trimmed.to_string())
3265 }),
3266 workdir_exec: raw_filesystem.workdir_exec,
3271 read_only: raw_filesystem
3275 .read_only
3276 .into_iter()
3277 .filter_map(|entry| {
3278 let trimmed = entry.trim();
3279 (!trimmed.is_empty()).then(|| trimmed.to_string())
3280 })
3281 .collect(),
3282 });
3283
3284 let shell = raw_caps
3285 .shell
3286 .map(|raw_shell| {
3287 if raw_shell.allow.is_empty() {
3288 return Err(RuntimeManifestError::InvalidCapabilities {
3289 field: "capabilities.shell.allow".to_string(),
3290 message: "must contain at least one binary".to_string(),
3291 });
3292 }
3293
3294 let interpreter_runtime =
3295 parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
3296 let staged_runtime = parse_staged_runtime(
3299 &raw_shell.allow,
3300 &interpreter_runtime,
3301 raw_shell.staged_runtime,
3302 )?;
3303
3304 Ok(ShellCapabilities {
3305 allow: raw_shell.allow,
3306 strip_env: raw_shell.strip_env,
3307 baseline_env: raw_shell.baseline_env,
3308 interpreter_runtime,
3309 staged_runtime,
3310 })
3311 })
3312 .transpose()?;
3313
3314 let spawn = raw_caps.spawn.map(|raw_spawn| SpawnCapabilities {
3315 allow: raw_spawn.allow,
3316 });
3317
3318 let env = raw_caps.env.map(|raw_env| EnvCapabilities {
3319 allow: raw_env.allow,
3320 });
3321
3322 let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
3323
3324 let resources = raw_caps
3325 .resources
3326 .map(parse_resource_capabilities)
3327 .transpose()?;
3328
3329 let state = raw_caps.state.map(parse_state_capabilities);
3330
3331 let task_io = raw_caps
3332 .task_io
3333 .map(|raw_task_io| {
3334 raw_task_io
3335 .read
3336 .map(|read| TaskIoCapabilities { read })
3337 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3338 field: "capabilities.task_io.read".to_string(),
3339 message: "must be set explicitly to true or false — a capability is never \
3340 inferred"
3341 .to_string(),
3342 })
3343 })
3344 .transpose()?;
3345
3346 let conversation = raw_caps
3347 .conversation
3348 .map(|raw_conversation| {
3349 raw_conversation
3350 .read
3351 .map(|read| ConversationCapabilities { read })
3352 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3353 field: "capabilities.conversation.read".to_string(),
3354 message: "must be set explicitly to true or false — a capability is never \
3355 inferred"
3356 .to_string(),
3357 })
3358 })
3359 .transpose()?;
3360
3361 let plan = raw_caps
3362 .plan
3363 .map(|raw_plan| {
3364 raw_plan
3365 .submit
3366 .map(|submit| PlanCapabilities { submit })
3367 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3368 field: "capabilities.plan.submit".to_string(),
3369 message: "must be set explicitly to true or false — a capability is never \
3370 inferred"
3371 .to_string(),
3372 })
3373 })
3374 .transpose()?;
3375
3376 let containment = raw_caps
3377 .containment
3378 .as_deref()
3379 .map(str::trim)
3380 .filter(|value| !value.is_empty())
3381 .map(|value| {
3382 value.parse::<ContainmentClass>().map_err(|error| {
3383 RuntimeManifestError::InvalidCapabilities {
3384 field: "capabilities.containment".to_string(),
3385 message: error.to_string(),
3386 }
3387 })
3388 })
3389 .transpose()?;
3390
3391 Ok(Some(Capabilities {
3392 network,
3393 peer_fetch,
3394 filesystem,
3395 shell,
3396 spawn,
3397 env,
3398 limits,
3399 resources,
3400 state,
3401 task_io,
3402 conversation,
3403 plan,
3404 containment,
3405 }))
3406}
3407
3408fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
3418 StateCapabilities {
3419 store: raw.store.map(|store| store.trim().to_string()),
3420 }
3421}
3422
3423fn parse_interpreter_runtime(
3429 allow: &[String],
3430 raw_grants: Vec<RawInterpreterRuntimeGrant>,
3431) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
3432 let mut grants = Vec::with_capacity(raw_grants.len());
3433 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
3434 let base = format!("capabilities.shell.interpreter_runtime[{index}]");
3435
3436 let binary = raw_grant
3437 .binary
3438 .filter(|b| !b.trim().is_empty())
3439 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3440 field: format!("{base}.binary"),
3441 message: "must name a binary".to_string(),
3442 })?;
3443
3444 if !allow.contains(&binary) {
3448 return Err(RuntimeManifestError::InvalidCapabilities {
3449 field: format!("{base}.binary"),
3450 message: format!(
3451 "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
3452 only narrow filesystem access for an already-allowlisted binary, never \
3453 grant exec"
3454 ),
3455 });
3456 }
3457
3458 if raw_grant.dirs.is_empty() {
3459 return Err(RuntimeManifestError::InvalidCapabilities {
3460 field: format!("{base}.dirs"),
3461 message: "must name at least one directory".to_string(),
3462 });
3463 }
3464
3465 let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
3466 for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
3467 let dir_base = format!("{base}.dirs[{dir_index}]");
3468
3469 let path = raw_dir
3470 .path
3471 .filter(|p| !p.trim().is_empty())
3472 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3473 field: format!("{dir_base}.path"),
3474 message: "must name a host directory".to_string(),
3475 })?;
3476 if !path.starts_with('/') {
3477 return Err(RuntimeManifestError::InvalidCapabilities {
3478 field: format!("{dir_base}.path"),
3479 message: format!(
3480 "'{path}' must be an absolute host path (start with '/') — these are \
3481 filesystem paths outside the workdir"
3482 ),
3483 });
3484 }
3485
3486 let list_dir =
3487 raw_dir
3488 .list_dir
3489 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3490 field: format!("{dir_base}.list_dir"),
3491 message: format!(
3492 "'{path}' must set list_dir explicitly to true or false — \
3493 enumerability is never inferred"
3494 ),
3495 })?;
3496
3497 dirs.push(InterpreterRuntimeDir { path, list_dir });
3498 }
3499
3500 grants.push(InterpreterRuntimeGrant { binary, dirs });
3501 }
3502
3503 Ok(grants)
3504}
3505
3506fn parse_staged_runtime(
3527 allow: &[String],
3528 interpreter_runtime: &[InterpreterRuntimeGrant],
3529 raw_grants: Vec<RawStagedRuntimeGrant>,
3530) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
3531 let mut grants = Vec::with_capacity(raw_grants.len());
3532 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
3533 let base = format!("capabilities.shell.staged_runtime[{index}]");
3534
3535 let binary = raw_grant
3536 .binary
3537 .filter(|b| !b.trim().is_empty())
3538 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3539 field: format!("{base}.binary"),
3540 message: "must name a binary".to_string(),
3541 })?;
3542
3543 if !allow.contains(&binary) {
3544 return Err(RuntimeManifestError::InvalidCapabilities {
3545 field: format!("{base}.binary"),
3546 message: format!(
3547 "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
3548 stage the runtime tree behind an already-allowlisted binary, never grant exec"
3549 ),
3550 });
3551 }
3552
3553 if interpreter_runtime
3554 .iter()
3555 .any(|grant| grant.binary == binary)
3556 {
3557 return Err(RuntimeManifestError::InvalidCapabilities {
3558 field: format!("{base}.binary"),
3559 message: format!(
3560 "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
3561 are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
3562 tree into the capsule's own root, which is what makes widening the capsule's \
3563 host filesystem scope unnecessary rather than something to add to it. Remove \
3564 the interpreter_runtime grant for '{binary}'"
3565 ),
3566 });
3567 }
3568
3569 let source_path = raw_grant
3570 .source_path
3571 .filter(|p| !p.trim().is_empty())
3572 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3573 field: format!("{base}.source_path"),
3574 message: "must name the host directory holding the pinned runtime tree".to_string(),
3575 })?;
3576 if !source_path.starts_with('/') {
3577 return Err(RuntimeManifestError::InvalidCapabilities {
3578 field: format!("{base}.source_path"),
3579 message: format!(
3580 "'{source_path}' must be an absolute host path (start with '/') — \
3581 staged-runtime sources are host filesystem paths outside the workdir"
3582 ),
3583 });
3584 }
3585
3586 let pin = raw_grant
3587 .pin
3588 .map(|p| p.trim().to_string())
3589 .filter(|p| !p.is_empty())
3590 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3591 field: format!("{base}.pin"),
3592 message: format!(
3593 "'{source_path}' must declare a non-empty pin identifying which build this \
3594 tree is — it is never inferred, because it is the value a human compares \
3595 across two hosts to confirm the same runtime shipped to both"
3596 ),
3597 })?;
3598
3599 grants.push(StagedRuntimeGrant {
3600 binary,
3601 source_path,
3602 pin,
3603 });
3604 }
3605
3606 Ok(grants)
3607}
3608
3609fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
3614 let reject_zero_usize =
3615 |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
3616 match value {
3617 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
3618 field: format!("capabilities.limits.{field}"),
3619 message: "must be greater than zero".to_string(),
3620 }),
3621 _ => Ok(()),
3622 }
3623 };
3624
3625 reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
3626 reject_zero_usize(raw.table_elements, "table_elements")?;
3627 reject_zero_usize(raw.instances, "instances")?;
3628 if raw.deadline_seconds == Some(0) {
3629 return Err(RuntimeManifestError::InvalidCapabilities {
3630 field: "capabilities.limits.deadline_seconds".to_string(),
3631 message: "must be greater than zero".to_string(),
3632 });
3633 }
3634
3635 Ok(ResourceLimits {
3636 memory_bytes: raw.memory_bytes,
3637 table_elements: raw.table_elements,
3638 instances: raw.instances,
3639 deadline_seconds: raw.deadline_seconds,
3640 })
3641}
3642
3643fn parse_resource_capabilities(
3649 raw: RawResourceCapabilities,
3650) -> Result<ResourceCapabilities, RuntimeManifestError> {
3651 let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
3652 match value {
3653 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
3654 field: format!("capabilities.resources.{field}"),
3655 message: "must be greater than zero".to_string(),
3656 }),
3657 _ => Ok(()),
3658 }
3659 };
3660
3661 reject_zero(raw.max_processes, "max_processes")?;
3662 reject_zero(raw.max_open_files, "max_open_files")?;
3663 reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
3664 reject_zero(raw.cpu_seconds, "cpu_seconds")?;
3665 reject_zero(raw.memory_bytes, "memory_bytes")?;
3666 reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
3667 reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
3668 reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
3669 reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
3670 reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
3671
3672 Ok(ResourceCapabilities {
3673 max_processes: raw.max_processes,
3674 max_open_files: raw.max_open_files,
3675 max_file_size_bytes: raw.max_file_size_bytes,
3676 cpu_seconds: raw.cpu_seconds,
3677 memory_bytes: raw.memory_bytes,
3678 cgroup_memory_bytes: raw.cgroup_memory_bytes,
3679 cgroup_pids_max: raw.cgroup_pids_max,
3680 cgroup_cpu_percent: raw.cgroup_cpu_percent,
3681 cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
3682 workdir_max_bytes: raw.workdir_max_bytes,
3683 })
3684}
3685
3686fn parse_inference(
3687 raw: Option<RawInferenceConfig>,
3688 secrets: SecretResolution,
3689) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
3690 let Some(raw) = raw else {
3691 return Ok(None);
3692 };
3693
3694 let transport = raw
3695 .transport
3696 .map(|s| s.trim().to_string())
3697 .filter(|s| !s.is_empty())
3698 .unwrap_or_else(|| "http".to_string());
3699
3700 let compaction = parse_compaction(raw.compaction)?;
3701 let system_prompt = optional_trimmed_string(raw.system_prompt);
3702 let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
3703 let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
3704
3705 let prompt_sources_set = [
3706 system_prompt.is_some(),
3707 system_prompt_file.is_some(),
3708 system_prompt_artifact.is_some(),
3709 ]
3710 .iter()
3711 .filter(|&&b| b)
3712 .count();
3713 if prompt_sources_set > 1 {
3714 return Err(RuntimeManifestError::InvalidInferenceConfig {
3715 field: "inference.system_prompt".to_string(),
3716 message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
3717 may be set"
3718 .to_string(),
3719 });
3720 }
3721
3722 let max_turns = match raw.max_turns {
3723 None => 10,
3724 Some(0) => {
3725 return Err(RuntimeManifestError::InvalidInferenceConfig {
3726 field: "inference.max_turns".to_string(),
3727 message: "must be greater than 0".to_string(),
3728 });
3729 }
3730 Some(n) => n,
3731 };
3732
3733 if raw.max_task_reopens.is_some() {
3737 return Err(RuntimeManifestError::InvalidInferenceConfig {
3738 field: "inference.max_task_reopens".to_string(),
3739 message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
3740 .to_string(),
3741 });
3742 }
3743
3744 match transport.as_str() {
3745 "http" => {
3746 let endpoint = required_inference_field(raw.endpoint, "endpoint")?;
3747 validate_inference_endpoint(&endpoint)?;
3748 let model = required_inference_field(raw.model, "model")?;
3749
3750 let api_key = match (raw.api_key, secrets) {
3751 (None, _) | (Some(_), SecretResolution::Skip) => None,
3752 (Some(value), SecretResolution::Resolve) => {
3753 let trimmed = value.trim();
3754 if trimmed.is_empty() {
3755 None
3756 } else {
3757 Some(match parse_env_reference(trimmed) {
3758 Some(name) => ApiKeyReference::Environment(name.to_string()),
3759 None => ApiKeyReference::Literal(trimmed.to_string()),
3760 })
3761 }
3762 }
3763 };
3764
3765 let driver_raw = raw.driver.or(raw.provider).ok_or_else(|| {
3766 RuntimeManifestError::InvalidInferenceConfig {
3767 field: "inference.driver".to_string(),
3768 message: "missing required field".to_string(),
3769 }
3770 })?;
3771 let driver = parse_inference_driver(driver_raw)?;
3772
3773 if raw.max_tokens == Some(0) {
3777 return Err(RuntimeManifestError::InvalidInferenceConfig {
3778 field: "inference.max_tokens".to_string(),
3779 message: "must be greater than 0".to_string(),
3780 });
3781 }
3782 if raw.max_session_tokens == Some(0) {
3783 return Err(RuntimeManifestError::InvalidInferenceConfig {
3784 field: "inference.max_session_tokens".to_string(),
3785 message: "must be greater than 0".to_string(),
3786 });
3787 }
3788
3789 Ok(Some(InferenceConfig {
3790 transport,
3791 endpoint: Some(endpoint),
3792 model,
3793 api_key,
3794 driver: Some(driver),
3795 command: None,
3796 compaction,
3797 system_prompt,
3798 system_prompt_file,
3799 system_prompt_artifact,
3800 max_turns,
3801 max_tokens: raw.max_tokens,
3802 max_session_tokens: raw.max_session_tokens,
3803 }))
3804 }
3805 "process" => {
3806 if raw.driver.is_some() || raw.provider.is_some() {
3808 return Err(RuntimeManifestError::InvalidInferenceConfig {
3809 field: "inference.driver.artifact".to_string(),
3810 message: "is not valid with transport: process".to_string(),
3811 });
3812 }
3813 if raw
3814 .endpoint
3815 .as_ref()
3816 .map(|s| !s.trim().is_empty())
3817 .unwrap_or(false)
3818 {
3819 return Err(RuntimeManifestError::InvalidInferenceConfig {
3820 field: "inference.endpoint".to_string(),
3821 message: "is not valid with transport: process".to_string(),
3822 });
3823 }
3824 if raw
3825 .api_key
3826 .as_ref()
3827 .map(|s| !s.trim().is_empty())
3828 .unwrap_or(false)
3829 {
3830 return Err(RuntimeManifestError::InvalidInferenceConfig {
3831 field: "inference.api_key".to_string(),
3832 message: "is not valid with transport: process".to_string(),
3833 });
3834 }
3835 if raw.max_tokens.is_some() {
3838 return Err(RuntimeManifestError::InvalidInferenceConfig {
3839 field: "inference.max_tokens".to_string(),
3840 message: "is not valid with transport: process".to_string(),
3841 });
3842 }
3843
3844 if raw.max_session_tokens.is_some() {
3847 return Err(RuntimeManifestError::InvalidInferenceConfig {
3848 field: "inference.max_session_tokens".to_string(),
3849 message: "is not valid with transport: process".to_string(),
3850 });
3851 }
3852
3853 let command = required_inference_field(raw.command, "command")?;
3854 let model = optional_trimmed_string(raw.model).unwrap_or_default();
3859
3860 Ok(Some(InferenceConfig {
3861 transport,
3862 endpoint: None,
3863 model,
3864 api_key: None,
3865 driver: None,
3866 command: Some(command),
3867 compaction,
3868 system_prompt,
3869 system_prompt_file,
3870 system_prompt_artifact,
3871 max_turns,
3872 max_tokens: None,
3873 max_session_tokens: None,
3874 }))
3875 }
3876 other => Err(RuntimeManifestError::InvalidInferenceConfig {
3877 field: "inference.transport".to_string(),
3878 message: format!("unknown value '{other}'"),
3879 }),
3880 }
3881}
3882
3883fn parse_context(
3884 raw: Option<RawContextConfig>,
3885) -> Result<Option<ContextConfig>, RuntimeManifestError> {
3886 let Some(raw) = raw else {
3887 return Ok(None);
3888 };
3889
3890 if let Some(max_tokens) = raw.max_tokens {
3891 if max_tokens == 0 {
3892 return Err(RuntimeManifestError::InvalidInferenceConfig {
3893 field: "context.max_tokens".to_string(),
3894 message: "must be greater than 0".to_string(),
3895 });
3896 }
3897 }
3898
3899 let fraction = |field: &str, value: Option<f32>, default: f32| match value {
3900 None => Ok(default),
3901 Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
3902 Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
3903 field: field.to_string(),
3904 message: "must be between 0.0 and 1.0".to_string(),
3905 }),
3906 };
3907
3908 let record = match raw.record.as_deref().map(str::trim) {
3909 None => true,
3910 Some("on") => true,
3911 Some("off") => false,
3912 Some(other) => {
3913 return Err(RuntimeManifestError::InvalidInferenceConfig {
3914 field: "context.record".to_string(),
3915 message: format!("unknown value '{other}'; expected: on, off"),
3916 })
3917 }
3918 };
3919
3920 Ok(Some(ContextConfig {
3921 max_tokens: raw.max_tokens,
3922 record,
3923 record_store: raw.record_store.map(|store| store.trim().to_string()),
3924 seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
3925 seed_overflow_margin: fraction(
3926 "context.seed_overflow_margin",
3927 raw.seed_overflow_margin,
3928 DEFAULT_SEED_OVERFLOW_MARGIN,
3929 )?,
3930 retain: parse_context_retain(raw.retain)?,
3931 }))
3932}
3933
3934const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
3936
3937const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
3939
3940const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
3943 keep everything, which is what no policy means";
3944
3945fn parse_retain_block(
3951 block: &str,
3952 count_key: &str,
3953 accepted: &[&str],
3954 raw: serde_yaml::Value,
3955 invalid: &dyn Fn(String, String) -> RuntimeManifestError,
3956) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
3957 let mapping = match raw {
3958 serde_yaml::Value::Mapping(mapping) => mapping,
3959 serde_yaml::Value::Null => {
3961 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
3962 }
3963 other => {
3964 return Err(invalid(
3965 block.to_string(),
3966 format!(
3967 "must be a block of keys ({}), got '{other:?}'",
3968 accepted.join(", ")
3969 ),
3970 ))
3971 }
3972 };
3973 if mapping.is_empty() {
3974 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
3975 }
3976
3977 let mut count: Option<u32> = None;
3978 let mut max_age_secs: Option<u64> = None;
3979 for (key, value) in &mapping {
3980 let key = key.as_str().unwrap_or_default();
3981 let field = format!("{block}.{key}");
3982 if key == count_key {
3983 let parsed = value
3984 .as_u64()
3985 .and_then(|n| u32::try_from(n).ok())
3986 .ok_or_else(|| {
3987 invalid(
3988 field.clone(),
3989 "must be a whole number of at least 1".to_string(),
3990 )
3991 })?;
3992 if parsed == 0 {
3993 return Err(invalid(
3994 field,
3995 "must be at least 1 — a limit of zero would delete what retention exists \
3996 to keep; remove the key to leave it unbounded"
3997 .to_string(),
3998 ));
3999 }
4000 count = Some(parsed);
4001 } else if key == "max_age" {
4002 let text = scalar_text(value).ok_or_else(|| {
4003 invalid(
4004 field.clone(),
4005 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
4006 )
4007 })?;
4008 let parsed =
4009 parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
4010 if parsed == 0 {
4011 return Err(invalid(
4012 field,
4013 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
4014 ));
4015 }
4016 max_age_secs = Some(parsed);
4017 } else {
4018 return Err(invalid(
4019 block.to_string(),
4020 format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
4021 ));
4022 }
4023 }
4024 Ok((count, max_age_secs))
4025}
4026
4027fn parse_trace_retain(
4028 raw: Option<serde_yaml::Value>,
4029) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
4030 let Some(raw) = raw else {
4031 return Ok(None);
4032 };
4033 let (max_sessions, max_age_secs) = parse_retain_block(
4034 "trace.retain",
4035 "max_sessions",
4036 &TRACE_RETAIN_KEYS,
4037 raw,
4038 &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
4039 )?;
4040 Ok(Some(TraceRetainConfig {
4041 max_sessions,
4042 max_age_secs,
4043 }))
4044}
4045
4046fn parse_context_retain(
4047 raw: Option<serde_yaml::Value>,
4048) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
4049 let Some(raw) = raw else {
4050 return Ok(None);
4051 };
4052 let (max_messages, max_age_secs) = parse_retain_block(
4053 "context.retain",
4054 "max_messages",
4055 &CONTEXT_RETAIN_KEYS,
4056 raw,
4057 &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
4058 )?;
4059 Ok(Some(ContextRetainConfig {
4060 max_messages,
4061 max_age_secs,
4062 }))
4063}
4064
4065fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
4066 raw.map(|raw| ObservabilityConfig {
4067 otel_endpoint: raw
4068 .otel_endpoint
4069 .map(|value| value.trim().to_string())
4070 .filter(|value| !value.is_empty()),
4071 eval: raw.eval.map(parse_eval_config),
4072 })
4073}
4074
4075fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
4076 let scorers = raw
4077 .scorers
4078 .into_iter()
4079 .filter_map(|raw| {
4080 let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
4081 match raw.scorer_type.as_str() {
4082 "exit_ok" => Some(ScorerConfig::ExitOk { name }),
4083 "max_turns" => Some(ScorerConfig::MaxTurns {
4084 name,
4085 max: raw.max.unwrap_or(10) as u32,
4086 }),
4087 "max_tokens" => Some(ScorerConfig::MaxTokens {
4088 name,
4089 max: raw.max.unwrap_or(100_000),
4090 }),
4091 "tool_sequence" => Some(ScorerConfig::ToolSequence {
4092 name,
4093 expected: raw.expected.unwrap_or_default(),
4094 }),
4095 "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
4096 other => {
4097 eprintln!("[murmur-artifact] unknown scorer type '{other}' — skipping");
4098 None
4099 }
4100 }
4101 })
4102 .collect();
4103
4104 EvalConfig {
4105 dataset_id: raw.dataset_id,
4106 scorers,
4107 }
4108}
4109
4110fn parse_compaction(
4111 raw: Option<RawCompactionConfig>,
4112) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
4113 let Some(raw) = raw else {
4114 return Ok(None);
4115 };
4116
4117 if let Some(threshold) = raw.threshold {
4118 if threshold <= 0.0 || threshold > 1.0 {
4119 return Err(RuntimeManifestError::InvalidInferenceConfig {
4120 field: "inference.compaction.threshold".to_string(),
4121 message: format!("must be in (0.0, 1.0] but got {threshold}"),
4122 });
4123 }
4124 }
4125
4126 if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
4127 return Err(RuntimeManifestError::InvalidInferenceConfig {
4128 field: "inference.compaction.system_prompt".to_string(),
4129 message: "at most one of inference.compaction.system_prompt, \
4130 inference.compaction.system_prompt_file may be set"
4131 .to_string(),
4132 });
4133 }
4134
4135 Ok(Some(CompactionConfig {
4136 threshold: raw.threshold,
4137 model: raw.model,
4138 system_prompt: raw.system_prompt,
4139 system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
4140 dump_summaries: raw.dump_summaries,
4143 }))
4144}
4145
4146fn optional_trimmed_string(value: Option<String>) -> Option<String> {
4147 value
4148 .map(|s| s.trim().to_string())
4149 .filter(|s| !s.is_empty())
4150}
4151
4152fn required_inference_field(
4153 value: Option<String>,
4154 key: &str,
4155) -> Result<String, RuntimeManifestError> {
4156 value
4157 .map(|value| value.trim().to_string())
4158 .filter(|value| !value.is_empty())
4159 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
4160 field: format!("inference.{key}"),
4161 message: "missing required field".to_string(),
4162 })
4163}
4164
4165fn validate_inference_endpoint(endpoint: &str) -> Result<(), RuntimeManifestError> {
4173 let url =
4174 Url::parse(endpoint).map_err(|source| RuntimeManifestError::InvalidInferenceConfig {
4175 field: "inference.endpoint".to_string(),
4176 message: format!("failed to parse '{endpoint}' as a URL: {source}"),
4177 })?;
4178
4179 match url.scheme() {
4180 "https" => Ok(()),
4181 "http" => {
4182 let host = url.host_str().unwrap_or("");
4183 let is_loopback = host == "localhost"
4184 || host
4185 .parse::<IpAddr>()
4186 .map(|ip| ip.is_loopback())
4187 .unwrap_or(false);
4188
4189 if is_loopback {
4190 Ok(())
4191 } else {
4192 Err(RuntimeManifestError::InvalidInferenceConfig {
4193 field: "inference.endpoint".to_string(),
4194 message: format!(
4195 "'{endpoint}' uses plain http:// with non-loopback host '{host}' — \
4196 use https:// for remote endpoints, or http://localhost or \
4197 a loopback IP literal for local inference"
4198 ),
4199 })
4200 }
4201 }
4202 other => Err(RuntimeManifestError::InvalidInferenceConfig {
4203 field: "inference.endpoint".to_string(),
4204 message: format!(
4205 "unsupported scheme '{other}' in '{endpoint}' — expected http or https"
4206 ),
4207 }),
4208 }
4209}
4210
4211fn parse_inference_driver(
4212 raw: RawInferenceDriver,
4213) -> Result<InferenceDriver, RuntimeManifestError> {
4214 let artifact = raw
4215 .artifact
4216 .map(|value| value.trim().to_string())
4217 .filter(|value| !value.is_empty())
4218 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
4219 field: "inference.driver.artifact".to_string(),
4220 message: "missing required field".to_string(),
4221 })?;
4222
4223 let config = parse_inference_driver_config(raw.config)?;
4224
4225 Ok(InferenceDriver { artifact, config })
4226}
4227
4228fn parse_inference_driver_config(
4229 raw: Option<serde_yaml::Value>,
4230) -> Result<Option<String>, RuntimeManifestError> {
4231 let Some(raw) = raw else {
4232 return Ok(None);
4233 };
4234
4235 if matches!(raw, serde_yaml::Value::Null) {
4236 return Ok(None);
4237 }
4238
4239 if !matches!(raw, serde_yaml::Value::Mapping(_)) {
4240 return Err(RuntimeManifestError::InvalidInferenceConfig {
4241 field: "inference.driver.config".to_string(),
4242 message: "expected mapping/object".to_string(),
4243 });
4244 }
4245
4246 let value =
4247 serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
4248 field: "inference.driver.config".to_string(),
4249 message: format!("failed to convert to JSON: {err}"),
4250 })?;
4251
4252 serde_json::to_string(&value).map(Some).map_err(|err| {
4253 RuntimeManifestError::InvalidInferenceConfig {
4254 field: "inference.driver.config".to_string(),
4255 message: format!("failed to encode JSON: {err}"),
4256 }
4257 })
4258}
4259
4260fn parse_env_reference(value: &str) -> Option<&str> {
4261 if !value.starts_with("${") || !value.ends_with('}') {
4262 return None;
4263 }
4264
4265 let variable = &value[2..value.len() - 1];
4266 is_valid_env_variable(variable).then_some(variable)
4267}
4268
4269fn is_valid_env_variable(value: &str) -> bool {
4270 let mut chars = value.chars();
4271 let Some(first) = chars.next() else {
4272 return false;
4273 };
4274
4275 if !(first == '_' || first.is_ascii_uppercase()) {
4276 return false;
4277 }
4278
4279 chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
4280}
4281
4282#[cfg(test)]
4283mod tests {
4284 use super::*;
4285 use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
4286
4287 fn manifest_with_trace(block: &str) -> String {
4288 format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
4289 }
4290
4291 #[test]
4294 fn absent_trace_block_stays_absent_and_defaults_to_meta() {
4295 let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
4296 assert_eq!(manifest.trace, None);
4297 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
4298 }
4299
4300 #[test]
4301 fn each_capture_mode_parses_from_the_trace_block() {
4302 for (yaml, expected) in [
4303 ("none", TraceCapture::None),
4304 ("meta", TraceCapture::Meta),
4305 ("content", TraceCapture::Content),
4306 ] {
4307 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
4308 " capture: {yaml}\n"
4309 )))
4310 .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
4311 assert_eq!(
4312 manifest.trace,
4313 Some(TraceConfig {
4314 capture: expected,
4315 retain: None
4316 })
4317 );
4318 }
4319 }
4320
4321 #[test]
4327 fn no_retain_block_anywhere_parses_as_no_policy() {
4328 let manifest = RuntimeManifest::from_yaml_str(
4329 "name: cap\nversion: 0.0.1\ntrace:\n capture: content\ncontext:\n record: on\n",
4330 )
4331 .unwrap();
4332 assert_eq!(manifest.trace.unwrap().retain, None);
4333 assert_eq!(manifest.context.unwrap().retain, None);
4334 }
4335
4336 #[test]
4337 fn trace_retain_parses_both_keys_and_the_day_suffix() {
4338 let manifest = RuntimeManifest::from_yaml_str(
4339 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 50\n max_age: 14d\n",
4340 )
4341 .unwrap();
4342 assert_eq!(
4343 manifest.trace.unwrap().retain,
4344 Some(TraceRetainConfig {
4345 max_sessions: Some(50),
4346 max_age_secs: Some(14 * 86_400),
4347 })
4348 );
4349 }
4350
4351 #[test]
4352 fn context_retain_parses_both_keys_and_either_alone() {
4353 let both = RuntimeManifest::from_yaml_str(
4354 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n max_messages: 2000\n max_age: 90d\n",
4355 )
4356 .unwrap();
4357 assert_eq!(
4358 both.context.unwrap().retain,
4359 Some(ContextRetainConfig {
4360 max_messages: Some(2000),
4361 max_age_secs: Some(90 * 86_400),
4362 })
4363 );
4364
4365 let count_only = RuntimeManifest::from_yaml_str(
4366 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 10\n",
4367 )
4368 .unwrap();
4369 assert_eq!(
4370 count_only.context.unwrap().retain,
4371 Some(ContextRetainConfig {
4372 max_messages: Some(10),
4373 max_age_secs: None,
4374 })
4375 );
4376 }
4377
4378 #[test]
4381 fn an_empty_retain_block_is_refused_naming_the_block() {
4382 for (yaml, expect_trace) in [
4383 (
4384 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain: {}\n",
4385 true,
4386 ),
4387 (
4388 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n",
4389 true,
4390 ),
4391 (
4392 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain: {}\n",
4393 false,
4394 ),
4395 (
4396 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n",
4397 false,
4398 ),
4399 ] {
4400 let err = RuntimeManifest::from_yaml_str(yaml)
4401 .expect_err("an empty retain block must be refused");
4402 let (field, message) = match (&err, expect_trace) {
4403 (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
4404 (field, message)
4405 }
4406 (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
4407 (field, message)
4408 }
4409 _ => panic!("wrong variant for {yaml:?}: {err:?}"),
4410 };
4411 assert_eq!(
4412 field,
4413 if expect_trace {
4414 "trace.retain"
4415 } else {
4416 "context.retain"
4417 }
4418 );
4419 assert!(message.contains("at least one key"), "{message}");
4420 }
4421 }
4422
4423 #[test]
4426 fn a_zero_retain_limit_is_refused_naming_the_key() {
4427 let trace = RuntimeManifest::from_yaml_str(
4428 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 0\n",
4429 )
4430 .expect_err("max_sessions: 0 must be refused");
4431 assert!(
4432 matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
4433 if field == "trace.retain.max_sessions"),
4434 "{trace:?}"
4435 );
4436
4437 let context = RuntimeManifest::from_yaml_str(
4438 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 0\n",
4439 )
4440 .expect_err("max_messages: 0 must be refused");
4441 assert!(
4442 matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
4443 if field == "context.retain.max_messages"),
4444 "{context:?}"
4445 );
4446
4447 let age = RuntimeManifest::from_yaml_str(
4448 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_age: 0\n",
4449 )
4450 .expect_err("max_age: 0 must be refused");
4451 assert!(
4452 matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
4453 if field == "trace.retain.max_age"),
4454 "{age:?}"
4455 );
4456 }
4457
4458 #[test]
4459 fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
4460 let err = RuntimeManifest::from_yaml_str(
4461 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_traces: 3\n",
4462 )
4463 .expect_err("an unknown key must be refused");
4464 match err {
4465 RuntimeManifestError::InvalidTraceConfig { field, message } => {
4466 assert_eq!(field, "trace.retain");
4467 assert!(message.contains("max_traces"), "{message}");
4468 assert!(message.contains("max_sessions"), "{message}");
4469 }
4470 other => panic!("expected InvalidTraceConfig, got {other:?}"),
4471 }
4472 }
4473
4474 #[test]
4475 fn an_unparseable_retain_duration_reports_the_accepted_form() {
4476 let err = RuntimeManifest::from_yaml_str(
4477 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_age: 5 weeks\n",
4478 )
4479 .expect_err("'5 weeks' must be refused");
4480 match err {
4481 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
4482 assert_eq!(field, "context.retain.max_age");
4483 assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
4484 }
4485 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
4486 }
4487 }
4488
4489 #[test]
4491 fn include_tool_output_still_resolves_to_content_and_meta() {
4492 let opted_in =
4493 RuntimeManifest::from_yaml_str(&manifest_with_trace(" include_tool_output: true\n"))
4494 .unwrap();
4495 assert_eq!(
4496 opted_in.trace,
4497 Some(TraceConfig {
4498 capture: TraceCapture::Content,
4499 retain: None
4500 })
4501 );
4502
4503 let opted_out =
4504 RuntimeManifest::from_yaml_str(&manifest_with_trace(" include_tool_output: false\n"))
4505 .unwrap();
4506 assert_eq!(
4507 opted_out.trace,
4508 Some(TraceConfig {
4509 capture: TraceCapture::Meta,
4510 retain: None
4511 })
4512 );
4513 }
4514
4515 #[test]
4516 fn setting_both_capture_keys_is_refused_naming_both() {
4517 let err = RuntimeManifest::from_yaml_str(&manifest_with_trace(
4518 " capture: content\n include_tool_output: true\n",
4519 ))
4520 .expect_err("both keys must be refused even when they agree");
4521 assert!(matches!(
4522 err,
4523 RuntimeManifestError::InvalidTraceConfig { .. }
4524 ));
4525 let rendered = err.to_string();
4526 assert!(rendered.contains("trace.capture"), "{rendered}");
4527 assert!(rendered.contains("trace.include_tool_output"), "{rendered}");
4528 }
4529
4530 #[test]
4531 fn unparseable_capture_value_names_the_field_and_accepted_values() {
4532 let err = RuntimeManifest::from_yaml_str(&manifest_with_trace(" capture: verbose\n"))
4533 .expect_err("'verbose' is not a capture mode");
4534 let rendered = err.to_string();
4535 assert!(rendered.contains("trace.capture"), "{rendered}");
4536 for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
4537 assert!(rendered.contains(accepted), "{rendered}");
4538 }
4539 }
4540
4541 #[test]
4542 fn resources_block_is_optional_and_absent_stays_absent() {
4543 let manifest = RuntimeManifest::from_yaml_str(
4544 r#"
4545name: cap
4546version: 0.0.1
4547capabilities:
4548 shell:
4549 allow: [bash]
4550"#,
4551 )
4552 .unwrap();
4553
4554 let caps = manifest.capabilities.expect("shell block must parse");
4558 assert_eq!(caps.resources, None);
4559 }
4560
4561 #[test]
4562 fn resources_fields_parse_and_each_stays_independently_optional() {
4563 let manifest = RuntimeManifest::from_yaml_str(
4564 r#"
4565name: cap
4566version: 0.0.1
4567capabilities:
4568 resources:
4569 max_processes: 32
4570 max_open_files: 16
4571 max_file_size_bytes: 1048576
4572 cpu_seconds: 30
4573 memory_bytes: 268435456
4574 cgroup_memory_bytes: 536870912
4575 cgroup_pids_max: 64
4576 cgroup_cpu_percent: 50
4577 cgroup_io_bytes_per_sec: 1048576
4578 workdir_max_bytes: 2097152
4579"#,
4580 )
4581 .unwrap();
4582
4583 let resources = manifest
4584 .capabilities
4585 .and_then(|caps| caps.resources)
4586 .expect("resources block must parse");
4587 assert_eq!(resources.max_processes, Some(32));
4588 assert_eq!(resources.max_open_files, Some(16));
4589 assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
4590 assert_eq!(resources.cpu_seconds, Some(30));
4591 assert_eq!(resources.memory_bytes, Some(268_435_456));
4592 assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
4593 assert_eq!(resources.cgroup_pids_max, Some(64));
4594 assert_eq!(resources.cgroup_cpu_percent, Some(50));
4595 assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
4596 assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
4597 }
4598
4599 #[test]
4600 fn state_block_is_optional_and_absent_stays_absent() {
4601 let manifest = RuntimeManifest::from_yaml_str(
4602 r#"
4603name: cap
4604version: 0.0.1
4605artifacts:
4606 - name: notes-tool
4607 version: 1.0.0
4608 runtime: tool
4609 capabilities:
4610 filesystem:
4611 scope: cache
4612"#,
4613 )
4614 .unwrap();
4615
4616 let caps = manifest.artifacts[0]
4618 .capabilities
4619 .as_ref()
4620 .expect("filesystem block must parse");
4621 assert_eq!(caps.state, None);
4622 }
4623
4624 #[test]
4628 fn state_block_declared_empty_grants_the_capsule_named_store() {
4629 let manifest = RuntimeManifest::from_yaml_str(
4630 r#"
4631name: cap
4632version: 0.0.1
4633artifacts:
4634 - name: notes-tool
4635 version: 1.0.0
4636 runtime: tool
4637 capabilities:
4638 state: {}
4639"#,
4640 )
4641 .unwrap();
4642
4643 let state = manifest.artifacts[0]
4644 .capabilities
4645 .as_ref()
4646 .and_then(|caps| caps.state.as_ref())
4647 .expect("state block must parse");
4648 assert_eq!(state.store, None);
4649 }
4650
4651 #[test]
4652 fn state_store_name_parses_and_stays_independently_optional() {
4653 let manifest = RuntimeManifest::from_yaml_str(
4654 r#"
4655name: cap
4656version: 0.0.1
4657artifacts:
4658 - name: notes-tool
4659 version: 1.0.0
4660 runtime: tool
4661 capabilities:
4662 state:
4663 store: " shey "
4664 network:
4665 allow: [https://api.example.com]
4666"#,
4667 )
4668 .unwrap();
4669
4670 let caps = manifest.artifacts[0]
4671 .capabilities
4672 .as_ref()
4673 .expect("capabilities must parse");
4674 assert_eq!(
4677 caps.state.as_ref().and_then(|state| state.store.as_deref()),
4678 Some("shey")
4679 );
4680 assert_eq!(
4682 caps.network
4683 .as_ref()
4684 .map(|network| network.allow.as_slice()),
4685 Some(["https://api.example.com".to_string()].as_slice())
4686 );
4687 assert_eq!(caps.filesystem, None);
4688 }
4689
4690 #[test]
4694 fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
4695 let manifest = RuntimeManifest::from_yaml_str(
4696 r#"
4697name: cap
4698version: 0.0.1
4699artifacts:
4700 - name: notes-tool
4701 version: 1.0.0
4702 runtime: tool
4703 capabilities:
4704 state:
4705 store: ""
4706"#,
4707 )
4708 .unwrap();
4709
4710 assert_eq!(
4711 manifest.artifacts[0]
4712 .capabilities
4713 .as_ref()
4714 .and_then(|caps| caps.state.as_ref())
4715 .and_then(|state| state.store.as_deref()),
4716 Some("")
4717 );
4718 }
4719
4720 #[test]
4721 fn resources_partial_block_leaves_undeclared_fields_none() {
4722 let manifest = RuntimeManifest::from_yaml_str(
4723 r#"
4724name: cap
4725version: 0.0.1
4726capabilities:
4727 resources:
4728 max_open_files: 16
4729"#,
4730 )
4731 .unwrap();
4732
4733 let resources = manifest
4734 .capabilities
4735 .and_then(|caps| caps.resources)
4736 .expect("resources block must parse");
4737 assert_eq!(resources.max_open_files, Some(16));
4738 assert_eq!(resources.max_processes, None);
4739 assert_eq!(resources.workdir_max_bytes, None);
4740 }
4741
4742 #[test]
4746 fn resources_zero_is_rejected_on_every_field() {
4747 for field in [
4748 "max_processes",
4749 "max_open_files",
4750 "max_file_size_bytes",
4751 "cpu_seconds",
4752 "memory_bytes",
4753 "cgroup_memory_bytes",
4754 "cgroup_pids_max",
4755 "cgroup_cpu_percent",
4756 "cgroup_io_bytes_per_sec",
4757 "workdir_max_bytes",
4758 ] {
4759 let yaml =
4760 format!("name: cap\nversion: 0.0.1\ncapabilities:\n resources:\n {field}: 0\n");
4761 let error =
4762 RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
4763
4764 match error {
4765 RuntimeManifestError::InvalidCapabilities {
4766 field: reported,
4767 message,
4768 } => {
4769 assert_eq!(reported, format!("capabilities.resources.{field}"));
4770 assert_eq!(message, "must be greater than zero");
4771 }
4772 other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
4773 }
4774 }
4775 }
4776
4777 #[test]
4778 fn artifact_runtime_defaults_to_tool() {
4779 let manifest = RuntimeManifest::from_yaml_str(
4780 r#"
4781name: cap
4782version: 0.0.1
4783artifacts:
4784 - name: echo
4785 version: 1.2.3
4786"#,
4787 )
4788 .unwrap();
4789
4790 assert_eq!(manifest.artifacts.len(), 1);
4791 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
4792 }
4793
4794 #[test]
4795 fn artifact_runtime_tool_explicit() {
4796 let manifest = RuntimeManifest::from_yaml_str(
4797 r#"
4798name: cap
4799version: 0.0.1
4800artifacts:
4801 - name: echo
4802 version: 1.2.3
4803 runtime: tool
4804"#,
4805 )
4806 .unwrap();
4807
4808 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
4809 }
4810
4811 #[test]
4812 fn artifact_runtime_wasm_is_error() {
4813 let err = RuntimeManifest::from_yaml_str(
4814 r#"
4815name: cap
4816version: 0.0.1
4817artifacts:
4818 - name: echo
4819 version: 1.2.3
4820 runtime: wasm
4821"#,
4822 )
4823 .unwrap_err();
4824
4825 let msg = err.to_string();
4826 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
4827 assert!(msg.contains("wasm"), "error was: {msg}");
4828 }
4829
4830 #[test]
4831 fn artifact_runtime_native_is_error() {
4832 let err = RuntimeManifest::from_yaml_str(
4833 r#"
4834name: cap
4835version: 0.0.1
4836artifacts:
4837 - name: echo
4838 version: 1.2.3
4839 runtime: native
4840"#,
4841 )
4842 .unwrap_err();
4843
4844 let msg = err.to_string();
4845 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
4846 assert!(msg.contains("native"), "error was: {msg}");
4847 }
4848
4849 #[test]
4850 fn invalid_artifact_reports_index() {
4851 let err = RuntimeManifest::from_yaml_str(
4852 r#"
4853name: cap
4854version: 0.0.1
4855artifacts:
4856 - name: ""
4857 version: 1.2.3
4858"#,
4859 )
4860 .unwrap_err();
4861
4862 let msg = err.to_string();
4863 assert!(msg.contains("index 0"));
4864 assert!(msg.contains("missing required field 'name'"));
4865 }
4866
4867 #[test]
4868 fn capabilities_parse_when_all_fields_present() {
4869 let manifest = RuntimeManifest::from_yaml_str(
4870 r#"
4871name: cap
4872version: 0.0.1
4873artifacts:
4874 - name: echo
4875 version: 1.2.3
4876capabilities:
4877 network:
4878 allow:
4879 - https://api.anthropic.com
4880 - http://127.0.0.1:8080
4881 filesystem:
4882 scope: ./sandbox
4883"#,
4884 )
4885 .unwrap();
4886
4887 let capabilities = manifest.capabilities.expect("capabilities should exist");
4888 assert_eq!(
4889 capabilities.network.unwrap().allow,
4890 vec![
4891 "https://api.anthropic.com".to_string(),
4892 "http://127.0.0.1:8080".to_string()
4893 ]
4894 );
4895 assert_eq!(
4896 capabilities.filesystem.unwrap().scope,
4897 Some("./sandbox".to_string())
4898 );
4899 }
4900
4901 #[test]
4902 fn env_capabilities_parse_allowlist() {
4903 let manifest = RuntimeManifest::from_yaml_str(
4904 r#"
4905name: cap
4906version: 0.0.1
4907artifacts:
4908 - name: echo
4909 version: 1.2.3
4910capabilities:
4911 env:
4912 allow:
4913 - MY_APP_REGION
4914 - GITHUB_TOKEN
4915"#,
4916 )
4917 .unwrap();
4918
4919 let capabilities = manifest.capabilities.expect("capabilities should exist");
4920 assert_eq!(
4921 capabilities.env.unwrap().allow,
4922 vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
4923 );
4924 }
4925
4926 #[test]
4927 fn env_capabilities_absent_when_block_omitted() {
4928 let manifest = RuntimeManifest::from_yaml_str(
4929 r#"
4930name: cap
4931version: 0.0.1
4932artifacts:
4933 - name: echo
4934 version: 1.2.3
4935capabilities:
4936 network:
4937 allow:
4938 - https://api.anthropic.com
4939"#,
4940 )
4941 .unwrap();
4942
4943 assert!(manifest.capabilities.unwrap().env.is_none());
4944 }
4945
4946 #[test]
4949 fn env_capabilities_allow_empty_list_is_accepted() {
4950 let manifest = RuntimeManifest::from_yaml_str(
4951 r#"
4952name: cap
4953version: 0.0.1
4954artifacts:
4955 - name: echo
4956 version: 1.2.3
4957capabilities:
4958 env: {}
4959"#,
4960 )
4961 .unwrap();
4962
4963 assert_eq!(
4964 manifest.capabilities.unwrap().env,
4965 Some(EnvCapabilities { allow: vec![] })
4966 );
4967 }
4968
4969 #[test]
4970 fn missing_capabilities_block_is_backward_compatible() {
4971 let manifest = RuntimeManifest::from_yaml_str(
4972 r#"
4973name: cap
4974version: 0.0.1
4975artifacts:
4976 - name: echo
4977 version: 1.2.3
4978"#,
4979 )
4980 .unwrap();
4981
4982 assert!(manifest.capabilities.is_none());
4983 }
4984
4985 #[test]
4986 fn network_only_capabilities_parse() {
4987 let manifest = RuntimeManifest::from_yaml_str(
4988 r#"
4989name: cap
4990version: 0.0.1
4991artifacts:
4992 - name: echo
4993 version: 1.2.3
4994capabilities:
4995 network:
4996 allow:
4997 - https://allowed.example.com
4998"#,
4999 )
5000 .unwrap();
5001
5002 let capabilities = manifest.capabilities.unwrap();
5003 assert_eq!(
5004 capabilities.network.unwrap().allow,
5005 vec!["https://allowed.example.com".to_string()]
5006 );
5007 assert!(capabilities.filesystem.is_none());
5008 }
5009
5010 #[test]
5011 fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
5012 let manifest = RuntimeManifest::from_yaml_str(
5013 r#"
5014name: cap
5015version: 0.0.1
5016artifacts:
5017 - name: echo
5018 version: 1.2.3
5019capabilities:
5020 network: {}
5021"#,
5022 )
5023 .unwrap();
5024
5025 let capabilities = manifest.capabilities.unwrap();
5026 assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
5027 }
5028
5029 #[test]
5032 fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
5033 let manifest = RuntimeManifest::from_yaml_str(
5034 r#"
5035name: cap
5036version: 0.0.1
5037artifacts:
5038 - name: echo
5039 version: 1.2.3
5040capabilities:
5041 network:
5042 allow:
5043 - https://api.anthropic.com
5044"#,
5045 )
5046 .unwrap();
5047
5048 let network = manifest.capabilities.unwrap().network.unwrap();
5049 assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
5050 assert!(!network.unix_sockets);
5051 }
5052
5053 #[test]
5054 fn network_unix_sockets_parses_explicit_false() {
5055 let manifest = RuntimeManifest::from_yaml_str(
5056 r#"
5057name: cap
5058version: 0.0.1
5059artifacts:
5060 - name: echo
5061 version: 1.2.3
5062capabilities:
5063 network:
5064 unix_sockets: false
5065"#,
5066 )
5067 .unwrap();
5068
5069 assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
5070 }
5071
5072 #[test]
5073 fn network_unix_sockets_parses_explicit_true() {
5074 let manifest = RuntimeManifest::from_yaml_str(
5075 r#"
5076name: cap
5077version: 0.0.1
5078artifacts:
5079 - name: echo
5080 version: 1.2.3
5081capabilities:
5082 network:
5083 unix_sockets: true
5084"#,
5085 )
5086 .unwrap();
5087
5088 let network = manifest.capabilities.unwrap().network.unwrap();
5089 assert!(network.unix_sockets);
5090 assert_eq!(network.allow, Vec::<String>::new());
5092 }
5093
5094 #[test]
5098 fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
5099 let manifest = RuntimeManifest::from_yaml_str(
5100 r#"
5101name: cap
5102version: 0.0.1
5103capabilities:
5104 filesystem:
5105 read_only:
5106 - tests
5107 - " bench/fixtures "
5108 - ""
5109 - " "
5110"#,
5111 )
5112 .unwrap();
5113
5114 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
5115 assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
5116 }
5117
5118 #[test]
5120 fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
5121 let manifest = RuntimeManifest::from_yaml_str(
5122 r#"
5123name: cap
5124version: 0.0.1
5125capabilities:
5126 filesystem:
5127 scope: workdir
5128"#,
5129 )
5130 .unwrap();
5131
5132 assert!(manifest
5133 .capabilities
5134 .unwrap()
5135 .filesystem
5136 .unwrap()
5137 .read_only
5138 .is_empty());
5139 }
5140
5141 #[test]
5144 fn filesystem_read_only_does_not_judge_path_shape() {
5145 let manifest = RuntimeManifest::from_yaml_str(
5146 r#"
5147name: cap
5148version: 0.0.1
5149capabilities:
5150 filesystem:
5151 read_only:
5152 - /etc
5153 - ../outside
5154"#,
5155 )
5156 .expect("shape is judged by the runtime, not the parser");
5157
5158 assert_eq!(
5159 manifest.capabilities.unwrap().filesystem.unwrap().read_only,
5160 vec!["/etc", "../outside"]
5161 );
5162 }
5163
5164 #[test]
5168 fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
5169 let manifest = RuntimeManifest::from_yaml_str(
5170 r#"
5171name: cap
5172version: 0.0.1
5173artifacts:
5174 - name: echo
5175 version: 1.2.3
5176capabilities:
5177 filesystem:
5178 scope: workdir
5179"#,
5180 )
5181 .unwrap();
5182
5183 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
5184 assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
5185 assert!(!filesystem.workdir_exec);
5186 }
5187
5188 #[test]
5193 fn filesystem_block_stays_absent_when_undeclared() {
5194 let manifest = RuntimeManifest::from_yaml_str(
5195 r#"
5196name: cap
5197version: 0.0.1
5198artifacts:
5199 - name: echo
5200 version: 1.2.3
5201capabilities:
5202 network:
5203 allow:
5204 - https://api.anthropic.com
5205"#,
5206 )
5207 .unwrap();
5208
5209 assert!(manifest.capabilities.unwrap().filesystem.is_none());
5210 }
5211
5212 #[test]
5213 fn filesystem_workdir_exec_parses_explicit_false() {
5214 let manifest = RuntimeManifest::from_yaml_str(
5215 r#"
5216name: cap
5217version: 0.0.1
5218artifacts:
5219 - name: echo
5220 version: 1.2.3
5221capabilities:
5222 filesystem:
5223 workdir_exec: false
5224"#,
5225 )
5226 .unwrap();
5227
5228 assert!(
5229 !manifest
5230 .capabilities
5231 .unwrap()
5232 .filesystem
5233 .unwrap()
5234 .workdir_exec
5235 );
5236 }
5237
5238 #[test]
5239 fn filesystem_workdir_exec_parses_explicit_true() {
5240 let manifest = RuntimeManifest::from_yaml_str(
5241 r#"
5242name: cap
5243version: 0.0.1
5244artifacts:
5245 - name: echo
5246 version: 1.2.3
5247capabilities:
5248 filesystem:
5249 workdir_exec: true
5250"#,
5251 )
5252 .unwrap();
5253
5254 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
5255 assert!(filesystem.workdir_exec);
5256 assert_eq!(filesystem.scope, None);
5259 }
5260
5261 #[test]
5262 fn network_internal_port_parses() {
5263 let manifest = RuntimeManifest::from_yaml_str(
5264 r#"
5265name: worker
5266version: 0.1.0
5267artifacts: []
5268
5269network:
5270 internal_port: 58172
5271"#,
5272 )
5273 .unwrap();
5274
5275 assert_eq!(
5276 manifest.network.unwrap().internal_port,
5277 Some(58172),
5278 "network.internal_port should parse to Some(58172)"
5279 );
5280 }
5281
5282 #[test]
5283 fn network_internal_port_absent_when_omitted() {
5284 let manifest = RuntimeManifest::from_yaml_str(
5285 r#"
5286name: worker
5287version: 0.1.0
5288artifacts: []
5289"#,
5290 )
5291 .unwrap();
5292
5293 assert!(manifest.network.is_none());
5294 }
5295
5296 #[test]
5297 fn shell_capabilities_parse_with_allow_and_env_fields() {
5298 let manifest = RuntimeManifest::from_yaml_str(
5299 r#"
5300name: cap
5301version: 0.0.1
5302artifacts: []
5303capabilities:
5304 shell:
5305 allow:
5306 - bash
5307 - git
5308 strip_env:
5309 - "*_TOKEN"
5310 - SECRET_KEY
5311 baseline_env:
5312 - SSH_AUTH_SOCK
5313"#,
5314 )
5315 .unwrap();
5316
5317 let shell = manifest
5318 .capabilities
5319 .unwrap()
5320 .shell
5321 .expect("shell capabilities should be present");
5322 assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
5323 assert_eq!(
5324 shell.strip_env,
5325 Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
5326 );
5327 assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
5328 }
5329
5330 #[test]
5331 fn shell_capabilities_require_non_empty_allowlist() {
5332 let err = RuntimeManifest::from_yaml_str(
5333 r#"
5334name: cap
5335version: 0.0.1
5336artifacts: []
5337capabilities:
5338 shell:
5339 allow: []
5340"#,
5341 )
5342 .unwrap_err();
5343
5344 let msg = err.to_string();
5345 assert!(msg.contains("capabilities.shell.allow"));
5346 assert!(msg.contains("at least one"));
5347 }
5348
5349 #[test]
5350 fn interpreter_runtime_parses_accepted_shape() {
5351 let manifest = RuntimeManifest::from_yaml_str(
5352 r#"
5353name: cap
5354version: 0.0.1
5355artifacts: []
5356capabilities:
5357 shell:
5358 allow:
5359 - python3
5360 interpreter_runtime:
5361 - binary: python3
5362 dirs:
5363 - path: /usr/lib/python3.11
5364 list_dir: true
5365 - path: /usr/lib/python3.11/lib-dynload
5366 list_dir: false
5367"#,
5368 )
5369 .unwrap();
5370
5371 let shell = manifest.capabilities.unwrap().shell.unwrap();
5372 assert_eq!(
5373 shell.interpreter_runtime,
5374 vec![InterpreterRuntimeGrant {
5375 binary: "python3".to_string(),
5376 dirs: vec![
5377 InterpreterRuntimeDir {
5378 path: "/usr/lib/python3.11".to_string(),
5379 list_dir: true,
5380 },
5381 InterpreterRuntimeDir {
5382 path: "/usr/lib/python3.11/lib-dynload".to_string(),
5383 list_dir: false,
5384 },
5385 ],
5386 }]
5387 );
5388 }
5389
5390 #[test]
5391 fn interpreter_runtime_defaults_to_empty_when_absent() {
5392 let manifest = RuntimeManifest::from_yaml_str(
5393 r#"
5394name: cap
5395version: 0.0.1
5396artifacts: []
5397capabilities:
5398 shell:
5399 allow:
5400 - python3
5401"#,
5402 )
5403 .unwrap();
5404
5405 let shell = manifest.capabilities.unwrap().shell.unwrap();
5406 assert!(shell.interpreter_runtime.is_empty());
5407 }
5408
5409 fn interpreter_runtime_reject(grant_yaml: &str) -> String {
5412 let yaml = format!(
5413 r#"
5414name: cap
5415version: 0.0.1
5416artifacts: []
5417capabilities:
5418 shell:
5419 allow:
5420 - python3
5421 interpreter_runtime:
5422{grant_yaml}
5423"#,
5424 );
5425 RuntimeManifest::from_yaml_str(&yaml)
5426 .expect_err("grant should be rejected")
5427 .to_string()
5428 }
5429
5430 #[test]
5431 fn interpreter_runtime_rejects_binary_not_in_allow() {
5432 let msg = interpreter_runtime_reject(
5433 " - binary: ruby\n dirs:\n - path: /usr/lib/ruby\n list_dir: true\n",
5434 );
5435 assert!(
5436 msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
5437 "{msg}"
5438 );
5439 assert!(msg.contains("ruby"), "{msg}");
5440 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
5441 }
5442
5443 #[test]
5444 fn interpreter_runtime_rejects_relative_path() {
5445 let msg = interpreter_runtime_reject(
5446 " - binary: python3\n dirs:\n - path: usr/lib/python3.11\n list_dir: true\n",
5447 );
5448 assert!(
5449 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
5450 "{msg}"
5451 );
5452 assert!(msg.contains("absolute"), "{msg}");
5453 }
5454
5455 fn manifest_with_plan(block: &str) -> String {
5459 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n{block}")
5460 }
5461
5462 #[test]
5463 fn plan_submit_true_grants_the_capsule() {
5464 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n"))
5465 .expect("a plan block parses");
5466 assert_eq!(
5467 manifest.capabilities.unwrap().plan,
5468 Some(PlanCapabilities { submit: true })
5469 );
5470 }
5471
5472 #[test]
5475 fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
5476 let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: false\n"))
5477 .expect("an explicit denial parses");
5478 assert_eq!(
5479 explicit.capabilities.unwrap().plan,
5480 Some(PlanCapabilities { submit: false })
5481 );
5482
5483 let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
5484 .expect("no capabilities block parses");
5485 assert!(absent.capabilities.is_none());
5486 }
5487
5488 #[test]
5491 fn plan_without_submit_is_rejected() {
5492 let err = RuntimeManifest::from_yaml_str(&manifest_with_plan(" {}\n"))
5493 .expect_err("an empty plan block is refused");
5494 let msg = err.to_string();
5495 assert!(msg.contains("capabilities.plan.submit"), "{msg}");
5496 assert!(msg.contains("never"), "{msg}");
5497 }
5498
5499 #[test]
5501 fn an_unknown_plan_key_is_reported_by_path() {
5502 let manifest =
5503 RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n depth: 3\n"))
5504 .expect("an unknown key is a warning, not a parse error");
5505 assert!(
5506 manifest
5507 .unknown_keys
5508 .iter()
5509 .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
5510 "{:?}",
5511 manifest.unknown_keys
5512 );
5513 }
5514
5515 fn manifest_with_task_io(runtime: &str, block: &str) -> String {
5519 format!(
5520 "name: cap\nversion: 0.0.1\nartifacts:\n - name: gate\n version: 1.2.3\n \
5521 runtime: {runtime}\n capabilities:\n task_io:\n{block}"
5522 )
5523 }
5524
5525 #[test]
5526 fn task_io_read_true_grants_the_hook() {
5527 let manifest =
5528 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: true\n"))
5529 .expect("task_io on a hook entry parses");
5530 assert_eq!(
5531 manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
5532 Some(TaskIoCapabilities { read: true })
5533 );
5534 }
5535
5536 #[test]
5539 fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
5540 let explicit =
5541 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: false\n"))
5542 .expect("an explicit denial parses");
5543 assert_eq!(
5544 explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
5545 Some(TaskIoCapabilities { read: false })
5546 );
5547
5548 let absent = RuntimeManifest::from_yaml_str(
5549 "name: cap\nversion: 0.0.1\nartifacts:\n - name: gate\n version: 1.2.3\n \
5550 runtime: hook\n",
5551 )
5552 .expect("no capabilities block parses");
5553 assert!(absent.artifacts[0].capabilities.is_none());
5554 }
5555
5556 #[test]
5559 fn task_io_without_read_is_rejected() {
5560 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " {}\n"))
5561 .unwrap_err();
5562 let msg = err.to_string();
5563 assert!(
5564 msg.contains("capabilities.task_io.read"),
5565 "error was: {msg}"
5566 );
5567 assert!(msg.contains("explicitly"), "error was: {msg}");
5568 }
5569
5570 #[test]
5573 fn task_io_outside_a_hook_entry_is_rejected() {
5574 for runtime in ["tool", "driver", "skill"] {
5575 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
5576 runtime,
5577 " read: true\n",
5578 ))
5579 .unwrap_err();
5580 let msg = err.to_string();
5581 assert!(
5582 msg.contains("capabilities") && msg.contains("runtime: hook"),
5583 "{runtime} entry: error was: {msg}"
5584 );
5585 }
5586 }
5587
5588 #[test]
5591 fn task_io_in_the_capsule_wide_block_is_rejected() {
5592 let err = RuntimeManifest::from_yaml_str(
5593 "name: cap\nversion: 0.0.1\ncapabilities:\n task_io:\n read: true\n",
5594 )
5595 .unwrap_err();
5596 let msg = err.to_string();
5597 assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
5598 assert!(msg.contains("runtime: hook"), "error was: {msg}");
5599 }
5600
5601 fn manifest_with_conversation(runtime: &str, block: &str) -> String {
5605 format!(
5606 "name: cap\nversion: 0.0.1\nartifacts:\n - name: memory\n version: 1.2.3\n \
5607 runtime: {runtime}\n capabilities:\n conversation:\n{block}"
5608 )
5609 }
5610
5611 #[test]
5612 fn conversation_read_lowers_onto_the_hook_entry() {
5613 for (declared, expected) in [("true", true), ("false", false)] {
5614 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
5615 "hook",
5616 &format!(" read: {declared}\n"),
5617 ))
5618 .expect("conversation on a hook entry parses");
5619 assert_eq!(
5620 manifest.artifacts[0]
5621 .capabilities
5622 .as_ref()
5623 .unwrap()
5624 .conversation,
5625 Some(ConversationCapabilities { read: expected })
5626 );
5627 }
5628 }
5629
5630 #[test]
5633 fn conversation_without_read_is_rejected() {
5634 let err =
5635 RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", " {}\n"))
5636 .unwrap_err();
5637 let msg = err.to_string();
5638 assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
5639 assert!(msg.contains("explicitly"), "was: {msg}");
5640 }
5641
5642 #[test]
5645 fn conversation_outside_a_hook_entry_is_rejected() {
5646 for runtime in ["tool", "driver", "skill"] {
5647 let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
5648 runtime,
5649 " read: true\n",
5650 ))
5651 .unwrap_err();
5652 let msg = err.to_string();
5653 assert!(
5654 msg.contains("capabilities") && msg.contains("runtime: hook"),
5655 "{runtime} entry: error was: {msg}"
5656 );
5657 }
5658 }
5659
5660 #[test]
5663 fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
5664 let manifest = RuntimeManifest::from_yaml_str(
5665 "name: cap\nversion: 0.0.1\ncapabilities:\n conversation:\n read: true\n",
5666 )
5667 .expect("a capsule-wide block is accepted");
5668 assert_eq!(
5669 manifest.capabilities.unwrap().conversation,
5670 Some(ConversationCapabilities { read: true })
5671 );
5672 }
5673
5674 #[test]
5679 fn a_context_block_records_by_default() {
5680 let context = context_of("context:\n max_tokens: 1000\n").unwrap();
5681 assert!(context.record);
5682 assert_eq!(context.record_store, None);
5683 }
5684
5685 #[test]
5686 fn record_off_and_on_are_the_two_accepted_values() {
5687 assert!(!context_of("context:\n record: off\n").unwrap().record);
5688 assert!(context_of("context:\n record: on\n").unwrap().record);
5689
5690 let err = RuntimeManifest::from_yaml_str(
5691 "name: cap\nversion: 0.0.1\ncontext:\n record: sometimes\n",
5692 )
5693 .unwrap_err();
5694 let msg = err.to_string();
5695 assert!(msg.contains("context.record"), "was: {msg}");
5696 assert!(msg.contains("sometimes"), "was: {msg}");
5697 }
5698
5699 #[test]
5702 fn record_store_is_kept_verbatim_and_trimmed() {
5703 assert_eq!(
5704 context_of("context:\n record_store: ' shey '\n")
5705 .unwrap()
5706 .record_store,
5707 Some("shey".to_string())
5708 );
5709 assert_eq!(
5710 context_of("context:\n record: off\n record_store: shey\n")
5711 .unwrap()
5712 .record_store,
5713 Some("shey".to_string()),
5714 "a store declared beside record: off is kept and simply never used"
5715 );
5716 }
5717
5718 #[test]
5719 fn interpreter_runtime_rejects_missing_list_dir() {
5720 let msg = interpreter_runtime_reject(
5721 " - binary: python3\n dirs:\n - path: /usr/lib/python3.11\n",
5722 );
5723 assert!(
5724 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
5725 "{msg}"
5726 );
5727 assert!(msg.contains("explicitly"), "{msg}");
5728 }
5729
5730 #[test]
5731 fn interpreter_runtime_rejects_empty_dirs() {
5732 let msg = interpreter_runtime_reject(" - binary: python3\n dirs: []\n");
5733 assert!(
5734 msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
5735 "{msg}"
5736 );
5737 assert!(msg.contains("at least one"), "{msg}");
5738 }
5739
5740 #[test]
5741 fn staged_runtime_parses_accepted_shape() {
5742 let manifest = RuntimeManifest::from_yaml_str(
5743 r#"
5744name: cap
5745version: 0.0.1
5746artifacts: []
5747capabilities:
5748 containment: sealed
5749 shell:
5750 allow:
5751 - python3
5752 - bash
5753 staged_runtime:
5754 - binary: python3
5755 source_path: /opt/testbed/conda/envs/django__django
5756 pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
5757"#,
5758 )
5759 .unwrap();
5760
5761 let shell = manifest.capabilities.unwrap().shell.unwrap();
5762 assert_eq!(
5763 shell.staged_runtime,
5764 vec![StagedRuntimeGrant {
5765 binary: "python3".to_string(),
5766 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
5767 pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
5768 }]
5769 );
5770 assert!(shell.interpreter_runtime.is_empty());
5773 }
5774
5775 #[test]
5776 fn staged_runtime_defaults_to_empty_when_absent() {
5777 let manifest = RuntimeManifest::from_yaml_str(
5778 r#"
5779name: cap
5780version: 0.0.1
5781artifacts: []
5782capabilities:
5783 shell:
5784 allow:
5785 - python3
5786"#,
5787 )
5788 .unwrap();
5789
5790 let shell = manifest.capabilities.unwrap().shell.unwrap();
5791 assert!(shell.staged_runtime.is_empty());
5792 }
5793
5794 fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
5798 let yaml = format!(
5799 r#"
5800name: cap
5801version: 0.0.1
5802artifacts: []
5803capabilities:
5804 shell:
5805 allow:
5806 - python3
5807{extra} staged_runtime:
5808{grant_yaml}
5809"#,
5810 );
5811 RuntimeManifest::from_yaml_str(&yaml)
5812 .expect_err("grant should be rejected")
5813 .to_string()
5814 }
5815
5816 #[test]
5817 fn staged_runtime_rejects_binary_not_in_allow() {
5818 let msg = staged_runtime_reject(
5819 " - binary: ruby\n source_path: /opt/ruby\n pin: ruby-3.2.2\n",
5820 "",
5821 );
5822 assert!(
5823 msg.contains("capabilities.shell.staged_runtime[0].binary"),
5824 "{msg}"
5825 );
5826 assert!(msg.contains("ruby"), "{msg}");
5827 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
5828 }
5829
5830 #[test]
5831 fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
5832 let msg = staged_runtime_reject(
5833 " - binary: python3\n source_path: /opt/py\n pin: cpython-3.9.19\n",
5834 " interpreter_runtime:\n - binary: python3\n dirs:\n - path: /usr/lib/python3.9\n list_dir: true\n",
5835 );
5836 assert!(
5837 msg.contains("capabilities.shell.staged_runtime[0].binary"),
5838 "{msg}"
5839 );
5840 assert!(msg.contains("python3"), "{msg}");
5841 assert!(msg.contains("interpreter_runtime"), "{msg}");
5842 assert!(msg.contains("mutually exclusive"), "{msg}");
5843 }
5844
5845 #[test]
5846 fn staged_runtime_rejects_relative_source_path() {
5847 let msg = staged_runtime_reject(
5848 " - binary: python3\n source_path: opt/testbed/conda\n pin: cpython-3.9.19\n",
5849 "",
5850 );
5851 assert!(
5852 msg.contains("capabilities.shell.staged_runtime[0].source_path"),
5853 "{msg}"
5854 );
5855 assert!(msg.contains("absolute"), "{msg}");
5856 }
5857
5858 #[test]
5859 fn staged_runtime_rejects_missing_or_empty_pin() {
5860 for grant in [
5863 " - binary: python3\n source_path: /opt/testbed/conda\n",
5864 " - binary: python3\n source_path: /opt/testbed/conda\n pin: \" \"\n",
5865 ] {
5866 let msg = staged_runtime_reject(grant, "");
5867 assert!(
5868 msg.contains("capabilities.shell.staged_runtime[0].pin"),
5869 "{msg}"
5870 );
5871 assert!(msg.contains("never inferred"), "{msg}");
5872 }
5873 }
5874
5875 #[test]
5876 fn parses_inference_block() {
5877 let manifest = RuntimeManifest::from_yaml_str(
5878 r#"
5879name: cap
5880version: 0.0.1
5881artifacts: []
5882inference:
5883 transport: http
5884 endpoint: http://127.0.0.1:8080
5885 model: test-model
5886 api_key: literal-key
5887 driver:
5888 artifact: murmur-driver-anthropic
5889 config:
5890 temperature: 0.2
5891 headers:
5892 x-test: true
5893"#,
5894 )
5895 .unwrap();
5896
5897 let inference = manifest.inference.expect("inference should exist");
5898 assert_eq!(inference.transport, "http");
5899 assert_eq!(
5900 inference.endpoint,
5901 Some("http://127.0.0.1:8080".to_string())
5902 );
5903 assert_eq!(inference.model, "test-model");
5904 assert_eq!(
5905 inference.api_key,
5906 Some(ApiKeyReference::Literal("literal-key".to_string()))
5907 );
5908 let driver = inference.driver.as_ref().expect("driver should be present");
5909 assert_eq!(driver.artifact, "murmur-driver-anthropic");
5910 assert_eq!(
5911 driver.config,
5912 Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
5913 );
5914 assert!(inference.system_prompt.is_none());
5915 assert!(inference.system_prompt_file.is_none());
5916 }
5917
5918 #[test]
5919 fn https_endpoint_accepted() {
5920 let manifest = RuntimeManifest::from_yaml_str(
5921 r#"
5922name: cap
5923version: 0.0.1
5924artifacts: []
5925inference:
5926 transport: http
5927 endpoint: https://api.anthropic.com
5928 model: test-model
5929 driver:
5930 artifact: murmur-driver-anthropic
5931"#,
5932 )
5933 .unwrap();
5934
5935 assert_eq!(
5936 manifest.inference.unwrap().endpoint,
5937 Some("https://api.anthropic.com".to_string())
5938 );
5939 }
5940
5941 #[test]
5942 fn http_localhost_endpoint_accepted() {
5943 let manifest = RuntimeManifest::from_yaml_str(
5944 r#"
5945name: cap
5946version: 0.0.1
5947artifacts: []
5948inference:
5949 transport: http
5950 endpoint: http://localhost:11434
5951 model: test-model
5952 driver:
5953 artifact: murmur-driver-anthropic
5954"#,
5955 )
5956 .unwrap();
5957
5958 assert_eq!(
5959 manifest.inference.unwrap().endpoint,
5960 Some("http://localhost:11434".to_string())
5961 );
5962 }
5963
5964 #[test]
5965 fn http_loopback_ip_endpoint_accepted() {
5966 let manifest = RuntimeManifest::from_yaml_str(
5967 r#"
5968name: cap
5969version: 0.0.1
5970artifacts: []
5971inference:
5972 transport: http
5973 endpoint: http://127.0.0.1:11434
5974 model: test-model
5975 driver:
5976 artifact: murmur-driver-anthropic
5977"#,
5978 )
5979 .unwrap();
5980
5981 assert_eq!(
5982 manifest.inference.unwrap().endpoint,
5983 Some("http://127.0.0.1:11434".to_string())
5984 );
5985 }
5986
5987 #[test]
5988 fn http_non_localhost_endpoint_rejected() {
5989 let err = RuntimeManifest::from_yaml_str(
5990 r#"
5991name: cap
5992version: 0.0.1
5993artifacts: []
5994inference:
5995 transport: http
5996 endpoint: http://api.attacker.example.com
5997 model: test-model
5998 driver:
5999 artifact: murmur-driver-anthropic
6000"#,
6001 )
6002 .unwrap_err();
6003
6004 let msg = err.to_string();
6005 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
6006 assert!(msg.contains("api.attacker.example.com"), "error was: {msg}");
6007 }
6008
6009 #[test]
6010 fn schemeless_endpoint_rejected() {
6011 let err = RuntimeManifest::from_yaml_str(
6012 r#"
6013name: cap
6014version: 0.0.1
6015artifacts: []
6016inference:
6017 transport: http
6018 endpoint: api.anthropic.com
6019 model: test-model
6020 driver:
6021 artifact: murmur-driver-anthropic
6022"#,
6023 )
6024 .unwrap_err();
6025
6026 let msg = err.to_string();
6027 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
6028 assert!(msg.contains("failed to parse"), "error was: {msg}");
6029 }
6030
6031 #[test]
6032 fn malformed_endpoint_rejected() {
6033 let err = RuntimeManifest::from_yaml_str(
6034 r#"
6035name: cap
6036version: 0.0.1
6037artifacts: []
6038inference:
6039 transport: http
6040 endpoint: "not a url"
6041 model: test-model
6042 driver:
6043 artifact: murmur-driver-anthropic
6044"#,
6045 )
6046 .unwrap_err();
6047
6048 let msg = err.to_string();
6049 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
6050 assert!(msg.contains("failed to parse"), "error was: {msg}");
6051 }
6052
6053 #[test]
6054 fn unsupported_scheme_endpoint_rejected() {
6055 let err = RuntimeManifest::from_yaml_str(
6056 r#"
6057name: cap
6058version: 0.0.1
6059artifacts: []
6060inference:
6061 transport: http
6062 endpoint: ftp://example.com
6063 model: test-model
6064 driver:
6065 artifact: murmur-driver-anthropic
6066"#,
6067 )
6068 .unwrap_err();
6069
6070 let msg = err.to_string();
6071 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
6072 assert!(msg.contains("unsupported scheme 'ftp'"), "error was: {msg}");
6073 }
6074
6075 #[test]
6076 fn parses_inline_system_prompt() {
6077 let manifest = RuntimeManifest::from_yaml_str(
6078 r#"
6079name: cap
6080version: 0.0.1
6081artifacts: []
6082inference:
6083 endpoint: http://127.0.0.1:8080
6084 model: test-model
6085 system_prompt: "Always begin with CONFIRMED:"
6086 driver:
6087 artifact: murmur-driver-anthropic
6088"#,
6089 )
6090 .unwrap();
6091
6092 let inference = manifest.inference.unwrap();
6093 assert_eq!(
6094 inference.system_prompt,
6095 Some("Always begin with CONFIRMED:".to_string())
6096 );
6097 assert!(inference.system_prompt_file.is_none());
6098 }
6099
6100 #[test]
6101 fn parses_compaction_system_prompt() {
6102 let manifest = RuntimeManifest::from_yaml_str(
6103 r#"
6104name: cap
6105version: 0.0.1
6106artifacts: []
6107inference:
6108 endpoint: http://127.0.0.1:8080
6109 model: test-model
6110 compaction:
6111 model: compaction-model
6112 system_prompt: "task = X, currently editing Y, already tried Z."
6113 driver:
6114 artifact: murmur-driver-anthropic
6115"#,
6116 )
6117 .unwrap();
6118
6119 let compaction = manifest.inference.unwrap().compaction.unwrap();
6120 assert_eq!(
6121 compaction.system_prompt,
6122 Some("task = X, currently editing Y, already tried Z.".to_string())
6123 );
6124 assert_eq!(compaction.model, Some("compaction-model".to_string()));
6125 }
6126
6127 #[test]
6130 fn compaction_system_prompt_absent_stays_none() {
6131 let manifest = RuntimeManifest::from_yaml_str(
6132 r#"
6133name: cap
6134version: 0.0.1
6135artifacts: []
6136inference:
6137 endpoint: http://127.0.0.1:8080
6138 model: test-model
6139 compaction:
6140 model: compaction-model
6141 driver:
6142 artifact: murmur-driver-anthropic
6143"#,
6144 )
6145 .unwrap();
6146
6147 let compaction = manifest.inference.unwrap().compaction.unwrap();
6148 assert!(compaction.system_prompt.is_none());
6149 assert!(compaction.system_prompt_file.is_none());
6150 assert_eq!(compaction.model, Some("compaction-model".to_string()));
6151 }
6152
6153 #[test]
6157 fn parses_compaction_system_prompt_file() {
6158 let manifest = RuntimeManifest::from_yaml_str(
6159 r#"
6160name: cap
6161version: 0.0.1
6162artifacts: []
6163inference:
6164 endpoint: http://127.0.0.1:8080
6165 model: test-model
6166 compaction:
6167 model: compaction-model
6168 system_prompt_file: "compaction-instructions.md"
6169 driver:
6170 artifact: murmur-driver-anthropic
6171"#,
6172 )
6173 .unwrap();
6174
6175 let compaction = manifest.inference.unwrap().compaction.unwrap();
6176 assert_eq!(
6177 compaction.system_prompt_file,
6178 Some("compaction-instructions.md".to_string())
6179 );
6180 assert!(compaction.system_prompt.is_none());
6181 assert_eq!(compaction.model, Some("compaction-model".to_string()));
6182 }
6183
6184 #[test]
6187 fn compaction_system_prompt_file_independent_of_model() {
6188 let manifest = RuntimeManifest::from_yaml_str(
6189 r#"
6190name: cap
6191version: 0.0.1
6192artifacts: []
6193inference:
6194 endpoint: http://127.0.0.1:8080
6195 model: test-model
6196 compaction:
6197 threshold: 0.5
6198 system_prompt_file: "compaction-instructions.md"
6199 driver:
6200 artifact: murmur-driver-anthropic
6201"#,
6202 )
6203 .unwrap();
6204
6205 let compaction = manifest.inference.unwrap().compaction.unwrap();
6206 assert_eq!(
6207 compaction.system_prompt_file,
6208 Some("compaction-instructions.md".to_string())
6209 );
6210 assert!(compaction.model.is_none());
6211 assert_eq!(compaction.threshold, Some(0.5));
6212 }
6213
6214 #[test]
6218 fn parses_compaction_dump_summaries() {
6219 let parse = |line: &str| {
6220 RuntimeManifest::from_yaml_str(&format!(
6221 r#"
6222name: cap
6223version: 0.0.1
6224artifacts: []
6225inference:
6226 endpoint: http://127.0.0.1:8080
6227 model: test-model
6228 compaction:
6229 model: compaction-model
6230{line} driver:
6231 artifact: murmur-driver-anthropic
6232"#
6233 ))
6234 .unwrap()
6235 .inference
6236 .unwrap()
6237 .compaction
6238 .unwrap()
6239 };
6240
6241 assert_eq!(
6242 parse(" dump_summaries: true\n").dump_summaries,
6243 Some(true)
6244 );
6245 assert_eq!(
6246 parse(" dump_summaries: false\n").dump_summaries,
6247 Some(false)
6248 );
6249 assert_eq!(parse("").dump_summaries, None);
6250 }
6251
6252 #[test]
6256 fn compaction_dump_summaries_does_not_interact_with_other_fields() {
6257 let compaction = RuntimeManifest::from_yaml_str(
6258 r#"
6259name: cap
6260version: 0.0.1
6261artifacts: []
6262inference:
6263 endpoint: http://127.0.0.1:8080
6264 model: test-model
6265 compaction:
6266 threshold: 0.5
6267 model: compaction-model
6268 system_prompt_file: "compaction-instructions.md"
6269 dump_summaries: true
6270 driver:
6271 artifact: murmur-driver-anthropic
6272"#,
6273 )
6274 .unwrap()
6275 .inference
6276 .unwrap()
6277 .compaction
6278 .unwrap();
6279
6280 assert_eq!(compaction.dump_summaries, Some(true));
6281 assert_eq!(compaction.threshold, Some(0.5));
6282 assert_eq!(compaction.model, Some("compaction-model".to_string()));
6283 assert_eq!(
6284 compaction.system_prompt_file,
6285 Some("compaction-instructions.md".to_string())
6286 );
6287 assert!(compaction.system_prompt.is_none());
6288
6289 let err = RuntimeManifest::from_yaml_str(
6291 r#"
6292name: cap
6293version: 0.0.1
6294artifacts: []
6295inference:
6296 endpoint: http://127.0.0.1:8080
6297 model: test-model
6298 compaction:
6299 dump_summaries: true
6300 system_prompt: "inline"
6301 system_prompt_file: "compaction-instructions.md"
6302 driver:
6303 artifact: murmur-driver-anthropic
6304"#,
6305 )
6306 .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
6307 assert!(matches!(
6308 err,
6309 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
6310 if field == "inference.compaction.system_prompt"
6311 ));
6312
6313 let err = RuntimeManifest::from_yaml_str(
6315 r#"
6316name: cap
6317version: 0.0.1
6318artifacts: []
6319inference:
6320 endpoint: http://127.0.0.1:8080
6321 model: test-model
6322 compaction:
6323 dump_summaries: true
6324 threshold: 1.5
6325 driver:
6326 artifact: murmur-driver-anthropic
6327"#,
6328 )
6329 .expect_err("dump_summaries must not suppress the threshold range check");
6330 assert!(matches!(
6331 err,
6332 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
6333 if field == "inference.compaction.threshold"
6334 ));
6335 }
6336
6337 #[test]
6341 fn compaction_prompt_sources_are_mutually_exclusive() {
6342 let err = RuntimeManifest::from_yaml_str(
6343 r#"
6344name: cap
6345version: 0.0.1
6346artifacts: []
6347inference:
6348 endpoint: http://127.0.0.1:8080
6349 model: test-model
6350 compaction:
6351 system_prompt: "inline"
6352 system_prompt_file: "compaction-instructions.md"
6353 driver:
6354 artifact: murmur-driver-anthropic
6355"#,
6356 )
6357 .expect_err("both compaction prompt sources set must fail");
6358
6359 match err {
6360 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
6361 assert_eq!(field, "inference.compaction.system_prompt");
6362 assert_eq!(
6363 message,
6364 "at most one of inference.compaction.system_prompt, \
6365 inference.compaction.system_prompt_file may be set"
6366 );
6367 }
6368 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
6369 }
6370 }
6371
6372 #[test]
6376 fn top_level_and_compaction_prompt_sources_do_not_collide() {
6377 let manifest = RuntimeManifest::from_yaml_str(
6378 r#"
6379name: cap
6380version: 0.0.1
6381artifacts: []
6382inference:
6383 endpoint: http://127.0.0.1:8080
6384 model: test-model
6385 system_prompt: "be helpful"
6386 compaction:
6387 system_prompt_file: "compaction-instructions.md"
6388 driver:
6389 artifact: murmur-driver-anthropic
6390"#,
6391 )
6392 .unwrap();
6393
6394 let inference = manifest.inference.unwrap();
6395 assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
6396 assert_eq!(
6397 inference.compaction.unwrap().system_prompt_file,
6398 Some("compaction-instructions.md".to_string())
6399 );
6400 }
6401
6402 #[test]
6403 fn parses_system_prompt_file() {
6404 let manifest = RuntimeManifest::from_yaml_str(
6405 r#"
6406name: cap
6407version: 0.0.1
6408artifacts: []
6409inference:
6410 endpoint: http://127.0.0.1:8080
6411 model: test-model
6412 system_prompt_file: "conventions.md"
6413 driver:
6414 artifact: murmur-driver-anthropic
6415"#,
6416 )
6417 .unwrap();
6418
6419 let inference = manifest.inference.unwrap();
6420 assert_eq!(
6421 inference.system_prompt_file,
6422 Some("conventions.md".to_string())
6423 );
6424 assert!(inference.system_prompt.is_none());
6425 }
6426
6427 #[test]
6428 fn rejects_both_inline_and_file_system_prompt() {
6429 let err = RuntimeManifest::from_yaml_str(
6430 r#"
6431name: cap
6432version: 0.0.1
6433artifacts: []
6434inference:
6435 endpoint: http://127.0.0.1:8080
6436 model: test-model
6437 system_prompt: "inline"
6438 system_prompt_file: "conventions.md"
6439 driver:
6440 artifact: murmur-driver-anthropic
6441"#,
6442 )
6443 .unwrap_err();
6444
6445 let msg = err.to_string();
6446 assert!(msg.contains("inference.system_prompt"));
6447 assert!(msg.contains("system_prompt_file"));
6448 }
6449
6450 #[test]
6453 fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
6454 let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
6455 let yaml = format!(
6456 "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"
6457 );
6458
6459 assert_eq!(
6460 RuntimeManifest::from_yaml_str(&yaml)
6461 .unwrap()
6462 .inference
6463 .unwrap()
6464 .api_key,
6465 Some(ApiKeyReference::Environment(key.to_string()))
6466 );
6467 assert_eq!(
6468 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
6469 .unwrap()
6470 .inference
6471 .unwrap()
6472 .api_key,
6473 None
6474 );
6475 }
6476
6477 #[test]
6478 fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
6479 let literal = ["sk-", "ant-", "debug"].concat();
6480 let debug = format!(
6481 "{:?} {:?}",
6482 ApiKeyReference::Environment("SOME_KEY".to_string()),
6483 ApiKeyReference::Literal(literal.clone())
6484 );
6485 assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
6486 assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
6487 assert!(!debug.contains(&literal), "{debug}");
6488 }
6489
6490 #[test]
6491 fn parse_without_secrets_drops_a_literal_api_key() {
6492 let literal = ["sk-", "ant-", "literal"].concat();
6495 let yaml = format!(
6496 "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: {literal}\n driver:\n artifact: murmur-driver-anthropic\n"
6497 );
6498
6499 assert_eq!(
6500 RuntimeManifest::from_yaml_str(&yaml)
6501 .unwrap()
6502 .inference
6503 .unwrap()
6504 .api_key,
6505 Some(ApiKeyReference::Literal(literal))
6506 );
6507 assert_eq!(
6508 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
6509 .unwrap()
6510 .inference
6511 .unwrap()
6512 .api_key,
6513 None
6514 );
6515 }
6516
6517 #[test]
6520 fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
6521 let process_with_key = format!(
6522 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n model: test-model\n api_key: {}\n",
6523 ["sk-", "ant-", "secret"].concat()
6524 );
6525 let http_without_model = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: http\n endpoint: http://127.0.0.1:8080\n driver:\n artifact: murmur-driver-anthropic\n";
6526
6527 for yaml in [process_with_key.as_str(), http_without_model] {
6528 let resolving = RuntimeManifest::from_yaml_str(yaml)
6529 .unwrap_err()
6530 .to_string();
6531 let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
6532 .unwrap_err()
6533 .to_string();
6534 assert_eq!(resolving, without_secrets);
6535 }
6536 }
6537
6538 #[test]
6542 fn referenced_env_variables_reports_a_reference_and_nothing_else() {
6543 let referencing = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: http\n endpoint: http://127.0.0.1:8080\n model: test-model\n driver:\n artifact: murmur-driver-anthropic\n api_key: ${MURMUR_TEST_REFERENCED_KEY}\n";
6544 let referenced = referenced_env_variables(referencing);
6545 assert_eq!(referenced.len(), 1);
6546 assert_eq!(referenced[0].field, "inference.api_key");
6547 assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
6548
6549 let literal = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: http\n endpoint: http://127.0.0.1:8080\n model: test-model\n driver:\n artifact: murmur-driver-anthropic\n api_key: not-a-reference\n";
6550 assert!(referenced_env_variables(literal).is_empty());
6551
6552 let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
6553 assert!(referenced_env_variables(no_inference).is_empty());
6554
6555 let unparseable = "name: broken\nversion: [\n";
6556 assert!(referenced_env_variables(unparseable).is_empty());
6557 }
6558
6559 #[test]
6560 fn accepts_legacy_inference_provider_field() {
6561 let manifest = RuntimeManifest::from_yaml_str(
6562 r#"
6563name: cap
6564version: 0.0.1
6565artifacts: []
6566inference:
6567 transport: http
6568 endpoint: http://127.0.0.1:8080
6569 model: test-model
6570 provider:
6571 artifact: murmur-driver-openai
6572"#,
6573 )
6574 .unwrap();
6575
6576 assert_eq!(
6577 manifest
6578 .inference
6579 .unwrap()
6580 .driver
6581 .as_ref()
6582 .unwrap()
6583 .artifact,
6584 "murmur-driver-openai"
6585 );
6586 }
6587
6588 #[test]
6589 fn prefers_driver_field_over_legacy_provider_field() {
6590 let manifest = RuntimeManifest::from_yaml_str(
6591 r#"
6592name: cap
6593version: 0.0.1
6594artifacts: []
6595inference:
6596 transport: http
6597 endpoint: http://127.0.0.1:8080
6598 model: test-model
6599 driver:
6600 artifact: murmur-driver-anthropic
6601 provider:
6602 artifact: murmur-driver-openai
6603"#,
6604 )
6605 .unwrap();
6606
6607 assert_eq!(
6608 manifest
6609 .inference
6610 .unwrap()
6611 .driver
6612 .as_ref()
6613 .unwrap()
6614 .artifact,
6615 "murmur-driver-anthropic"
6616 );
6617 }
6618
6619 #[test]
6620 fn inference_requires_driver_artifact() {
6621 let err = RuntimeManifest::from_yaml_str(
6622 r#"
6623name: cap
6624version: 0.0.1
6625artifacts: []
6626inference:
6627 transport: http
6628 endpoint: http://127.0.0.1:8080
6629 model: test-model
6630"#,
6631 )
6632 .unwrap_err();
6633
6634 assert!(err.to_string().contains("inference.driver"));
6635 }
6636
6637 #[test]
6638 fn test_runtime_driver_deserializes() {
6639 let manifest = RuntimeManifest::from_yaml_str(
6640 r#"
6641name: cap
6642version: 0.0.1
6643artifacts:
6644 - name: my-provider
6645 version: 1.0.0
6646 runtime: driver
6647"#,
6648 )
6649 .unwrap();
6650
6651 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
6652 }
6653
6654 #[test]
6655 fn test_runtime_unknown_is_error() {
6656 let err = RuntimeManifest::from_yaml_str(
6657 r#"
6658name: cap
6659version: 0.0.1
6660artifacts:
6661 - name: foo
6662 version: 1.0.0
6663 runtime: unknown_type
6664"#,
6665 )
6666 .unwrap_err();
6667
6668 let msg = err.to_string();
6669 assert!(msg.contains("runtime"), "error should mention 'runtime'");
6670 assert!(
6671 msg.contains("unknown_type"),
6672 "error should identify the bad value"
6673 );
6674 }
6675
6676 #[test]
6677 fn test_runtime_all_variants_roundtrip() {
6678 for variant in [
6679 ArtifactRuntime::Tool,
6680 ArtifactRuntime::Driver,
6681 ArtifactRuntime::Hook,
6682 ArtifactRuntime::Skill,
6683 ] {
6684 let json = serde_json::to_string(&variant).unwrap();
6685 let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
6686 assert_eq!(variant, back);
6687 }
6688 }
6689
6690 #[test]
6691 fn skill_source_without_version_defaults_to_local() {
6692 let manifest = RuntimeManifest::from_yaml_str(
6693 r#"
6694name: cap
6695version: 0.0.1
6696artifacts:
6697 - name: my-skill
6698 source: ./skills/my-skill/skill.md
6699 runtime: skill
6700"#,
6701 )
6702 .unwrap();
6703
6704 assert_eq!(manifest.artifacts.len(), 1);
6705 let artifact = &manifest.artifacts[0];
6706 assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
6707 assert_eq!(
6708 artifact.source.as_deref(),
6709 Some("./skills/my-skill/skill.md")
6710 );
6711 assert_eq!(artifact.version, "local");
6712 }
6713
6714 #[test]
6715 fn skill_source_directory_path_is_accepted() {
6716 let manifest = RuntimeManifest::from_yaml_str(
6717 r#"
6718name: cap
6719version: 0.0.1
6720artifacts:
6721 - name: my-skill
6722 source: ./skills/my-skill/
6723 runtime: skill
6724"#,
6725 )
6726 .unwrap();
6727
6728 assert_eq!(
6729 manifest.artifacts[0].source.as_deref(),
6730 Some("./skills/my-skill/")
6731 );
6732 assert_eq!(manifest.artifacts[0].version, "local");
6733 }
6734
6735 #[test]
6736 fn skill_source_with_explicit_version_substitutes_local() {
6737 let manifest = RuntimeManifest::from_yaml_str(
6739 r#"
6740name: cap
6741version: 0.0.1
6742artifacts:
6743 - name: my-skill
6744 source: ./skills/my-skill/skill.md
6745 version: 9.9.9
6746 runtime: skill
6747"#,
6748 )
6749 .unwrap();
6750
6751 assert_eq!(manifest.artifacts[0].version, "local");
6752 }
6753
6754 #[test]
6755 fn local_source_true_allows_source_on_non_skill_runtime() {
6756 for runtime in ["tool", "driver", "hook"] {
6757 let yaml = format!(
6758 r#"
6759name: cap
6760version: 0.0.1
6761artifacts:
6762 - name: my-thing
6763 source: ./local/path
6764 local_source: true
6765 runtime: {runtime}
6766"#
6767 );
6768 let manifest = RuntimeManifest::from_yaml_str(&yaml)
6769 .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
6770 let artifact = &manifest.artifacts[0];
6771 assert!(artifact.local_source, "runtime {runtime}");
6772 assert_eq!(artifact.source.as_deref(), Some("./local/path"));
6773 assert_eq!(artifact.version, "local", "runtime {runtime}");
6774 }
6775 }
6776
6777 #[test]
6778 fn local_source_false_on_skill_with_source_is_error() {
6779 let err = RuntimeManifest::from_yaml_str(
6782 r#"
6783name: cap
6784version: 0.0.1
6785artifacts:
6786 - name: my-skill
6787 source: ./skills/my-skill
6788 local_source: false
6789 runtime: skill
6790"#,
6791 )
6792 .unwrap_err();
6793 let msg = err.to_string();
6794 assert!(msg.contains("'local_source: true'"), "error was: {msg}");
6795 assert!(msg.contains("my-skill"), "error was: {msg}");
6796 }
6797
6798 #[test]
6799 fn local_source_defaults_from_role_when_absent() {
6800 let manifest = RuntimeManifest::from_yaml_str(
6801 r#"
6802name: cap
6803version: 0.0.1
6804artifacts:
6805 - name: my-skill
6806 source: ./skills/my-skill
6807 runtime: skill
6808 - name: my-tool
6809 version: 1.0.0
6810 runtime: tool
6811"#,
6812 )
6813 .unwrap();
6814 assert!(manifest.artifacts[0].local_source, "skill defaults to true");
6815 assert!(
6816 !manifest.artifacts[1].local_source,
6817 "tool defaults to false"
6818 );
6819 }
6820
6821 #[test]
6822 fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
6823 let manifest = RuntimeManifest::from_yaml_str(
6824 r#"
6825name: cap
6826version: 0.0.1
6827artifacts:
6828 - name: my-tool
6829 version: 1.0.0
6830 runtime: tool
6831 prompt_payload: true
6832inference:
6833 endpoint: http://127.0.0.1:8080
6834 model: test-model
6835 system_prompt_artifact: my-tool
6836 driver:
6837 artifact: murmur-driver-anthropic
6838"#,
6839 )
6840 .unwrap();
6841 assert!(manifest.artifacts[0].prompt_payload);
6842 }
6843
6844 #[test]
6845 fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
6846 let err = RuntimeManifest::from_yaml_str(
6847 r#"
6848name: cap
6849version: 0.0.1
6850artifacts:
6851 - name: my-skill
6852 version: 1.0.0
6853 runtime: skill
6854 prompt_payload: false
6855inference:
6856 endpoint: http://127.0.0.1:8080
6857 model: test-model
6858 system_prompt_artifact: my-skill
6859 driver:
6860 artifact: murmur-driver-anthropic
6861"#,
6862 )
6863 .unwrap_err();
6864 let msg = err.to_string();
6865 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
6866 }
6867
6868 #[test]
6869 fn prompt_payload_defaults_from_role_when_absent() {
6870 let manifest = RuntimeManifest::from_yaml_str(
6871 r#"
6872name: cap
6873version: 0.0.1
6874artifacts:
6875 - name: my-skill
6876 version: 1.0.0
6877 runtime: skill
6878 - name: my-tool
6879 version: 1.0.0
6880 runtime: tool
6881"#,
6882 )
6883 .unwrap();
6884 assert!(
6885 manifest.artifacts[0].prompt_payload,
6886 "skill defaults to true"
6887 );
6888 assert!(
6889 !manifest.artifacts[1].prompt_payload,
6890 "tool defaults to false"
6891 );
6892 }
6893
6894 #[test]
6895 fn source_on_non_skill_runtime_is_error() {
6896 for runtime in ["tool", "driver", "hook"] {
6897 let yaml = format!(
6898 r#"
6899name: cap
6900version: 0.0.1
6901artifacts:
6902 - name: my-thing
6903 source: ./local/path
6904 runtime: {runtime}
6905"#
6906 );
6907 let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
6908 let msg = err.to_string();
6909 assert!(
6910 msg.contains("'local_source: true'"),
6911 "runtime {runtime}: error was: {msg}"
6912 );
6913 assert!(
6914 msg.contains("my-thing"),
6915 "runtime {runtime}: error was: {msg}"
6916 );
6917 }
6918 }
6919
6920 #[test]
6921 fn registry_skill_still_requires_version() {
6922 let err = RuntimeManifest::from_yaml_str(
6923 r#"
6924name: cap
6925version: 0.0.1
6926artifacts:
6927 - name: my-skill
6928 runtime: skill
6929"#,
6930 )
6931 .unwrap_err();
6932
6933 let msg = err.to_string();
6934 assert!(
6935 msg.contains("missing required field 'version'"),
6936 "error was: {msg}"
6937 );
6938 }
6939
6940 #[test]
6941 fn rejects_unknown_inference_transport() {
6942 let err = RuntimeManifest::from_yaml_str(
6943 r#"
6944name: cap
6945version: 0.0.1
6946artifacts: []
6947inference:
6948 transport: grpc
6949 endpoint: ignored
6950 model: test-model
6951 driver:
6952 artifact: murmur-driver-anthropic
6953"#,
6954 )
6955 .unwrap_err();
6956
6957 let msg = err.to_string();
6958 assert!(msg.contains("inference.transport"), "error was: {msg}");
6959 assert!(msg.contains("unknown value"), "error was: {msg}");
6960 assert!(msg.contains("grpc"), "error was: {msg}");
6961 }
6962
6963 #[test]
6964 fn parses_process_transport() {
6965 let manifest = RuntimeManifest::from_yaml_str(
6966 r#"
6967name: cap
6968version: 0.0.1
6969artifacts: []
6970inference:
6971 transport: process
6972 command: claude
6973 model: claude-haiku-4-5-20251001
6974 max_turns: 20
6975"#,
6976 )
6977 .unwrap();
6978
6979 let inference = manifest.inference.expect("inference should exist");
6980 assert_eq!(inference.transport, "process");
6981 assert_eq!(inference.command, Some("claude".to_string()));
6982 assert_eq!(inference.model, "claude-haiku-4-5-20251001");
6983 assert_eq!(inference.max_turns, 20);
6984 assert!(inference.driver.is_none());
6985 assert!(inference.endpoint.is_none());
6986 assert!(inference.api_key.is_none());
6987 }
6988
6989 #[test]
6990 fn process_transport_requires_command() {
6991 let err = RuntimeManifest::from_yaml_str(
6992 r#"
6993name: cap
6994version: 0.0.1
6995artifacts: []
6996inference:
6997 transport: process
6998 model: claude-haiku-4-5-20251001
6999"#,
7000 )
7001 .unwrap_err();
7002
7003 let msg = err.to_string();
7004 assert!(msg.contains("inference.command"), "error was: {msg}");
7005 }
7006
7007 #[test]
7008 fn process_transport_allows_absent_model() {
7009 let manifest = RuntimeManifest::from_yaml_str(
7012 r#"
7013name: cap
7014version: 0.0.1
7015artifacts: []
7016inference:
7017 transport: process
7018 command: codex
7019"#,
7020 )
7021 .expect("process transport should parse without a model");
7022 let inference = manifest.inference.expect("inference present");
7023 assert_eq!(inference.transport, "process");
7024 assert_eq!(inference.command.as_deref(), Some("codex"));
7025 assert_eq!(
7026 inference.model, "",
7027 "absent model resolves to empty (provider default)"
7028 );
7029 }
7030
7031 #[test]
7032 fn process_transport_rejects_driver_artifact() {
7033 let err = RuntimeManifest::from_yaml_str(
7034 r#"
7035name: cap
7036version: 0.0.1
7037artifacts: []
7038inference:
7039 transport: process
7040 command: claude
7041 model: claude-haiku-4-5-20251001
7042 driver:
7043 artifact: murmur-driver-anthropic
7044"#,
7045 )
7046 .unwrap_err();
7047
7048 let msg = err.to_string();
7049 assert!(
7050 msg.contains("inference.driver.artifact"),
7051 "error was: {msg}"
7052 );
7053 assert!(
7054 msg.contains("not valid with transport: process"),
7055 "error was: {msg}"
7056 );
7057 }
7058
7059 #[test]
7060 fn process_transport_rejects_endpoint() {
7061 let err = RuntimeManifest::from_yaml_str(
7062 r#"
7063name: cap
7064version: 0.0.1
7065artifacts: []
7066inference:
7067 transport: process
7068 command: claude
7069 model: claude-haiku-4-5-20251001
7070 endpoint: http://localhost:8080
7071"#,
7072 )
7073 .unwrap_err();
7074
7075 let msg = err.to_string();
7076 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
7077 assert!(
7078 msg.contains("not valid with transport: process"),
7079 "error was: {msg}"
7080 );
7081 }
7082
7083 #[test]
7084 fn process_transport_rejects_api_key() {
7085 let key = ["sk-", "ant-", "secret"].concat();
7088 let err = RuntimeManifest::from_yaml_str(&format!(
7089 r#"
7090name: cap
7091version: 0.0.1
7092artifacts: []
7093inference:
7094 transport: process
7095 command: claude
7096 model: claude-haiku-4-5-20251001
7097 api_key: {key}
7098"#
7099 ))
7100 .unwrap_err();
7101
7102 let msg = err.to_string();
7103 assert!(msg.contains("inference.api_key"), "error was: {msg}");
7104 assert!(
7105 msg.contains("not valid with transport: process"),
7106 "error was: {msg}"
7107 );
7108 }
7109
7110 #[test]
7111 fn process_transport_rejects_max_tokens() {
7112 let err = RuntimeManifest::from_yaml_str(
7115 r#"
7116name: cap
7117version: 0.0.1
7118artifacts: []
7119inference:
7120 transport: process
7121 command: claude
7122 model: claude-haiku-4-5-20251001
7123 max_tokens: 4096
7124"#,
7125 )
7126 .unwrap_err();
7127
7128 let msg = err.to_string();
7129 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
7130 assert!(
7131 msg.contains("not valid with transport: process"),
7132 "error was: {msg}"
7133 );
7134 }
7135
7136 #[test]
7137 fn process_transport_rejects_max_session_tokens() {
7138 let err = RuntimeManifest::from_yaml_str(
7139 r#"
7140name: cap
7141version: 0.0.1
7142artifacts: []
7143inference:
7144 transport: process
7145 command: claude
7146 model: claude-haiku-4-5-20251001
7147 max_session_tokens: 200000
7148"#,
7149 )
7150 .unwrap_err();
7151
7152 let msg = err.to_string();
7153 assert!(
7154 msg.contains("inference.max_session_tokens"),
7155 "error was: {msg}"
7156 );
7157 assert!(
7158 msg.contains("not valid with transport: process"),
7159 "error was: {msg}"
7160 );
7161 }
7162
7163 #[test]
7164 fn inference_max_session_tokens_round_trips() {
7165 let manifest = RuntimeManifest::from_yaml_str(
7166 r#"
7167name: cap
7168version: 0.0.1
7169artifacts: []
7170inference:
7171 transport: http
7172 endpoint: https://api.anthropic.com
7173 model: claude-opus-4-5
7174 max_session_tokens: 5000000000
7175 driver:
7176 artifact: murmur-driver-anthropic
7177"#,
7178 )
7179 .unwrap();
7180
7181 assert_eq!(
7182 manifest.inference.unwrap().max_session_tokens,
7183 Some(5_000_000_000)
7184 );
7185 }
7186
7187 #[test]
7188 fn inference_max_session_tokens_absent_is_none() {
7189 let manifest = RuntimeManifest::from_yaml_str(
7190 r#"
7191name: cap
7192version: 0.0.1
7193artifacts: []
7194inference:
7195 transport: http
7196 endpoint: https://api.anthropic.com
7197 model: claude-opus-4-5
7198 driver:
7199 artifact: murmur-driver-anthropic
7200"#,
7201 )
7202 .unwrap();
7203
7204 assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
7205 }
7206
7207 #[test]
7208 fn inference_max_session_tokens_zero_is_rejected() {
7209 let err = RuntimeManifest::from_yaml_str(
7210 r#"
7211name: cap
7212version: 0.0.1
7213artifacts: []
7214inference:
7215 transport: http
7216 endpoint: https://api.anthropic.com
7217 model: claude-opus-4-5
7218 max_session_tokens: 0
7219 driver:
7220 artifact: murmur-driver-anthropic
7221"#,
7222 )
7223 .unwrap_err();
7224
7225 let msg = err.to_string();
7226 assert!(
7227 msg.contains("inference.max_session_tokens"),
7228 "error was: {msg}"
7229 );
7230 assert!(msg.contains("greater than 0"), "error was: {msg}");
7231 }
7232
7233 #[test]
7234 fn inference_max_tokens_round_trips() {
7235 let manifest = RuntimeManifest::from_yaml_str(
7236 r#"
7237name: cap
7238version: 0.0.1
7239artifacts: []
7240inference:
7241 transport: http
7242 endpoint: https://api.anthropic.com
7243 model: claude-opus-4-5
7244 max_tokens: 4096
7245 driver:
7246 artifact: murmur-driver-anthropic
7247"#,
7248 )
7249 .unwrap();
7250
7251 let inference = manifest.inference.unwrap();
7252 assert_eq!(inference.max_tokens, Some(4096));
7253 }
7254
7255 #[test]
7256 fn inference_max_tokens_absent_is_none() {
7257 let manifest = RuntimeManifest::from_yaml_str(
7260 r#"
7261name: cap
7262version: 0.0.1
7263artifacts: []
7264inference:
7265 transport: http
7266 endpoint: https://api.anthropic.com
7267 model: claude-opus-4-5
7268 driver:
7269 artifact: murmur-driver-anthropic
7270"#,
7271 )
7272 .unwrap();
7273
7274 assert_eq!(manifest.inference.unwrap().max_tokens, None);
7275 }
7276
7277 #[test]
7278 fn inference_max_tokens_zero_is_rejected() {
7279 let err = RuntimeManifest::from_yaml_str(
7280 r#"
7281name: cap
7282version: 0.0.1
7283artifacts: []
7284inference:
7285 transport: http
7286 endpoint: https://api.anthropic.com
7287 model: claude-opus-4-5
7288 max_tokens: 0
7289 driver:
7290 artifact: murmur-driver-anthropic
7291"#,
7292 )
7293 .unwrap_err();
7294
7295 let msg = err.to_string();
7296 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
7297 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
7298 }
7299
7300 #[test]
7301 fn inference_max_tokens_large_value_is_accepted() {
7302 let manifest = RuntimeManifest::from_yaml_str(
7305 r#"
7306name: cap
7307version: 0.0.1
7308artifacts: []
7309inference:
7310 transport: http
7311 endpoint: https://api.anthropic.com
7312 model: claude-opus-4-5
7313 max_tokens: 999999
7314 driver:
7315 artifact: murmur-driver-anthropic
7316"#,
7317 )
7318 .unwrap();
7319
7320 assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
7321 }
7322
7323 #[test]
7324 fn inference_and_context_max_tokens_are_independent() {
7325 let manifest = RuntimeManifest::from_yaml_str(
7328 r#"
7329name: cap
7330version: 0.0.1
7331artifacts: []
7332inference:
7333 transport: http
7334 endpoint: https://api.anthropic.com
7335 model: claude-opus-4-5
7336 max_tokens: 4096
7337 driver:
7338 artifact: murmur-driver-anthropic
7339context:
7340 max_tokens: 200000
7341"#,
7342 )
7343 .unwrap();
7344
7345 assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
7346 assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
7347 }
7348
7349 #[test]
7350 fn hook_config_defaults_when_fields_absent() {
7351 let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
7352 assert_eq!(config.binding, HookBinding::All);
7353 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
7354 assert_eq!(config.commit_policy, HookCommitPolicy::None);
7355 }
7356
7357 #[test]
7358 fn hook_config_parses_all_fields() {
7359 let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
7360 let config = parse_hook_config_from_yaml(yaml).unwrap();
7361 assert_eq!(config.binding, HookBinding::OnCompaction);
7362 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
7363 assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
7364 }
7365
7366 #[test]
7367 fn hook_config_parses_on_task_start() {
7368 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
7369 let config = parse_hook_config_from_yaml(yaml).unwrap();
7370 assert_eq!(config.binding, HookBinding::OnTaskStart);
7371 }
7372
7373 #[test]
7374 fn hook_config_parses_on_task_end() {
7375 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
7376 let config = parse_hook_config_from_yaml(yaml).unwrap();
7377 assert_eq!(config.binding, HookBinding::OnTaskEnd);
7378 }
7379
7380 #[test]
7381 fn hook_config_unknown_binding_lists_task_events() {
7382 let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
7383 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7384 assert!(err.contains("on-task-start"), "error was: {err}");
7385 assert!(err.contains("on-task-end"), "error was: {err}");
7386 }
7387
7388 #[test]
7389 fn hook_config_async_with_commit_is_rejected() {
7390 let yaml =
7391 "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
7392 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7393 assert!(err.contains("async-with-commit"), "error was: {err}");
7394 }
7395
7396 #[test]
7397 fn hook_config_on_stage_async_is_rejected() {
7398 let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
7399 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7400 assert!(err.contains("on-stage"), "error was: {err}");
7401 assert!(err.contains("blocking"), "error was: {err}");
7402 }
7403
7404 #[test]
7405 fn hook_config_async_none_is_valid() {
7406 let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
7407 let config = parse_hook_config_from_yaml(yaml).unwrap();
7408 assert_eq!(config.execution_mode, HookExecutionMode::Async);
7409 assert_eq!(config.commit_policy, HookCommitPolicy::None);
7410 }
7411
7412 #[test]
7413 fn hook_config_parses_reopen_task_commit_policy() {
7414 let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
7415 let config = parse_hook_config_from_yaml(yaml).unwrap();
7416 assert_eq!(config.binding, HookBinding::OnTaskEnd);
7417 assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
7418 }
7419
7420 #[test]
7421 fn hook_config_unknown_commit_policy_lists_reopen_task() {
7422 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
7423 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7424 assert!(err.contains("reopen-task"), "error was: {err}");
7425 }
7426
7427 #[test]
7428 fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
7429 for binding in ["on-shell", "on-tool-call"] {
7430 let yaml =
7431 format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
7432 let config = parse_hook_config_from_yaml(&yaml).unwrap();
7433 assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
7434 }
7435 assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
7436 }
7437
7438 #[test]
7441 fn hook_config_deny_without_a_binding_is_rejected() {
7442 let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
7443 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7444 assert!(
7445 err.contains("requires an explicit binding"),
7446 "error was: {err}"
7447 );
7448 assert!(err.contains("on-shell"), "error was: {err}");
7449 assert!(err.contains("on-tool-call"), "error was: {err}");
7450 }
7451
7452 #[test]
7454 fn hook_config_deny_on_an_ungated_binding_is_rejected() {
7455 let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
7456 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7457 assert!(err.contains("is not valid for binding"), "error was: {err}");
7458 assert!(err.contains("replace-context"), "error was: {err}");
7459 }
7460
7461 #[test]
7463 fn hook_config_async_deny_is_rejected() {
7464 let yaml =
7465 "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
7466 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7467 assert!(err.contains("async-with-commit"), "error was: {err}");
7468 assert!(err.contains("deny"), "error was: {err}");
7469 }
7470
7471 #[test]
7472 fn hook_config_unknown_commit_policy_lists_deny() {
7473 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
7474 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7475 assert!(err.contains("deny"), "error was: {err}");
7476 }
7477
7478 #[test]
7481 fn hook_config_async_reopen_task_is_rejected() {
7482 let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
7483 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
7484 assert!(err.contains("async-with-commit"), "error was: {err}");
7485 assert!(err.contains("reopen-task"), "error was: {err}");
7486 }
7487
7488 #[test]
7494 fn hook_config_non_committing_binding_rejects_any_commit_policy() {
7495 for binding in ["on-session-start", "on-session-end"] {
7496 let yaml = format!(
7497 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
7498 );
7499 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
7500 assert!(err.contains(binding), "error was: {err}");
7501 assert!(err.contains("replace-context"), "error was: {err}");
7502 assert!(
7503 err.contains("honors commit_policy 'none'"),
7504 "error was: {err}"
7505 );
7506 }
7507 }
7508
7509 #[test]
7512 fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
7513 for (binding, declared, honored) in [
7514 ("on-stage", "reopen-task", "write-manifests"),
7515 ("on-compaction", "write-manifests", "replace-context"),
7516 ("on-task-end", "replace-context", "reopen-task"),
7517 ("on-task-start", "replace-context", "seed-context"),
7518 ("on-shell", "replace-context", "deny"),
7519 ("on-tool-call", "replace-context", "deny"),
7520 ] {
7521 let yaml = format!(
7522 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
7523 );
7524 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
7525 assert!(err.contains(binding), "error was: {err}");
7526 assert!(
7527 err.contains(&format!("commit_policy '{declared}' is not valid")),
7528 "error was: {err}"
7529 );
7530 assert!(
7531 err.contains(&format!("honors commit_policy '{honored}'")),
7532 "error was: {err}"
7533 );
7534 }
7535 }
7536
7537 #[test]
7540 fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
7541 for declared in ["replace-context", "write-manifests", "reopen-task"] {
7542 let yaml = format!(
7543 "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
7544 );
7545 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
7546 assert!(err.contains("on-inference"), "error was: {err}");
7547 assert!(err.contains(declared), "error was: {err}");
7548 assert!(
7549 err.contains("honors commit_policy 'none'"),
7550 "error was: {err}"
7551 );
7552 assert!(err.contains("artifact"), "error was: {err}");
7553 }
7554 }
7555
7556 #[test]
7559 fn hook_config_matching_binding_and_commit_policy_is_valid() {
7560 for (binding, policy, expected) in [
7561 (
7562 "on-stage",
7563 "write-manifests",
7564 HookCommitPolicy::WriteManifests,
7565 ),
7566 (
7567 "on-compaction",
7568 "replace-context",
7569 HookCommitPolicy::ReplaceContext,
7570 ),
7571 ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
7572 ("on-shell", "deny", HookCommitPolicy::Deny),
7573 ("on-tool-call", "deny", HookCommitPolicy::Deny),
7574 ] {
7575 let yaml =
7576 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
7577 let config = parse_hook_config_from_yaml(&yaml).unwrap();
7578 assert_eq!(config.commit_policy, expected);
7579 }
7580 }
7581
7582 #[test]
7585 fn hook_config_commit_policy_none_is_valid_for_every_binding() {
7586 for binding in [
7587 "on-stage",
7588 "on-session-start",
7589 "on-task-start",
7590 "on-inference",
7591 "on-tool-call",
7592 "on-shell",
7593 "on-compaction",
7594 "on-task-end",
7595 "on-session-end",
7596 ] {
7597 let yaml =
7598 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
7599 let config = parse_hook_config_from_yaml(&yaml).unwrap();
7600 assert_eq!(config.commit_policy, HookCommitPolicy::None);
7601 }
7602 }
7603
7604 #[test]
7609 fn hook_config_omitted_binding_accepts_every_commit_policy() {
7610 for (policy, expected) in [
7611 ("replace-context", HookCommitPolicy::ReplaceContext),
7612 ("write-manifests", HookCommitPolicy::WriteManifests),
7613 ("reopen-task", HookCommitPolicy::ReopenTask),
7614 ("none", HookCommitPolicy::None),
7615 ] {
7616 let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
7617 let config = parse_hook_config_from_yaml(&yaml).unwrap();
7618 assert_eq!(config.binding, HookBinding::All);
7619 assert_eq!(config.commit_policy, expected);
7620 }
7621 }
7622
7623 #[test]
7628 fn hook_config_shipped_default_artifact_manifests_still_parse() {
7629 let shipped: [(&str, &str, HookConfig); 8] = [
7630 (
7631 "murmur-hook-compact",
7632 "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
7633 HookConfig {
7634 binding: HookBinding::OnCompaction,
7635 execution_mode: HookExecutionMode::Blocking,
7636 commit_policy: HookCommitPolicy::ReplaceContext,
7637 },
7638 ),
7639 (
7640 "murmur-hook-shell-desc",
7641 "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
7642 HookConfig {
7643 binding: HookBinding::OnStage,
7644 execution_mode: HookExecutionMode::Blocking,
7645 commit_policy: HookCommitPolicy::WriteManifests,
7646 },
7647 ),
7648 (
7649 "murmur-hook-regression-verifier",
7651 "execution_mode: blocking\ncommit_policy: reopen-task\n",
7652 HookConfig {
7653 binding: HookBinding::All,
7654 execution_mode: HookExecutionMode::Blocking,
7655 commit_policy: HookCommitPolicy::ReopenTask,
7656 },
7657 ),
7658 (
7659 "murmur-hook-memory",
7660 "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
7661 HookConfig {
7662 binding: HookBinding::OnTaskStart,
7663 execution_mode: HookExecutionMode::Blocking,
7664 commit_policy: HookCommitPolicy::SeedContext,
7665 },
7666 ),
7667 (
7668 "murmur-hook-debug",
7669 "execution_mode: async\ncommit_policy: none\n",
7670 HookConfig {
7671 binding: HookBinding::All,
7672 execution_mode: HookExecutionMode::Async,
7673 commit_policy: HookCommitPolicy::None,
7674 },
7675 ),
7676 (
7677 "murmur-hook-diff-summary",
7678 "execution_mode: blocking\ncommit_policy: none\n",
7679 HookConfig {
7680 binding: HookBinding::All,
7681 execution_mode: HookExecutionMode::Blocking,
7682 commit_policy: HookCommitPolicy::None,
7683 },
7684 ),
7685 ("murmur-hook-eval", "", HookConfig::default()),
7686 ("murmur-hook-grafana", "", HookConfig::default()),
7687 ];
7688
7689 for (name, fields, expected) in shipped {
7690 let yaml = format!("name: {name}\nruntime: hook\n{fields}");
7691 let config = parse_hook_config_from_yaml(&yaml)
7692 .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
7693 assert_eq!(config, expected, "shipped hook {name}");
7694 }
7695 }
7696
7697 #[test]
7701 fn commit_policy_for_binding_matches_the_declarable_pairs() {
7702 assert_eq!(
7703 commit_policy_for_binding(&HookBinding::OnStage),
7704 Some(HookCommitPolicy::WriteManifests)
7705 );
7706 assert_eq!(
7707 commit_policy_for_binding(&HookBinding::OnCompaction),
7708 Some(HookCommitPolicy::ReplaceContext)
7709 );
7710 assert_eq!(
7711 commit_policy_for_binding(&HookBinding::OnTaskEnd),
7712 Some(HookCommitPolicy::ReopenTask)
7713 );
7714 assert_eq!(
7715 commit_policy_for_binding(&HookBinding::OnTaskStart),
7716 Some(HookCommitPolicy::SeedContext)
7717 );
7718 assert_eq!(
7719 commit_policy_for_binding(&HookBinding::OnToolCall),
7720 Some(HookCommitPolicy::Deny)
7721 );
7722 assert_eq!(
7723 commit_policy_for_binding(&HookBinding::OnShell),
7724 Some(HookCommitPolicy::Deny)
7725 );
7726 for binding in [
7730 HookBinding::OnSessionStart,
7731 HookBinding::OnInference,
7732 HookBinding::OnSessionEnd,
7733 HookBinding::All,
7734 ] {
7735 assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
7736 }
7737 }
7738
7739 #[test]
7742 fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
7743 let config = parse_hook_config_from_yaml(
7744 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
7745 commit_policy: seed-context\n",
7746 )
7747 .expect("on-task-start + seed-context is the declarable pairing");
7748 assert_eq!(config.binding, HookBinding::OnTaskStart);
7749 assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
7750
7751 let error = parse_hook_config_from_yaml(
7752 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
7753 commit_policy: seed-context\n",
7754 )
7755 .expect_err("on-compaction cannot commit a seed");
7756 assert!(error.contains("on-compaction"), "{error}");
7757 assert!(error.contains("seed-context"), "{error}");
7758 }
7759
7760 #[test]
7763 fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
7764 let error = parse_hook_config_from_yaml(
7765 "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
7766 )
7767 .expect_err("a misspelled policy is rejected");
7768 assert!(error.contains("seed-context"), "{error}");
7769 }
7770
7771 fn context_of(yaml_block: &str) -> Option<ContextConfig> {
7774 RuntimeManifest::from_yaml_str(&format!(
7775 "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
7776 ))
7777 .expect("the manifest under test parses")
7778 .context
7779 }
7780
7781 #[test]
7782 fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
7783 let context = context_of(
7784 "context:\n max_tokens: 200000\n seed_budget: 0.10\n seed_overflow_margin: 0.10\n",
7785 )
7786 .expect("a declared context block parses to Some");
7787 assert_eq!(context.max_tokens, Some(200_000));
7788 assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
7789 assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
7790 }
7791
7792 #[test]
7793 fn seed_budget_and_overflow_margin_default_when_omitted() {
7794 let context = context_of("context:\n max_tokens: 200000\n")
7795 .expect("a declared context block parses to Some");
7796 assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
7797 assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
7798 }
7799
7800 #[test]
7803 fn seed_budget_defaults_do_not_create_a_context_block() {
7804 assert_eq!(context_of(""), None);
7805 }
7806
7807 #[test]
7808 fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
7809 for (block, field) in [
7810 ("context:\n seed_budget: 1.5\n", "context.seed_budget"),
7811 ("context:\n seed_budget: -0.1\n", "context.seed_budget"),
7812 (
7813 "context:\n seed_overflow_margin: 2.0\n",
7814 "context.seed_overflow_margin",
7815 ),
7816 ] {
7817 let error = RuntimeManifest::from_yaml_str(&format!(
7818 "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
7819 ))
7820 .expect_err("an out-of-range fraction is rejected");
7821 match error {
7822 RuntimeManifestError::InvalidInferenceConfig {
7823 field: got,
7824 ref message,
7825 } => {
7826 assert_eq!(got, field);
7827 assert!(message.contains("0.0 and 1.0"), "{message}");
7828 }
7829 other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
7830 }
7831 }
7832 }
7833
7834 #[test]
7835 fn parse_tool_implementation_defaults_to_wasm() {
7836 let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
7837 assert_eq!(impl_, ArtifactImplementation::Wasm);
7838 }
7839
7840 #[test]
7841 fn parse_tool_implementation_native() {
7842 let impl_ = parse_tool_implementation_from_yaml(
7843 "name: my-tool\nruntime: tool\nimplementation: native\n",
7844 );
7845 assert_eq!(impl_, ArtifactImplementation::Native);
7846 }
7847
7848 #[test]
7849 fn mur_version_parses_correctly() {
7850 let manifest = RuntimeManifest::from_yaml_str(
7851 "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"0.4.5\"\n",
7852 )
7853 .unwrap();
7854 assert_eq!(manifest.mur_version, Some("0.4.5".to_string()));
7855 }
7856
7857 #[test]
7858 fn mur_version_absent_is_none() {
7859 let manifest =
7860 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
7861 assert_eq!(manifest.mur_version, None);
7862 }
7863
7864 #[test]
7865 fn mur_version_empty_string_is_none() {
7866 let manifest = RuntimeManifest::from_yaml_str(
7867 "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"\"\n",
7868 )
7869 .unwrap();
7870 assert_eq!(manifest.mur_version, None);
7871 }
7872
7873 #[test]
7874 fn inference_max_turns_defaults_to_10() {
7875 let manifest = RuntimeManifest::from_yaml_str(
7876 "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",
7877 ).unwrap();
7878 assert_eq!(manifest.inference.unwrap().max_turns, 10);
7879 }
7880
7881 #[test]
7882 fn inference_max_turns_explicit_value() {
7883 let manifest = RuntimeManifest::from_yaml_str(
7884 "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",
7885 ).unwrap();
7886 assert_eq!(manifest.inference.unwrap().max_turns, 20);
7887 }
7888
7889 #[test]
7890 fn inference_max_turns_zero_is_rejected() {
7891 let err = RuntimeManifest::from_yaml_str(
7892 "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",
7893 ).unwrap_err();
7894 let msg = err.to_string();
7895 assert!(msg.contains("inference.max_turns"), "error was: {msg}");
7896 assert!(msg.contains("greater than 0"), "error was: {msg}");
7897 }
7898
7899 #[test]
7901 fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
7902 let manifest = RuntimeManifest::from_yaml_str(
7903 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 45\n",
7904 )
7905 .unwrap();
7906 let lifecycle = manifest.lifecycle.unwrap();
7907 assert_eq!(lifecycle.shell_grace_secs, 45);
7908 assert_eq!(
7910 lifecycle.task_acceptance,
7911 LifecycleConfig::default().task_acceptance
7912 );
7913 assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
7914 assert_eq!(
7915 lifecycle.queue_depth,
7916 LifecycleConfig::default().queue_depth
7917 );
7918 assert_eq!(lifecycle.input_timeout_secs, None);
7919 assert_eq!(
7920 lifecycle.conversation_mode,
7921 LifecycleConfig::default().conversation_mode
7922 );
7923 assert_eq!(
7924 lifecycle.max_task_reopens,
7925 LifecycleConfig::default().max_task_reopens
7926 );
7927 }
7928
7929 #[test]
7930 fn lifecycle_shell_grace_secs_defaults_to_10() {
7931 let manifest = RuntimeManifest::from_yaml_str(
7932 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
7933 )
7934 .unwrap();
7935 let lifecycle = manifest.lifecycle.unwrap();
7936 assert_eq!(lifecycle.shell_grace_secs, 10);
7937 assert_eq!(lifecycle.queue_depth, 3);
7938
7939 let without_block =
7940 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
7941 assert_eq!(without_block.effective_lifecycle().shell_grace_secs, 10);
7942 }
7943
7944 #[test]
7947 fn lifecycle_delegation_deadline_secs_defaults_to_600() {
7948 let without_block =
7949 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
7950 assert_eq!(
7951 without_block.effective_lifecycle().delegation_deadline_secs,
7952 600
7953 );
7954
7955 let without_key = RuntimeManifest::from_yaml_str(
7956 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
7957 )
7958 .unwrap();
7959 let lifecycle = without_key.lifecycle.unwrap();
7960 assert_eq!(lifecycle.delegation_deadline_secs, 600);
7961 assert_eq!(lifecycle.queue_depth, 3);
7962 }
7963
7964 #[test]
7965 fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
7966 let manifest = RuntimeManifest::from_yaml_str(
7967 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
7968 delegation_deadline_secs: 30\n",
7969 )
7970 .unwrap();
7971 let lifecycle = manifest.lifecycle.unwrap();
7972 assert_eq!(lifecycle.delegation_deadline_secs, 30);
7973 assert_eq!(
7975 lifecycle.shell_grace_secs,
7976 LifecycleConfig::default().shell_grace_secs
7977 );
7978 assert_eq!(
7979 lifecycle.max_task_reopens,
7980 LifecycleConfig::default().max_task_reopens
7981 );
7982 }
7983
7984 #[test]
7986 fn lifecycle_delegation_deadline_secs_is_a_known_key() {
7987 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
7988 let manifest = RuntimeManifest::from_yaml_str(
7989 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
7990 delegation_deadline_secs: 30\n delegation_deadline: 30\n",
7991 )
7992 .unwrap();
7993 let reported: Vec<(String, String)> = manifest
7994 .unknown_keys
7995 .iter()
7996 .map(|key| (key.key.clone(), key.block_path.clone()))
7997 .collect();
7998 assert_eq!(
7999 reported,
8000 vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
8001 "the declared key is known and only the misspelt neighbour is reported"
8002 );
8003 assert_eq!(
8004 manifest.lifecycle.unwrap().delegation_deadline_secs,
8005 30,
8006 "the misspelt neighbour does not disturb the declared value"
8007 );
8008 }
8009
8010 #[test]
8013 fn lifecycle_shell_grace_secs_zero_is_accepted() {
8014 let manifest = RuntimeManifest::from_yaml_str(
8015 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 0\n",
8016 )
8017 .unwrap();
8018 let lifecycle = manifest.lifecycle.unwrap();
8019 assert_eq!(lifecycle.shell_grace_secs, 0);
8020 assert_eq!(
8021 lifecycle.max_task_reopens,
8022 LifecycleConfig::default().max_task_reopens
8023 );
8024 }
8025
8026 #[test]
8027 fn lifecycle_max_task_reopens_defaults_to_1() {
8028 let manifest = RuntimeManifest::from_yaml_str(
8029 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n after_task: sleep\n",
8030 )
8031 .unwrap();
8032 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
8033 }
8034
8035 #[test]
8037 fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
8038 let manifest =
8039 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
8040 assert!(manifest.lifecycle.is_none());
8041 assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
8042 }
8043
8044 #[test]
8045 fn lifecycle_max_task_reopens_explicit_value() {
8046 let manifest = RuntimeManifest::from_yaml_str(
8047 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 3\n",
8048 )
8049 .unwrap();
8050 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
8051 }
8052
8053 #[test]
8055 fn lifecycle_max_task_reopens_zero_is_accepted() {
8056 let manifest = RuntimeManifest::from_yaml_str(
8057 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 0\n",
8058 )
8059 .unwrap();
8060 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
8061 }
8062
8063 #[test]
8065 fn lifecycle_max_task_reopens_with_process_transport() {
8066 let manifest = RuntimeManifest::from_yaml_str(
8067 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\nlifecycle:\n max_task_reopens: 2\n",
8068 ).unwrap();
8069 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
8070 }
8071
8072 #[test]
8073 fn inference_max_task_reopens_is_rejected_under_http_transport() {
8074 let err = RuntimeManifest::from_yaml_str(
8075 "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",
8076 ).unwrap_err();
8077 assert!(
8078 matches!(
8079 &err,
8080 RuntimeManifestError::InvalidInferenceConfig { field, .. }
8081 if field == "inference.max_task_reopens"
8082 ),
8083 "error was: {err:?}"
8084 );
8085 let msg = err.to_string();
8086 assert!(
8087 msg.contains("lifecycle.max_task_reopens"),
8088 "error was: {msg}"
8089 );
8090 }
8091
8092 #[test]
8093 fn inference_max_task_reopens_is_rejected_under_process_transport() {
8094 let err = RuntimeManifest::from_yaml_str(
8095 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n max_task_reopens: 2\n",
8096 ).unwrap_err();
8097 assert!(
8098 matches!(
8099 &err,
8100 RuntimeManifestError::InvalidInferenceConfig { field, .. }
8101 if field == "inference.max_task_reopens"
8102 ),
8103 "error was: {err:?}"
8104 );
8105 let msg = err.to_string();
8106 assert!(
8107 msg.contains("lifecycle.max_task_reopens"),
8108 "error was: {msg}"
8109 );
8110 }
8111
8112 #[test]
8114 fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
8115 let err = RuntimeManifest::from_yaml_str(
8116 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n max_task_reopens: 2\nlifecycle:\n max_task_reopens: 3\n",
8117 ).unwrap_err();
8118 assert!(
8119 matches!(
8120 &err,
8121 RuntimeManifestError::InvalidInferenceConfig { field, .. }
8122 if field == "inference.max_task_reopens"
8123 ),
8124 "error was: {err:?}"
8125 );
8126 }
8127
8128 #[test]
8129 fn skill_is_llm_visible() {
8130 assert!(ArtifactRuntime::Skill.is_llm_visible());
8131 }
8132
8133 #[test]
8134 fn driver_and_hook_are_not_llm_visible() {
8135 assert!(!ArtifactRuntime::Driver.is_llm_visible());
8136 assert!(!ArtifactRuntime::Hook.is_llm_visible());
8137 }
8138
8139 #[test]
8140 fn parses_system_prompt_artifact() {
8141 let manifest = RuntimeManifest::from_yaml_str(
8142 r#"
8143name: cap
8144version: 0.0.1
8145artifacts:
8146 - name: my-skill
8147 version: 1.0.0
8148 runtime: skill
8149inference:
8150 endpoint: http://127.0.0.1:8080
8151 model: test-model
8152 system_prompt_artifact: my-skill
8153 driver:
8154 artifact: murmur-driver-anthropic
8155"#,
8156 )
8157 .unwrap();
8158
8159 let inference = manifest.inference.unwrap();
8160 assert_eq!(
8161 inference.system_prompt_artifact,
8162 Some("my-skill".to_string())
8163 );
8164 assert!(inference.system_prompt.is_none());
8165 assert!(inference.system_prompt_file.is_none());
8166 }
8167
8168 #[test]
8169 fn rejects_system_prompt_artifact_with_inline_prompt() {
8170 let err = RuntimeManifest::from_yaml_str(
8171 r#"
8172name: cap
8173version: 0.0.1
8174artifacts:
8175 - name: my-skill
8176 version: 1.0.0
8177 runtime: skill
8178inference:
8179 endpoint: http://127.0.0.1:8080
8180 model: test-model
8181 system_prompt: "inline"
8182 system_prompt_artifact: my-skill
8183 driver:
8184 artifact: murmur-driver-anthropic
8185"#,
8186 )
8187 .unwrap_err();
8188
8189 let msg = err.to_string();
8190 assert!(msg.contains("at most one"), "error was: {msg}");
8191 }
8192
8193 #[test]
8194 fn rejects_system_prompt_artifact_naming_non_skill() {
8195 let err = RuntimeManifest::from_yaml_str(
8196 r#"
8197name: cap
8198version: 0.0.1
8199artifacts:
8200 - name: my-tool
8201 version: 1.0.0
8202 runtime: tool
8203inference:
8204 endpoint: http://127.0.0.1:8080
8205 model: test-model
8206 system_prompt_artifact: my-tool
8207 driver:
8208 artifact: murmur-driver-anthropic
8209"#,
8210 )
8211 .unwrap_err();
8212
8213 let msg = err.to_string();
8214 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
8215 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
8216 }
8217
8218 #[test]
8219 fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
8220 let err = RuntimeManifest::from_yaml_str(
8221 r#"
8222name: cap
8223version: 0.0.1
8224artifacts: []
8225inference:
8226 endpoint: http://127.0.0.1:8080
8227 model: test-model
8228 system_prompt_artifact: missing-skill
8229 driver:
8230 artifact: murmur-driver-anthropic
8231"#,
8232 )
8233 .unwrap_err();
8234
8235 let msg = err.to_string();
8236 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
8237 assert!(msg.contains("not declared"), "error was: {msg}");
8238 }
8239
8240 #[test]
8241 fn rejects_all_three_system_prompt_sources() {
8242 let err = RuntimeManifest::from_yaml_str(
8243 r#"
8244name: cap
8245version: 0.0.1
8246artifacts:
8247 - name: my-skill
8248 version: 1.0.0
8249 runtime: skill
8250inference:
8251 endpoint: http://127.0.0.1:8080
8252 model: test-model
8253 system_prompt: "inline"
8254 system_prompt_file: "conventions.md"
8255 system_prompt_artifact: my-skill
8256 driver:
8257 artifact: murmur-driver-anthropic
8258"#,
8259 )
8260 .unwrap_err();
8261
8262 let msg = err.to_string();
8263 assert!(msg.contains("at most one"), "error was: {msg}");
8264 }
8265
8266 #[test]
8269 fn hook_artifact_capabilities_parse_network_and_filesystem() {
8270 let manifest = RuntimeManifest::from_yaml_str(
8271 r#"
8272name: cap
8273version: 0.0.1
8274artifacts:
8275 - name: telemetry-hook
8276 version: 1.0.0
8277 runtime: hook
8278 capabilities:
8279 network:
8280 allow:
8281 - https://telemetry.example.com
8282 filesystem:
8283 scope: hook-state
8284"#,
8285 )
8286 .unwrap();
8287
8288 let caps = manifest.artifacts[0]
8289 .capabilities
8290 .as_ref()
8291 .expect("hook entry carries a capability grant");
8292 assert_eq!(
8293 caps.network.as_ref().unwrap().allow,
8294 vec!["https://telemetry.example.com".to_string()]
8295 );
8296 assert_eq!(
8297 caps.filesystem.as_ref().unwrap().scope,
8298 Some("hook-state".to_string())
8299 );
8300 }
8301
8302 #[test]
8305 fn hook_artifact_without_capabilities_is_none() {
8306 let manifest = RuntimeManifest::from_yaml_str(
8307 r#"
8308name: cap
8309version: 0.0.1
8310artifacts:
8311 - name: quiet-hook
8312 version: 1.0.0
8313 runtime: hook
8314"#,
8315 )
8316 .unwrap();
8317
8318 assert!(manifest.artifacts[0].capabilities.is_none());
8319 }
8320
8321 #[test]
8324 fn tool_and_driver_artifact_capabilities_parse() {
8325 for role in ["tool", "driver"] {
8326 let yaml = format!(
8327 r#"
8328name: cap
8329version: 0.0.1
8330artifacts:
8331 - name: scoped
8332 version: 1.0.0
8333 runtime: {role}
8334 capabilities:
8335 network:
8336 allow:
8337 - https://api.example.com
8338 filesystem:
8339 scope: cache
8340"#
8341 );
8342 let manifest = RuntimeManifest::from_yaml_str(&yaml)
8343 .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
8344
8345 let caps = manifest.artifacts[0]
8346 .capabilities
8347 .as_ref()
8348 .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
8349 assert_eq!(
8350 caps.network.as_ref().unwrap().allow,
8351 vec!["https://api.example.com".to_string()]
8352 );
8353 assert_eq!(
8354 caps.filesystem.as_ref().unwrap().scope,
8355 Some("cache".to_string())
8356 );
8357 }
8358 }
8359
8360 #[test]
8363 fn tool_artifact_without_capabilities_is_none() {
8364 let manifest = RuntimeManifest::from_yaml_str(
8365 r#"
8366name: cap
8367version: 0.0.1
8368artifacts:
8369 - name: plain-tool
8370 version: 1.0.0
8371 runtime: tool
8372"#,
8373 )
8374 .unwrap();
8375
8376 assert!(manifest.artifacts[0].capabilities.is_none());
8377 }
8378
8379 #[test]
8382 fn skill_artifact_capabilities_are_rejected() {
8383 let yaml = r#"
8384name: cap
8385version: 0.0.1
8386artifacts:
8387 - name: sneaky
8388 version: 1.0.0
8389 runtime: skill
8390 capabilities:
8391 network:
8392 allow:
8393 - https://evil.example.com
8394"#;
8395 let err = match RuntimeManifest::from_yaml_str(yaml) {
8396 Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
8397 Err(err) => err,
8398 };
8399 let msg = err.to_string();
8400 assert!(msg.contains("sneaky"), "error was: {msg}");
8401 assert!(
8402 msg.contains(
8403 "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
8404 entries"
8405 ),
8406 "error was: {msg}"
8407 );
8408 assert!(msg.contains("runtime: skill"), "error was: {msg}");
8409 }
8410
8411 #[test]
8416 fn artifact_config_is_carried_verbatim() {
8417 let manifest = RuntimeManifest::from_yaml_str(
8418 r#"
8419name: cap
8420version: 0.0.1
8421artifacts:
8422 - name: murmur-tool-corpus
8423 version: 0.1.0
8424 runtime: tool
8425 config:
8426 read_recent: { default: 20, max: 100 }
8427"#,
8428 )
8429 .unwrap();
8430
8431 let config = manifest.artifacts[0]
8432 .config
8433 .as_ref()
8434 .expect("a declared block parses");
8435 assert_eq!(
8436 config["read_recent"]["max"],
8437 serde_yaml::Value::Number(100.into())
8438 );
8439 }
8440
8441 #[test]
8443 fn an_undeclared_config_is_none() {
8444 let manifest = RuntimeManifest::from_yaml_str(
8445 r#"
8446name: cap
8447version: 0.0.1
8448artifacts:
8449 - name: git
8450 version: 1.0.0
8451 runtime: tool
8452"#,
8453 )
8454 .unwrap();
8455
8456 assert!(manifest.artifacts[0].config.is_none());
8457 }
8458
8459 #[test]
8463 fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
8464 let manifest = RuntimeManifest::from_yaml_str(
8465 r#"
8466name: cap
8467version: 0.0.1
8468artifacts:
8469 - name: git
8470 version: 1.0.0
8471 runtime: tool
8472 config:
8473"#,
8474 )
8475 .unwrap();
8476
8477 assert_eq!(
8478 manifest.artifacts[0].config,
8479 Some(serde_yaml::Value::Null),
8480 "an empty block must survive parsing as a declaration, not vanish into None"
8481 );
8482 }
8483
8484 #[test]
8487 fn skill_artifact_config_is_rejected() {
8488 let yaml = r#"
8489name: cap
8490version: 0.0.1
8491artifacts:
8492 - name: sneaky
8493 version: 1.0.0
8494 runtime: skill
8495 config:
8496 who: me
8497"#;
8498 let err = match RuntimeManifest::from_yaml_str(yaml) {
8499 Ok(_) => panic!("config: must be rejected for runtime: skill"),
8500 Err(err) => err,
8501 };
8502 assert!(matches!(
8503 err,
8504 RuntimeManifestError::InvalidArtifact { index: 0, .. }
8505 ));
8506 let msg = err.to_string();
8507 assert!(msg.contains("sneaky"), "error was: {msg}");
8508 assert!(msg.contains("runtime: skill"), "error was: {msg}");
8509 assert!(
8510 msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
8511 "error was: {msg}"
8512 );
8513 }
8514
8515 #[test]
8518 fn a_capsule_wide_config_block_is_rejected() {
8519 let yaml = r#"
8520name: cap
8521version: 0.0.1
8522config:
8523 who: capsule
8524artifacts:
8525 - name: git
8526 version: 1.0.0
8527 runtime: tool
8528"#;
8529 let err = match RuntimeManifest::from_yaml_str(yaml) {
8530 Ok(_) => panic!("a capsule-wide config block must be rejected"),
8531 Err(err) => err,
8532 };
8533 let msg = err.to_string();
8534 assert!(msg.contains("config"), "error was: {msg}");
8535 assert!(msg.contains("declared per artifact"), "error was: {msg}");
8536 }
8537
8538 #[test]
8542 fn hook_on_overflow_block_parses() {
8543 let manifest = RuntimeManifest::from_yaml_str(
8544 r#"
8545name: cap
8546version: 0.0.1
8547artifacts:
8548 - name: murmur-hook-grafana
8549 version: 1.0.0
8550 runtime: hook
8551 on_overflow: block
8552"#,
8553 )
8554 .unwrap();
8555
8556 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
8557 }
8558
8559 #[test]
8562 fn hook_on_overflow_defaults_to_drop() {
8563 let manifest = RuntimeManifest::from_yaml_str(
8564 r#"
8565name: cap
8566version: 0.0.1
8567artifacts:
8568 - name: murmur-hook-grafana
8569 version: 1.0.0
8570 runtime: hook
8571"#,
8572 )
8573 .unwrap();
8574
8575 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
8576 assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
8577 }
8578
8579 #[test]
8582 fn on_overflow_on_a_non_hook_artifact_is_rejected() {
8583 let yaml = r#"
8584name: cap
8585version: 0.0.1
8586artifacts:
8587 - name: git
8588 version: 1.0.0
8589 runtime: tool
8590 on_overflow: block
8591"#;
8592 let err = match RuntimeManifest::from_yaml_str(yaml) {
8593 Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
8594 Err(err) => err,
8595 };
8596 assert!(matches!(
8597 err,
8598 RuntimeManifestError::InvalidArtifact { index: 0, .. }
8599 ));
8600 let msg = err.to_string();
8601 assert!(msg.contains("git"), "error was: {msg}");
8602 assert!(
8603 msg.contains("only recognized on 'runtime: hook' entries"),
8604 "error was: {msg}"
8605 );
8606 }
8607
8608 #[test]
8610 fn unknown_on_overflow_value_is_rejected() {
8611 let yaml = r#"
8612name: cap
8613version: 0.0.1
8614artifacts:
8615 - name: murmur-hook-grafana
8616 version: 1.0.0
8617 runtime: hook
8618 on_overflow: spill
8619"#;
8620 let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
8621 let msg = err.to_string();
8622 assert!(msg.contains("spill"), "error was: {msg}");
8623 assert!(msg.contains("expected: drop, block"), "error was: {msg}");
8624 }
8625
8626 #[test]
8629 fn on_overflow_is_accepted_on_any_hook_entry() {
8630 let manifest = RuntimeManifest::from_yaml_str(
8631 r#"
8632name: cap
8633version: 0.0.1
8634artifacts:
8635 - name: murmur-hook-eval
8636 version: 1.0.0
8637 runtime: hook
8638 on_overflow: drop
8639 capabilities:
8640 network:
8641 allow:
8642 - https://telemetry.example.com
8643"#,
8644 )
8645 .unwrap();
8646
8647 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
8648 assert!(manifest.artifacts[0].capabilities.is_some());
8649 }
8650
8651 #[test]
8654 fn capsule_wide_and_hook_capabilities_coexist() {
8655 let manifest = RuntimeManifest::from_yaml_str(
8656 r#"
8657name: cap
8658version: 0.0.1
8659artifacts:
8660 - name: telemetry-hook
8661 version: 1.0.0
8662 runtime: hook
8663 capabilities:
8664 network:
8665 allow:
8666 - https://telemetry.example.com
8667capabilities:
8668 network:
8669 allow:
8670 - https://api.anthropic.com
8671"#,
8672 )
8673 .unwrap();
8674
8675 assert_eq!(
8676 manifest.capabilities.unwrap().network.unwrap().allow,
8677 vec!["https://api.anthropic.com".to_string()]
8678 );
8679 assert_eq!(
8680 manifest.artifacts[0]
8681 .capabilities
8682 .as_ref()
8683 .unwrap()
8684 .network
8685 .as_ref()
8686 .unwrap()
8687 .allow,
8688 vec!["https://telemetry.example.com".to_string()]
8689 );
8690 }
8691
8692 #[test]
8696 fn hook_own_manifest_capabilities_are_not_read_into_config() {
8697 let config = parse_hook_config_from_yaml(
8698 r#"
8699name: telemetry-hook
8700version: 1.0.0
8701binding: on-inference
8702execution_mode: blocking
8703capabilities:
8704 network:
8705 allow:
8706 - https://evil.example.com
8707 filesystem:
8708 scope: /
8709"#,
8710 )
8711 .expect("a self-declared capabilities block is ignored, not an error");
8712
8713 assert_eq!(
8714 config,
8715 HookConfig {
8716 binding: HookBinding::OnInference,
8717 execution_mode: HookExecutionMode::Blocking,
8718 commit_policy: HookCommitPolicy::None,
8719 }
8720 );
8721 }
8722
8723 #[test]
8726 fn containment_classes_order_weakest_to_strongest() {
8727 assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
8728 assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
8729 assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
8730 assert_eq!(
8731 ContainmentClass::ALL.iter().copied().max(),
8732 Some(ContainmentClass::Sealed)
8733 );
8734 }
8735
8736 #[test]
8737 fn containment_class_defaults_to_advisory() {
8738 assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
8739 }
8740
8741 #[test]
8742 fn containment_class_round_trips_through_string() {
8743 for class in ContainmentClass::ALL {
8744 assert_eq!(
8745 class.to_string().parse::<ContainmentClass>(),
8746 Ok(class),
8747 "{class} must survive Display -> FromStr"
8748 );
8749 }
8750 assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
8751 assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
8752 assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
8753 }
8754
8755 #[test]
8756 fn containment_class_rejects_unknown_names() {
8757 let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
8758 assert_eq!(err.value, "paranoid");
8759 assert_eq!(
8760 err.to_string(),
8761 "must be one of: advisory, scoped, sealed; got 'paranoid'"
8762 );
8763 assert!("Sealed".parse::<ContainmentClass>().is_err());
8765 }
8766
8767 #[test]
8768 fn containment_class_serializes_as_its_wire_name() {
8769 assert_eq!(
8770 serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
8771 "\"scoped\""
8772 );
8773 assert_eq!(
8774 serde_yaml::from_str::<ContainmentClass>("sealed").unwrap(),
8775 ContainmentClass::Sealed
8776 );
8777 }
8778
8779 #[test]
8780 fn effective_floor_is_advisory_when_nothing_is_declared() {
8781 assert_eq!(
8782 effective_containment_floor(None, None, None),
8783 ContainmentClass::Advisory
8784 );
8785 }
8786
8787 #[test]
8788 fn effective_floor_takes_the_strongest_across_every_presence_combination() {
8789 use ContainmentClass::{Advisory, Scoped, Sealed};
8790
8791 assert_eq!(
8793 effective_containment_floor(Some(Scoped), None, None),
8794 Scoped
8795 );
8796 assert_eq!(
8797 effective_containment_floor(None, Some(Scoped), None),
8798 Scoped
8799 );
8800 assert_eq!(
8801 effective_containment_floor(None, None, Some(Scoped)),
8802 Scoped
8803 );
8804 assert_eq!(
8805 effective_containment_floor(Some(Sealed), None, None),
8806 Sealed
8807 );
8808 assert_eq!(
8809 effective_containment_floor(None, Some(Sealed), None),
8810 Sealed
8811 );
8812 assert_eq!(
8813 effective_containment_floor(None, None, Some(Sealed)),
8814 Sealed
8815 );
8816
8817 assert_eq!(
8820 effective_containment_floor(Some(Advisory), Some(Sealed), None),
8821 Sealed
8822 );
8823 assert_eq!(
8824 effective_containment_floor(Some(Sealed), Some(Advisory), None),
8825 Sealed
8826 );
8827 assert_eq!(
8828 effective_containment_floor(Some(Advisory), None, Some(Scoped)),
8829 Scoped
8830 );
8831 assert_eq!(
8832 effective_containment_floor(Some(Scoped), None, Some(Advisory)),
8833 Scoped
8834 );
8835 assert_eq!(
8836 effective_containment_floor(None, Some(Advisory), Some(Scoped)),
8837 Scoped
8838 );
8839 assert_eq!(
8840 effective_containment_floor(None, Some(Scoped), Some(Advisory)),
8841 Scoped
8842 );
8843
8844 assert_eq!(
8846 effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
8847 Sealed
8848 );
8849 assert_eq!(
8850 effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
8851 Sealed
8852 );
8853 assert_eq!(
8854 effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
8855 Sealed
8856 );
8857 assert_eq!(
8858 effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
8859 Scoped
8860 );
8861 assert_eq!(
8862 effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
8863 Advisory
8864 );
8865 }
8866
8867 #[test]
8868 fn manifest_parses_each_declared_containment_class() {
8869 for class in ContainmentClass::ALL {
8870 let manifest = RuntimeManifest::from_yaml_str(&format!(
8871 r#"
8872name: cap
8873version: 0.0.1
8874capabilities:
8875 containment: {class}
8876"#
8877 ))
8878 .expect("a valid containment class parses");
8879
8880 assert_eq!(
8881 manifest.capabilities.unwrap().containment,
8882 Some(class),
8883 "capabilities.containment: {class}"
8884 );
8885 }
8886 }
8887
8888 #[test]
8889 fn manifest_without_containment_key_declares_nothing() {
8890 let manifest = RuntimeManifest::from_yaml_str(
8892 r#"
8893name: cap
8894version: 0.0.1
8895"#,
8896 )
8897 .unwrap();
8898 assert!(manifest.capabilities.is_none());
8899
8900 let manifest = RuntimeManifest::from_yaml_str(
8903 r#"
8904name: cap
8905version: 0.0.1
8906capabilities:
8907 network:
8908 allow:
8909 - https://api.example.com
8910"#,
8911 )
8912 .unwrap();
8913 assert_eq!(manifest.capabilities.unwrap().containment, None);
8914 }
8915
8916 #[test]
8917 fn manifest_rejects_an_unknown_containment_class() {
8918 let err = RuntimeManifest::from_yaml_str(
8919 r#"
8920name: cap
8921version: 0.0.1
8922capabilities:
8923 containment: paranoid
8924"#,
8925 )
8926 .expect_err("an unknown containment class is rejected at parse time");
8927
8928 match err {
8929 RuntimeManifestError::InvalidCapabilities { field, message } => {
8930 assert_eq!(field, "capabilities.containment");
8931 assert_eq!(
8932 message,
8933 "must be one of: advisory, scoped, sealed; got 'paranoid'"
8934 );
8935 }
8936 other => panic!("expected InvalidCapabilities, got {other:?}"),
8937 }
8938 }
8939
8940 fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
8943 RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
8944 }
8945
8946 fn export_error(block: &str) -> (String, String) {
8947 match exports_manifest(block).expect_err("this exports block must not parse") {
8948 RuntimeManifestError::InvalidExports { field, message } => (field, message),
8949 other => panic!("expected InvalidExports, got {other:?}"),
8950 }
8951 }
8952
8953 fn peer_fetch_error(block: &str) -> (String, String) {
8954 match exports_manifest(block).expect_err("this capabilities block must not parse") {
8955 RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
8956 other => panic!("expected InvalidCapabilities, got {other:?}"),
8957 }
8958 }
8959
8960 #[test]
8963 fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
8964 assert_eq!(parse_duration_secs("90").unwrap(), 90);
8965 assert_eq!(parse_duration_secs("30s").unwrap(), 30);
8966 assert_eq!(parse_duration_secs("15m").unwrap(), 900);
8967 assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
8968 assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
8969 assert_eq!(parse_duration_secs(" 2h ").unwrap(), 7200);
8970 assert_eq!(parse_duration_secs("0").unwrap(), 0);
8971 }
8972
8973 #[test]
8976 fn parse_duration_secs_rejects_every_other_spelling() {
8977 for input in [
8978 "",
8979 " ",
8980 "5 minutes",
8981 "30S",
8982 "1H",
8983 "5min",
8984 "1.5h",
8985 "-1",
8986 "m",
8987 "h",
8988 "1h30m",
8989 "0x10",
8990 "30 s",
8991 ] {
8992 let error = parse_duration_secs(input)
8993 .expect_err(&format!("'{input}' must not parse as a duration"));
8994 assert!(
8995 error.contains(DURATION_ACCEPTED_FORM),
8996 "'{input}' must be refused with the accepted form; got: {error}"
8997 );
8998 }
8999 }
9000
9001 #[test]
9002 fn parse_duration_secs_refuses_to_overflow() {
9003 assert!(parse_duration_secs("18446744073709551615h")
9004 .unwrap_err()
9005 .contains("overflows"));
9006 }
9007
9008 #[test]
9009 fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
9010 let manifest = exports_manifest("exports:\n peer_files:\n root: out/\n")
9011 .expect("a minimal peer_files block parses");
9012 let peer = manifest.exports.unwrap().peer_files.unwrap();
9013 assert_eq!(peer.root, "out/");
9014 assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
9015 assert_eq!(peer.max_bytes, 10_485_760);
9016 assert_eq!(peer.max_ttl_secs, None);
9019 assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
9020 assert_eq!(peer.effective_max_ttl_secs(), 3600);
9021 }
9022
9023 #[test]
9024 fn peer_files_accepts_every_duration_spelling() {
9025 for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
9026 let manifest = exports_manifest(&format!(
9027 "exports:\n peer_files:\n root: out/\n max_ttl: {declared}\n"
9028 ))
9029 .expect("a declared max_ttl parses");
9030 let peer = manifest.exports.unwrap().peer_files.unwrap();
9031 assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
9032 assert_eq!(peer.effective_max_ttl_secs(), expected);
9033 }
9034 }
9035
9036 #[test]
9039 fn the_two_export_blocks_are_independent() {
9040 let both = exports_manifest(
9041 "exports:\n files:\n root: out/\n mode: read-only\n \
9042 peer_files:\n root: out/handoff/\n",
9043 )
9044 .expect("both blocks parse together")
9045 .exports
9046 .unwrap();
9047 assert_eq!(both.files.unwrap().root, "out/");
9048 assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
9049
9050 let only_files =
9051 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
9052 .unwrap()
9053 .exports
9054 .unwrap();
9055 assert!(only_files.peer_files.is_none());
9056
9057 let only_peer = exports_manifest("exports:\n peer_files:\n root: out/\n")
9058 .unwrap()
9059 .exports
9060 .unwrap();
9061 assert!(only_peer.files.is_none());
9062
9063 let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
9064 assert!(neither.files.is_none() && neither.peer_files.is_none());
9065 }
9066
9067 #[test]
9068 fn peer_files_root_must_be_relative_and_inside_the_workdir() {
9069 for root in ["../out", "/etc", "out/../../etc"] {
9070 let (field, message) =
9071 export_error(&format!("exports:\n peer_files:\n root: {root}\n"));
9072 assert_eq!(field, "exports.peer_files.root", "root: {root}");
9073 assert!(
9074 message.contains(EXPORT_ROOT_ACCEPTED_FORM),
9075 "root: {root}; got: {message}"
9076 );
9077 }
9078 }
9079
9080 #[test]
9081 fn peer_files_root_is_required() {
9082 let (field, message) = export_error("exports:\n peer_files: {}\n");
9083 assert_eq!(field, "exports.peer_files.root");
9084 assert!(message.contains("is required"));
9085 assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
9086 }
9087
9088 #[test]
9089 fn peer_files_max_ttl_states_its_accepted_form() {
9090 for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
9091 let (field, message) = export_error(&format!(
9092 "exports:\n peer_files:\n root: out/\n max_ttl: \"{declared}\"\n"
9093 ));
9094 assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
9095 assert!(
9096 message.contains(DURATION_ACCEPTED_FORM),
9097 "max_ttl: {declared}; got: {message}"
9098 );
9099 }
9100 }
9101
9102 #[test]
9105 fn peer_files_max_ttl_rejects_zero() {
9106 let (field, message) =
9107 export_error("exports:\n peer_files:\n root: out/\n max_ttl: 0\n");
9108 assert_eq!(field, "exports.peer_files.max_ttl");
9109 assert!(message.contains("greater than zero"));
9110 }
9111
9112 #[test]
9113 fn peer_files_max_bytes_states_its_accepted_form() {
9114 let (field, message) =
9115 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 10MB\n");
9116 assert_eq!(field, "exports.peer_files.max_bytes");
9117 assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
9118 }
9119
9120 #[test]
9121 fn peer_files_max_bytes_rejects_zero() {
9122 let (field, message) =
9123 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 0\n");
9124 assert_eq!(field, "exports.peer_files.max_bytes");
9125 assert!(message.contains("greater than zero"));
9126 }
9127
9128 #[test]
9131 fn peer_fetch_allow_carries_its_declared_destinations() {
9132 let manifest = exports_manifest(
9133 "capabilities:\n peer_fetch:\n allow:\n - localhost:41234\n \
9134 - reporting.internal\n - https://gateway.example.com\n",
9135 )
9136 .expect("a peer_fetch block parses");
9137 assert_eq!(
9138 manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
9139 vec![
9140 "localhost:41234".to_string(),
9141 "reporting.internal".to_string(),
9142 "https://gateway.example.com".to_string(),
9143 ]
9144 );
9145 }
9146
9147 #[test]
9150 fn peer_fetch_allow_must_not_be_empty() {
9151 let (field, message) = peer_fetch_error("capabilities:\n peer_fetch:\n allow: []\n");
9152 assert_eq!(field, "capabilities.peer_fetch.allow");
9153 assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
9154 }
9155
9156 #[test]
9157 fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
9158 for entry in [
9159 "http://[not a host",
9160 "\"\"",
9161 "localhost:notaport",
9162 "http://example.com/path",
9163 "ftp://example.com",
9164 ] {
9165 let (field, message) = peer_fetch_error(&format!(
9166 "capabilities:\n peer_fetch:\n allow:\n - {entry}\n"
9167 ));
9168 assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
9169 assert!(
9170 message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
9171 "entry: {entry}; got: {message}"
9172 );
9173 }
9174 }
9175
9176 #[test]
9179 fn peer_fetch_does_not_widen_network_allow() {
9180 let capabilities = exports_manifest(
9181 "capabilities:\n network:\n allow:\n - api.example.com\n \
9182 peer_fetch:\n allow:\n - localhost:41234\n",
9183 )
9184 .unwrap()
9185 .capabilities
9186 .unwrap();
9187 assert_eq!(
9188 capabilities.network.unwrap().allow,
9189 vec!["api.example.com".to_string()]
9190 );
9191 assert_eq!(
9192 capabilities.peer_fetch.unwrap().allow,
9193 vec!["localhost:41234".to_string()]
9194 );
9195 }
9196
9197 #[test]
9198 fn an_absent_peer_fetch_block_is_absent() {
9199 assert!(exports_manifest("capabilities: {}\n")
9200 .unwrap()
9201 .capabilities
9202 .unwrap()
9203 .peer_fetch
9204 .is_none());
9205 }
9206
9207 #[test]
9208 fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
9209 assert_eq!(parse_byte_size("0").unwrap(), 0);
9210 assert_eq!(parse_byte_size("4096").unwrap(), 4096);
9211 assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
9212 assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
9213 assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
9214 assert_eq!(parse_byte_size(" 10Mi ").unwrap(), 10 * 1024 * 1024);
9215 }
9216
9217 #[test]
9220 fn parse_byte_size_rejects_every_other_spelling() {
9221 for input in [
9222 "", " ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
9223 "1Ki1",
9224 ] {
9225 let error = parse_byte_size(input)
9226 .expect_err(&format!("'{input}' must not parse as a byte size"));
9227 assert!(
9228 error.contains(BYTE_SIZE_ACCEPTED_FORM),
9229 "'{input}' must be refused with the accepted form; got: {error}"
9230 );
9231 }
9232 }
9233
9234 #[test]
9235 fn parse_byte_size_refuses_to_overflow() {
9236 assert!(parse_byte_size("18446744073709551615Gi")
9237 .unwrap_err()
9238 .contains("overflows"));
9239 }
9240
9241 #[test]
9242 fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
9243 let manifest =
9244 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
9245 .expect("a minimal exports block parses");
9246 let files = manifest.exports.unwrap().files.unwrap();
9247 assert_eq!(files.root, "out/");
9248 assert_eq!(files.mode, ExportMode::ReadOnly);
9249 assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
9250 assert_eq!(files.max_bytes, 10_485_760);
9251 }
9252
9253 #[test]
9254 fn exports_files_accepts_both_byte_spellings() {
9255 for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
9256 let manifest = exports_manifest(&format!(
9257 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: {declared}\n"
9258 ))
9259 .expect("a declared max_bytes parses");
9260 assert_eq!(
9261 manifest.exports.unwrap().files.unwrap().max_bytes,
9262 expected,
9263 "max_bytes: {declared}"
9264 );
9265 }
9266 }
9267
9268 #[test]
9271 fn an_exports_block_without_files_declares_no_plane() {
9272 let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
9273 assert!(manifest.exports.unwrap().files.is_none());
9274 }
9275
9276 #[test]
9277 fn an_absent_exports_block_is_absent() {
9278 let manifest = exports_manifest("").expect("a manifest without exports parses");
9279 assert!(manifest.exports.is_none());
9280 }
9281
9282 #[test]
9283 fn exports_files_root_must_be_relative_and_inside_the_workdir() {
9284 for root in ["../out", "/etc", "out/../../etc", "''"] {
9285 let (field, message) = export_error(&format!(
9286 "exports:\n files:\n root: {root}\n mode: read-only\n"
9287 ));
9288 assert_eq!(field, "exports.files.root", "root: {root}");
9289 assert!(
9290 message.contains("must be a relative path inside the workdir"),
9291 "root: {root}; got: {message}"
9292 );
9293 }
9294 }
9295
9296 #[test]
9297 fn exports_files_root_is_required() {
9298 let (field, message) = export_error("exports:\n files:\n mode: read-only\n");
9299 assert_eq!(field, "exports.files.root");
9300 assert_eq!(
9301 message,
9302 "is required and must be a relative path inside the workdir"
9303 );
9304 }
9305
9306 #[test]
9307 fn exports_files_mode_is_required_and_read_only() {
9308 let (field, message) = export_error("exports:\n files:\n root: out/\n");
9309 assert_eq!(field, "exports.files.mode");
9310 assert_eq!(message, "is required and must be 'read-only'");
9311
9312 let (field, message) =
9313 export_error("exports:\n files:\n root: out/\n mode: read-write\n");
9314 assert_eq!(field, "exports.files.mode");
9315 assert_eq!(message, "'read-write' must be 'read-only'");
9316 }
9317
9318 #[test]
9319 fn exports_files_max_bytes_states_its_accepted_form() {
9320 let (field, message) = export_error(
9321 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 10MB\n",
9322 );
9323 assert_eq!(field, "exports.files.max_bytes");
9324 assert_eq!(
9325 message,
9326 "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
9327 );
9328 }
9329
9330 #[test]
9333 fn exports_files_max_bytes_rejects_zero() {
9334 let (field, message) = export_error(
9335 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 0\n",
9336 );
9337 assert_eq!(field, "exports.files.max_bytes");
9338 assert!(message.contains("must be greater than zero"), "{message}");
9339 }
9340
9341 #[test]
9343 fn exports_files_root_accepts_a_current_dir_prefix() {
9344 let manifest =
9345 exports_manifest("exports:\n files:\n root: ./out\n mode: read-only\n")
9346 .expect("./out is a legal root");
9347 assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
9348 }
9349
9350 fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
9353 RuntimeManifest::from_yaml_str(yaml)
9354 .expect("fixture must parse")
9355 .unknown_keys
9356 .into_iter()
9357 .map(|key| (key.key, key.block_path, key.nearest_known))
9358 .collect()
9359 }
9360
9361 #[test]
9364 fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
9365 assert_eq!(
9366 unknown_keys_of(
9367 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n read-only:\n - tests\n"
9368 ),
9369 vec![(
9370 "read-only".to_string(),
9371 "capabilities.filesystem".to_string(),
9372 Some("read_only".to_string())
9373 )]
9374 );
9375 }
9376
9377 #[test]
9381 fn a_manifest_of_recognized_keys_reports_nothing() {
9382 assert!(unknown_keys_of(
9383 r#"
9384name: cap
9385version: 0.1.0
9386mur_version: "0.2.0"
9387artifacts:
9388 - name: notes-tool
9389 version: 0.1.0
9390 runtime: tool
9391 capabilities:
9392 filesystem:
9393 scope: notes
9394 network:
9395 allow:
9396 - example.com:443
9397capabilities:
9398 filesystem:
9399 read_only:
9400 - tests
9401 - bench/fixtures
9402 workdir_exec: false
9403 shell:
9404 allow:
9405 - git
9406 - python3
9407 network:
9408 allow:
9409 - example.com:443
9410 unix_sockets: false
9411"#
9412 )
9413 .is_empty());
9414 }
9415
9416 #[test]
9419 fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
9420 assert_eq!(
9421 unknown_keys_of(
9422 "name: cap\nversion: 0.1.0\nartifacts:\n - name: t\n version: 0.1.0\n \
9423 runtime: tool\n capabilities:\n shell:\n allow:\n - git\n \
9424 allwo:\n - git\n"
9425 ),
9426 vec![(
9427 "allwo".to_string(),
9428 "artifacts[0].capabilities.shell".to_string(),
9429 Some("allow".to_string())
9430 )]
9431 );
9432 }
9433
9434 #[test]
9437 fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
9438 assert_eq!(
9439 unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
9440 vec![("quantum_teleport".to_string(), String::new(), None)]
9441 );
9442 }
9443
9444 #[test]
9449 fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
9450 assert!(unknown_keys_of(
9451 "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
9452 )
9453 .is_empty());
9454 }
9455
9456 #[test]
9458 fn every_build_side_key_is_recognized() {
9459 assert!(unknown_keys_of(
9460 "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
9461 wasm\nrequires_files:\n - tool.wasm\n - assets/logo.png\n"
9462 )
9463 .is_empty());
9464 }
9465
9466 #[test]
9469 fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
9470 assert_eq!(
9471 unknown_keys_of(
9472 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
9473 static\nrequires_files:\n - capsule.wasm\nquantum_teleport: true\n"
9474 ),
9475 vec![("quantum_teleport".to_string(), String::new(), None)]
9476 );
9477 }
9478
9479 #[test]
9483 fn the_build_side_keys_change_nothing_the_run_path_reads() {
9484 let body = "artifacts:\n - name: notes-tool\n version: 0.1.0\n runtime: tool\n\
9485 capabilities:\n filesystem:\n read_only:\n - tests\n\
9486 inference:\n transport: http\n endpoint: http://127.0.0.1:8080\n model: \
9487 test-model\n driver:\n artifact: murmur-driver-anthropic\n";
9488 let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
9489 .expect("fixture must parse");
9490 let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
9491 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n \
9492 any: shape\nrequires_files: not-a-list\n{body}"
9493 ))
9494 .expect("a build-side key of any shape never refuses a manifest");
9495
9496 assert!(with_build_keys.unknown_keys.is_empty());
9497 assert!(
9498 !plain.artifacts.is_empty()
9499 && plain.capabilities.is_some()
9500 && plain.inference.is_some(),
9501 "the fixture must populate the blocks the comparison below rests on, or it \
9502 compares absence against absence"
9503 );
9504 assert_eq!(with_build_keys.name, plain.name);
9505 assert_eq!(with_build_keys.version, plain.version);
9506 assert_eq!(with_build_keys.artifacts, plain.artifacts);
9507 assert_eq!(with_build_keys.capabilities, plain.capabilities);
9508 assert_eq!(with_build_keys.inference, plain.inference);
9509 }
9510
9511 #[test]
9514 fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
9515 let manifest = RuntimeManifest::from_yaml_str(
9516 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n scope: notes\n \
9517 read-only:\n - tests\n",
9518 )
9519 .expect("an unrecognized key never refuses a manifest");
9520 assert_eq!(
9521 manifest
9522 .capabilities
9523 .as_ref()
9524 .and_then(|caps| caps.filesystem.as_ref())
9525 .and_then(|fs| fs.scope.as_deref()),
9526 Some("notes")
9527 );
9528 assert_eq!(manifest.unknown_keys.len(), 1);
9529 }
9530
9531 #[test]
9533 fn nested_blocks_outside_capabilities_are_walked_too() {
9534 let keys = unknown_keys_of(
9535 "name: cap\nversion: 0.1.0\ncontext:\n max_tokns: 100\nexports:\n files:\n \
9536 root: out\n mode: read-only\n max_byts: 10\n",
9537 );
9538 assert_eq!(
9539 keys,
9540 vec![
9541 (
9542 "max_tokns".to_string(),
9543 "context".to_string(),
9544 Some("max_tokens".to_string())
9545 ),
9546 (
9547 "max_byts".to_string(),
9548 "exports.files".to_string(),
9549 Some("max_bytes".to_string())
9550 ),
9551 ]
9552 );
9553 }
9554
9555 #[test]
9562 fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
9563 let source = include_str!("runtime_manifest.rs");
9564 let lines: Vec<&str> = source.lines().collect();
9565
9566 let mut checked = 0usize;
9567 for (start, line) in lines.iter().enumerate() {
9568 let Some(name) = line
9569 .strip_prefix("struct Raw")
9570 .and_then(|rest| rest.strip_suffix(" {"))
9571 else {
9572 continue;
9573 };
9574 let name = format!("Raw{name}");
9575 let end = (start + 1..lines.len())
9576 .find(|index| lines[*index] == "}")
9577 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
9578 let body = &lines[start + 1..end];
9579
9580 assert!(
9581 body.contains(&" #[serde(flatten)]")
9582 && body.contains(&" unknown: UnknownKeys,"),
9583 "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
9584 so a key it does not recognize would be dropped instead of reported"
9585 );
9586
9587 let declared = serde_field_names(body);
9588 let known = known_keys_of(source, &name);
9589 assert_eq!(
9590 declared, known,
9591 "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
9592 suggester would propose the wrong set for that block"
9593 );
9594 checked += 1;
9595 }
9596 assert!(
9597 checked >= 30,
9598 "expected every Raw* struct to be scanned, found only {checked}"
9599 );
9600 }
9601
9602 #[test]
9608 fn every_raw_struct_descends_into_the_blocks_it_owns() {
9609 let source = include_str!("runtime_manifest.rs");
9610 let lines: Vec<&str> = source.lines().collect();
9611
9612 let mut structs = 0usize;
9613 let mut children = 0usize;
9614 for (start, line) in lines.iter().enumerate() {
9615 let Some(name) = line
9616 .strip_prefix("struct Raw")
9617 .and_then(|rest| rest.strip_suffix(" {"))
9618 else {
9619 continue;
9620 };
9621 let name = format!("Raw{name}");
9622 let end = (start + 1..lines.len())
9623 .find(|index| lines[*index] == "}")
9624 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
9625 let owned = block_typed_fields(&lines[start + 1..end]);
9626 structs += 1;
9627
9628 if owned.is_empty() {
9629 continue;
9630 }
9631 let block = raw_block_impl_of(source, &name);
9632 assert!(
9633 block.contains("fn walk_children"),
9634 "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
9635 `walk_children`, so every key written inside {} would go unreported",
9636 if owned.len() == 1 { "" } else { "s" },
9637 owned.join(", "),
9638 if owned.len() == 1 { "it" } else { "them" }
9639 );
9640 for field in &owned {
9641 assert!(
9642 mentions_field(&block, field),
9643 "{name}'s `walk_children` never touches `self.{field}`, so every key written \
9644 inside that block would go unreported — descend into it with \
9645 `collect_block(.., &child_path(path, \"{field}\"), out)`"
9646 );
9647 children += 1;
9648 }
9649 }
9650 assert!(
9651 structs >= 30,
9652 "expected every Raw* struct to be scanned, found only {structs}"
9653 );
9654 assert!(
9655 children >= 31,
9656 "expected every nested block field to be scanned, found only {children}"
9657 );
9658 }
9659
9660 fn mentions_field(impl_body: &str, field: &str) -> bool {
9664 let needle = format!("self.{field}");
9665 impl_body.match_indices(&needle).any(|(at, _)| {
9666 impl_body[at + needle.len()..]
9667 .chars()
9668 .next()
9669 .is_none_or(|next| !next.is_alphanumeric() && next != '_')
9670 })
9671 }
9672
9673 fn block_typed_fields(body: &[&str]) -> Vec<String> {
9677 let mut fields = Vec::new();
9678 for line in body {
9679 let trimmed = line.trim();
9680 if trimmed.starts_with("//") || trimmed.starts_with("#[") {
9681 continue;
9682 }
9683 let Some((field, declared)) = line
9684 .strip_prefix(" ")
9685 .and_then(|rest| rest.split_once(':'))
9686 else {
9687 continue;
9688 };
9689 if field.is_empty()
9690 || !field
9691 .chars()
9692 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
9693 {
9694 continue;
9695 }
9696 if declared.contains("Raw") {
9697 fields.push(field.to_string());
9698 }
9699 }
9700 fields
9701 }
9702
9703 fn raw_block_impl_of(source: &str, name: &str) -> String {
9706 let header = format!("impl RawBlock for {name} {{");
9707 let body = source
9708 .split_once(&header)
9709 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
9710 .1;
9711 body.split_once("\n}\n")
9712 .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
9713 .0
9714 .to_string()
9715 }
9716
9717 fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
9724 let raw: RawRuntimeManifest = serde_yaml::from_str(yaml).expect("fixture must parse");
9725 collect_unknown_keys(&raw)
9726 .into_iter()
9727 .map(|key| (key.key, key.block_path))
9728 .collect()
9729 }
9730
9731 #[test]
9735 fn every_block_reports_its_own_unknown_key_at_its_own_path() {
9736 let keys = raw_unknown_keys(
9737 r#"
9738name: every-block
9739version: 0.1.0
9740probe_root: 1
9741artifacts:
9742 - name: t
9743 version: 0.1.0
9744 runtime: tool
9745 probe_artifact: 1
9746 capabilities:
9747 probe_artifact_capabilities: 1
9748 shell:
9749 probe_artifact_shell: 1
9750capabilities:
9751 probe_capabilities: 1
9752 network:
9753 probe_network: 1
9754 peer_fetch:
9755 probe_peer_fetch: 1
9756 filesystem:
9757 probe_filesystem: 1
9758 shell:
9759 probe_shell: 1
9760 interpreter_runtime:
9761 - binary: python3
9762 probe_interpreter_runtime: 1
9763 dirs:
9764 - path: /usr/lib/python3
9765 probe_interpreter_runtime_dir: 1
9766 staged_runtime:
9767 - binary: rg
9768 probe_staged_runtime: 1
9769 spawn:
9770 probe_spawn: 1
9771 env:
9772 probe_env: 1
9773 limits:
9774 probe_limits: 1
9775 resources:
9776 probe_resources: 1
9777 state:
9778 probe_state: 1
9779 task_io:
9780 probe_task_io: 1
9781 conversation:
9782 probe_conversation: 1
9783inference:
9784 probe_inference: 1
9785 driver:
9786 probe_driver: 1
9787 provider:
9788 probe_provider: 1
9789 compaction:
9790 probe_compaction: 1
9791context:
9792 probe_context: 1
9793observability:
9794 probe_observability: 1
9795 eval:
9796 probe_eval: 1
9797 scorers:
9798 - type: exact_match
9799 probe_scorer: 1
9800trace:
9801 probe_trace: 1
9802network:
9803 probe_network_config: 1
9804lifecycle:
9805 probe_lifecycle: 1
9806exports:
9807 probe_exports: 1
9808 files:
9809 probe_files: 1
9810 peer_files:
9811 probe_peer_files: 1
9812"#,
9813 );
9814
9815 let expected: Vec<(String, String)> = [
9816 ("probe_root", ""),
9817 ("probe_artifact", "artifacts[0]"),
9818 ("probe_artifact_capabilities", "artifacts[0].capabilities"),
9819 ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
9820 ("probe_capabilities", "capabilities"),
9821 ("probe_network", "capabilities.network"),
9822 ("probe_peer_fetch", "capabilities.peer_fetch"),
9823 ("probe_filesystem", "capabilities.filesystem"),
9824 ("probe_shell", "capabilities.shell"),
9825 (
9826 "probe_interpreter_runtime",
9827 "capabilities.shell.interpreter_runtime[0]",
9828 ),
9829 (
9830 "probe_interpreter_runtime_dir",
9831 "capabilities.shell.interpreter_runtime[0].dirs[0]",
9832 ),
9833 (
9834 "probe_staged_runtime",
9835 "capabilities.shell.staged_runtime[0]",
9836 ),
9837 ("probe_spawn", "capabilities.spawn"),
9838 ("probe_env", "capabilities.env"),
9839 ("probe_limits", "capabilities.limits"),
9840 ("probe_resources", "capabilities.resources"),
9841 ("probe_state", "capabilities.state"),
9842 ("probe_task_io", "capabilities.task_io"),
9843 ("probe_conversation", "capabilities.conversation"),
9844 ("probe_inference", "inference"),
9845 ("probe_driver", "inference.driver"),
9846 ("probe_provider", "inference.provider"),
9847 ("probe_compaction", "inference.compaction"),
9848 ("probe_context", "context"),
9849 ("probe_observability", "observability"),
9850 ("probe_eval", "observability.eval"),
9851 ("probe_scorer", "observability.eval.scorers[0]"),
9852 ("probe_trace", "trace"),
9853 ("probe_network_config", "network"),
9854 ("probe_lifecycle", "lifecycle"),
9855 ("probe_exports", "exports"),
9856 ("probe_files", "exports.files"),
9857 ("probe_peer_files", "exports.peer_files"),
9858 ]
9859 .into_iter()
9860 .map(|(key, path)| (key.to_string(), path.to_string()))
9861 .collect();
9862
9863 assert_eq!(keys, expected);
9864 }
9865
9866 fn serde_field_names(body: &[&str]) -> Vec<String> {
9870 let mut names = Vec::new();
9871 let mut rename: Option<String> = None;
9872 for line in body {
9873 let trimmed = line.trim();
9874 if trimmed.starts_with("#[") {
9875 if let Some(rest) = trimmed.split("rename = \"").nth(1) {
9876 if !trimmed.contains("rename_all") {
9877 rename = rest.split('"').next().map(str::to_string);
9878 }
9879 }
9880 continue;
9881 }
9882 if trimmed.starts_with("//") {
9883 continue;
9884 }
9885 let Some(field) = line
9886 .strip_prefix(" ")
9887 .and_then(|rest| rest.split(':').next())
9888 else {
9889 continue;
9890 };
9891 if field.is_empty()
9892 || !field
9893 .chars()
9894 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
9895 {
9896 continue;
9897 }
9898 if field == "unknown" {
9899 rename = None;
9900 continue;
9901 }
9902 names.push(rename.take().unwrap_or_else(|| field.to_string()));
9903 }
9904 names
9905 }
9906
9907 fn known_keys_of(source: &str, name: &str) -> Vec<String> {
9911 let header = format!("impl RawBlock for {name} {{");
9912 let body = source
9913 .split_once(&header)
9914 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
9915 .1;
9916 let array = body
9917 .split_once("KNOWN_KEYS: &'static [&'static str] = &[")
9918 .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
9919 .1
9920 .split_once("];")
9921 .expect("KNOWN_KEYS array is unterminated")
9922 .0;
9923 array
9924 .split('"')
9925 .skip(1)
9926 .step_by(2)
9927 .map(str::to_string)
9928 .collect()
9929 }
9930}