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 pub gateway: Option<ArtifactGateway>,
859}
860
861#[derive(Debug, Clone, PartialEq, Eq)]
865pub struct ArtifactGateway {
866 pub endpoint: String,
869 pub api_key: Option<ApiKeyReference>,
872 pub keyless: bool,
876}
877
878#[derive(Debug, Clone, PartialEq, Eq)]
879pub struct InferenceDriver {
880 pub artifact: String,
881 pub config: Option<String>,
882}
883
884#[derive(Clone, PartialEq, Eq)]
887pub enum ApiKeyReference {
888 Environment(String),
891 Literal(String),
893}
894
895impl std::fmt::Debug for ApiKeyReference {
896 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
897 match self {
898 Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
899 Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
900 }
901 }
902}
903
904#[derive(Debug, Clone, PartialEq, Eq)]
905pub struct InferenceConfig {
906 pub transport: String,
907 pub model: String,
908 pub driver: Option<InferenceDriver>,
911 pub command: Option<String>,
915 pub compaction: Option<CompactionConfig>,
916 pub system_prompt: Option<String>,
917 pub system_prompt_file: Option<String>,
918 pub system_prompt_artifact: Option<String>,
922 pub max_turns: u32,
925 pub max_tokens: Option<u32>,
932 pub max_session_tokens: Option<u64>,
940}
941
942#[derive(Debug, Clone, PartialEq)]
943pub struct CompactionConfig {
944 pub threshold: Option<f32>,
946 pub model: Option<String>,
948 pub system_prompt: Option<String>,
951 pub system_prompt_file: Option<String>,
955 pub dump_summaries: Option<bool>,
959}
960
961impl Eq for CompactionConfig {}
963
964pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
968
969pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
973
974#[derive(Debug, Clone, PartialEq)]
975pub struct ContextConfig {
976 pub max_tokens: Option<u32>,
978 pub record: bool,
982 pub record_store: Option<String>,
986 pub seed_budget: f32,
991 pub seed_overflow_margin: f32,
996 pub retain: Option<ContextRetainConfig>,
999}
1000
1001impl Eq for ContextConfig {}
1003
1004#[derive(Debug, Clone, PartialEq, Eq)]
1005pub struct ObservabilityConfig {
1006 pub otel_endpoint: Option<String>,
1007 pub eval: Option<EvalConfig>,
1008}
1009
1010#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1011pub struct TraceConfig {
1012 pub capture: TraceCapture,
1016 pub retain: Option<TraceRetainConfig>,
1021}
1022
1023#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1029pub struct TraceRetainConfig {
1030 pub max_sessions: Option<u32>,
1034 pub max_age_secs: Option<u64>,
1037}
1038
1039#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1045pub struct ContextRetainConfig {
1046 pub max_messages: Option<u32>,
1049 pub max_age_secs: Option<u64>,
1052}
1053
1054#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1055pub struct EvalConfig {
1056 #[serde(skip_serializing_if = "Option::is_none")]
1057 pub dataset_id: Option<String>,
1058 pub scorers: Vec<ScorerConfig>,
1059}
1060
1061#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1062#[serde(tag = "type", rename_all = "snake_case")]
1063pub enum ScorerConfig {
1064 ExitOk { name: String },
1065 MaxTurns { name: String, max: u32 },
1066 MaxTokens { name: String, max: u64 },
1067 ToolSequence { name: String, expected: Vec<String> },
1068 LlmJudge { name: String },
1069}
1070
1071#[derive(Debug, Clone, PartialEq, Eq)]
1072pub struct NetworkCapabilities {
1073 pub allow: Vec<String>,
1074 pub unix_sockets: bool,
1082}
1083
1084#[derive(Debug, Clone, PartialEq, Eq)]
1085pub struct FilesystemCapabilities {
1086 pub scope: Option<String>,
1087 pub workdir_exec: bool,
1102 pub read_only: Vec<String>,
1109}
1110
1111#[derive(Debug, Clone, PartialEq, Eq)]
1116pub struct TaskIoCapabilities {
1117 pub read: bool,
1121}
1122
1123#[derive(Debug, Clone, PartialEq, Eq)]
1128pub struct ConversationCapabilities {
1129 pub read: bool,
1132}
1133
1134#[derive(Debug, Clone, PartialEq, Eq)]
1138pub struct PlanCapabilities {
1139 pub submit: bool,
1142}
1143
1144#[derive(Debug, Clone, PartialEq, Eq)]
1145pub struct ShellCapabilities {
1146 pub allow: Vec<String>,
1147 pub strip_env: Option<Vec<String>>,
1148 pub baseline_env: Option<Vec<String>>,
1149 pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1156 pub staged_runtime: Vec<StagedRuntimeGrant>,
1162}
1163
1164#[derive(Debug, Clone, PartialEq, Eq)]
1169pub struct InterpreterRuntimeGrant {
1170 pub binary: String,
1174 pub dirs: Vec<InterpreterRuntimeDir>,
1177}
1178
1179#[derive(Debug, Clone, PartialEq, Eq)]
1181pub struct InterpreterRuntimeDir {
1182 pub path: String,
1184 pub list_dir: bool,
1189}
1190
1191#[derive(Debug, Clone, PartialEq, Eq)]
1208pub struct StagedRuntimeGrant {
1209 pub binary: String,
1213 pub source_path: String,
1217 pub pin: String,
1222}
1223
1224#[derive(Debug, Clone, PartialEq, Eq)]
1225pub struct SpawnCapabilities {
1226 pub allow: Vec<String>,
1227}
1228
1229#[derive(Debug, Clone, PartialEq, Eq, Default)]
1234pub struct EnvCapabilities {
1235 pub allow: Vec<String>,
1236}
1237
1238#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1246pub struct ResourceLimits {
1247 pub memory_bytes: Option<usize>,
1249 pub table_elements: Option<usize>,
1251 pub instances: Option<usize>,
1253 pub deadline_seconds: Option<u64>,
1255}
1256
1257#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1271pub struct ResourceCapabilities {
1272 pub max_processes: Option<u64>,
1274 pub max_open_files: Option<u64>,
1276 pub max_file_size_bytes: Option<u64>,
1278 pub cpu_seconds: Option<u64>,
1280 pub memory_bytes: Option<u64>,
1283 pub cgroup_memory_bytes: Option<u64>,
1285 pub cgroup_pids_max: Option<u64>,
1287 pub cgroup_cpu_percent: Option<u32>,
1289 pub cgroup_io_bytes_per_sec: Option<u64>,
1292 pub workdir_max_bytes: Option<u64>,
1294}
1295
1296#[derive(Debug, Clone, PartialEq, Eq, Default)]
1303pub struct StateCapabilities {
1304 pub store: Option<String>,
1312}
1313
1314#[derive(
1327 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1328)]
1329#[serde(rename_all = "snake_case")]
1330pub enum ContainmentClass {
1331 #[default]
1335 Advisory,
1336 Scoped,
1340 Sealed,
1348}
1349
1350impl ContainmentClass {
1351 pub fn as_str(self) -> &'static str {
1354 match self {
1355 Self::Advisory => "advisory",
1356 Self::Scoped => "scoped",
1357 Self::Sealed => "sealed",
1358 }
1359 }
1360
1361 pub const ALL: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1363}
1364
1365impl std::fmt::Display for ContainmentClass {
1366 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1367 f.write_str(self.as_str())
1368 }
1369}
1370
1371#[derive(Debug, Clone, PartialEq, Eq)]
1373pub struct ParseContainmentClassError {
1374 pub value: String,
1375}
1376
1377impl std::fmt::Display for ParseContainmentClassError {
1378 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1379 write!(
1380 f,
1381 "must be one of: advisory, scoped, sealed; got '{}'",
1382 self.value
1383 )
1384 }
1385}
1386
1387impl std::error::Error for ParseContainmentClassError {}
1388
1389impl std::str::FromStr for ContainmentClass {
1390 type Err = ParseContainmentClassError;
1391
1392 fn from_str(value: &str) -> Result<Self, Self::Err> {
1393 match value.trim() {
1394 "advisory" => Ok(Self::Advisory),
1395 "scoped" => Ok(Self::Scoped),
1396 "sealed" => Ok(Self::Sealed),
1397 other => Err(ParseContainmentClassError {
1398 value: other.to_string(),
1399 }),
1400 }
1401 }
1402}
1403
1404pub fn effective_containment_floor(
1410 workspace_default: Option<ContainmentClass>,
1411 manifest_declared: Option<ContainmentClass>,
1412 cli_flag: Option<ContainmentClass>,
1413) -> ContainmentClass {
1414 [workspace_default, manifest_declared, cli_flag]
1415 .into_iter()
1416 .flatten()
1417 .max()
1418 .unwrap_or_default()
1419}
1420
1421#[derive(Debug, Clone, PartialEq, Eq)]
1422pub struct Capabilities {
1423 pub network: Option<NetworkCapabilities>,
1424 pub peer_fetch: Option<PeerFetchCapabilities>,
1427 pub filesystem: Option<FilesystemCapabilities>,
1428 pub shell: Option<ShellCapabilities>,
1429 pub spawn: Option<SpawnCapabilities>,
1430 pub env: Option<EnvCapabilities>,
1431 pub limits: Option<ResourceLimits>,
1432 pub resources: Option<ResourceCapabilities>,
1433 pub state: Option<StateCapabilities>,
1436 pub task_io: Option<TaskIoCapabilities>,
1439 pub conversation: Option<ConversationCapabilities>,
1442 pub plan: Option<PlanCapabilities>,
1445 pub containment: Option<ContainmentClass>,
1449}
1450
1451#[derive(Debug, Error)]
1452pub enum RuntimeManifestError {
1453 #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
1454 NotFound(String),
1455 #[error("{0}")]
1456 YamlSyntax(String),
1457 #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
1458 MissingField { field: String },
1459 #[error(
1460 "{}: invalid artifact declaration at index {index}: {message}",
1461 MANIFEST_FILENAME
1462 )]
1463 InvalidArtifact { index: usize, message: String },
1464 #[error(
1465 "{}: invalid inference config for '{field}': {message}",
1466 MANIFEST_FILENAME
1467 )]
1468 InvalidInferenceConfig { field: String, message: String },
1469 #[error(
1470 "{}: invalid capability config for '{field}': {message}",
1471 MANIFEST_FILENAME
1472 )]
1473 InvalidCapabilities { field: String, message: String },
1474 #[error(
1475 "{}: invalid exports config for '{field}': {message}",
1476 MANIFEST_FILENAME
1477 )]
1478 InvalidExports { field: String, message: String },
1479 #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
1480 InvalidTraceConfig { field: String, message: String },
1481 #[error(
1485 "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
1486 gateway.endpoint '{endpoint}' but binds no credential",
1487 MANIFEST_FILENAME
1488 )]
1489 GatewayWithoutCredential {
1490 index: usize,
1491 name: String,
1492 endpoint: String,
1493 },
1494 #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
1495 Io {
1496 path: String,
1497 #[source]
1498 source: std::io::Error,
1499 },
1500}
1501
1502#[derive(Debug, Deserialize)]
1503struct RawRuntimeManifest {
1504 name: Option<String>,
1505 version: Option<String>,
1506 #[serde(default)]
1507 artifacts: Vec<RawArtifact>,
1508 #[serde(default)]
1509 capabilities: Option<RawCapabilities>,
1510 #[serde(default)]
1511 inference: Option<RawInferenceConfig>,
1512 #[serde(default)]
1513 context: Option<RawContextConfig>,
1514 #[serde(default)]
1515 observability: Option<RawObservabilityConfig>,
1516 #[serde(default)]
1517 trace: Option<RawTraceConfig>,
1518 #[serde(default)]
1519 network: Option<RawNetworkConfig>,
1520 #[serde(default)]
1521 lifecycle: Option<RawLifecycleConfig>,
1522 #[serde(default)]
1523 exports: Option<RawExports>,
1524 #[serde(default)]
1525 mur_version: Option<String>,
1526 #[serde(default, deserialize_with = "deserialize_present")]
1530 config: Option<serde_yaml::Value>,
1531 #[serde(default)]
1536 #[allow(dead_code)]
1537 runtime: Option<serde_yaml::Value>,
1538 #[serde(default)]
1542 #[allow(dead_code)]
1543 implementation: Option<serde_yaml::Value>,
1544 #[serde(default)]
1548 #[allow(dead_code)]
1549 execution: Option<serde_yaml::Value>,
1550 #[serde(default)]
1553 #[allow(dead_code)]
1554 requires_files: Option<serde_yaml::Value>,
1555 #[serde(flatten)]
1556 unknown: UnknownKeys,
1557}
1558
1559#[derive(Debug, Deserialize)]
1560struct RawExports {
1561 #[serde(default)]
1562 files: Option<RawFileExport>,
1563 #[serde(default)]
1564 peer_files: Option<RawPeerFilesExport>,
1565 #[serde(flatten)]
1566 unknown: UnknownKeys,
1567}
1568
1569#[derive(Debug, Deserialize)]
1570struct RawPeerFilesExport {
1571 #[serde(default)]
1572 root: Option<String>,
1573 #[serde(default)]
1577 max_ttl: Option<serde_yaml::Value>,
1578 #[serde(default)]
1579 max_bytes: Option<serde_yaml::Value>,
1580 #[serde(flatten)]
1581 unknown: UnknownKeys,
1582}
1583
1584#[derive(Debug, Deserialize)]
1585struct RawFileExport {
1586 #[serde(default)]
1587 root: Option<String>,
1588 #[serde(default)]
1589 mode: Option<String>,
1590 #[serde(default)]
1595 max_bytes: Option<serde_yaml::Value>,
1596 #[serde(flatten)]
1597 unknown: UnknownKeys,
1598}
1599
1600#[derive(Debug, Deserialize)]
1601struct RawLifecycleConfig {
1602 #[serde(default)]
1603 task_acceptance: Option<TaskAcceptance>,
1604 #[serde(default)]
1605 after_task: Option<AfterTask>,
1606 #[serde(default)]
1607 queue_depth: Option<usize>,
1608 #[serde(default)]
1609 input_timeout_secs: Option<u64>,
1610 #[serde(default)]
1611 conversation: Option<ConversationMode>,
1612 #[serde(default)]
1613 max_task_reopens: Option<u32>,
1614 #[serde(default)]
1615 shell_grace_secs: Option<u64>,
1616 #[serde(default)]
1617 delegation_deadline_secs: Option<u64>,
1618 #[serde(flatten)]
1619 unknown: UnknownKeys,
1620}
1621
1622#[derive(Debug, Deserialize)]
1623struct RawNetworkConfig {
1624 internal_port: Option<u16>,
1625 #[serde(flatten)]
1626 unknown: UnknownKeys,
1627}
1628
1629#[derive(Debug, Deserialize)]
1630struct RawContextConfig {
1631 max_tokens: Option<u32>,
1632 #[serde(default)]
1635 record: Option<String>,
1636 #[serde(default)]
1637 record_store: Option<String>,
1638 #[serde(default)]
1639 seed_budget: Option<f32>,
1640 #[serde(default)]
1641 seed_overflow_margin: Option<f32>,
1642 #[serde(default, deserialize_with = "present_yaml_value")]
1645 retain: Option<serde_yaml::Value>,
1646 #[serde(flatten)]
1647 unknown: UnknownKeys,
1648}
1649
1650#[derive(Debug, Deserialize)]
1651struct RawObservabilityConfig {
1652 #[serde(default)]
1653 otel_endpoint: Option<String>,
1654 #[serde(default)]
1655 eval: Option<RawEvalConfig>,
1656 #[serde(flatten)]
1657 unknown: UnknownKeys,
1658}
1659
1660#[derive(Debug, Deserialize)]
1661struct RawTraceConfig {
1662 #[serde(default)]
1666 capture: Option<String>,
1667 #[serde(default)]
1670 include_tool_output: Option<bool>,
1671 #[serde(default, deserialize_with = "present_yaml_value")]
1673 retain: Option<serde_yaml::Value>,
1674 #[serde(flatten)]
1675 unknown: UnknownKeys,
1676}
1677
1678#[derive(Debug, Deserialize)]
1679struct RawEvalConfig {
1680 #[serde(default)]
1681 dataset_id: Option<String>,
1682 #[serde(default)]
1683 scorers: Vec<RawScorerConfig>,
1684 #[serde(flatten)]
1685 unknown: UnknownKeys,
1686}
1687
1688#[derive(Debug, Deserialize)]
1689struct RawScorerConfig {
1690 #[serde(rename = "type")]
1691 scorer_type: String,
1692 name: Option<String>,
1693 max: Option<u64>,
1694 expected: Option<Vec<String>>,
1695 #[serde(flatten)]
1696 unknown: UnknownKeys,
1697}
1698
1699#[derive(Debug, Deserialize)]
1700struct RawArtifact {
1701 name: Option<String>,
1702 version: Option<String>,
1703 runtime: Option<String>,
1704 #[serde(default)]
1705 source: Option<String>,
1706 #[serde(default)]
1707 local_source: Option<bool>,
1708 #[serde(default)]
1709 prompt_payload: Option<bool>,
1710 #[serde(default)]
1711 capabilities: Option<RawCapabilities>,
1712 #[serde(default)]
1713 on_overflow: Option<String>,
1714 #[serde(default, deserialize_with = "deserialize_present")]
1719 config: Option<serde_yaml::Value>,
1720 #[serde(default, deserialize_with = "deserialize_present")]
1723 gateway: Option<Option<RawArtifactGateway>>,
1724 #[serde(flatten)]
1725 unknown: UnknownKeys,
1726}
1727
1728#[derive(Debug, Deserialize)]
1729struct RawArtifactGateway {
1730 #[serde(default)]
1731 endpoint: Option<String>,
1732 #[serde(default, deserialize_with = "deserialize_present")]
1735 api_key: Option<Option<String>>,
1736 #[serde(default)]
1738 keyless: Option<serde_yaml::Value>,
1739 #[serde(flatten)]
1740 unknown: UnknownKeys,
1741}
1742
1743fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
1749where
1750 T: Deserialize<'de>,
1751 D: serde::Deserializer<'de>,
1752{
1753 T::deserialize(deserializer).map(Some)
1754}
1755
1756#[derive(Debug, Deserialize)]
1757struct RawCapabilities {
1758 #[serde(default)]
1759 network: Option<RawNetworkCapabilities>,
1760 #[serde(default)]
1761 peer_fetch: Option<RawPeerFetchCapabilities>,
1762 #[serde(default)]
1763 filesystem: Option<RawFilesystemCapabilities>,
1764 #[serde(default)]
1765 shell: Option<RawShellCapabilities>,
1766 #[serde(default)]
1767 spawn: Option<RawSpawnCapabilities>,
1768 #[serde(default)]
1769 env: Option<RawEnvCapabilities>,
1770 #[serde(default)]
1771 limits: Option<RawResourceLimits>,
1772 #[serde(default)]
1773 resources: Option<RawResourceCapabilities>,
1774 #[serde(default)]
1775 state: Option<RawStateCapabilities>,
1776 #[serde(default)]
1777 task_io: Option<RawTaskIoCapabilities>,
1778 #[serde(default)]
1779 conversation: Option<RawConversationCapabilities>,
1780 #[serde(default)]
1781 plan: Option<RawPlanCapabilities>,
1782 #[serde(default)]
1786 containment: Option<String>,
1787 #[serde(flatten)]
1788 unknown: UnknownKeys,
1789}
1790
1791#[derive(Debug, Deserialize)]
1792struct RawTaskIoCapabilities {
1793 #[serde(default)]
1796 read: Option<bool>,
1797 #[serde(flatten)]
1798 unknown: UnknownKeys,
1799}
1800
1801#[derive(Debug, Deserialize)]
1802struct RawConversationCapabilities {
1803 #[serde(default)]
1806 read: Option<bool>,
1807 #[serde(flatten)]
1808 unknown: UnknownKeys,
1809}
1810
1811#[derive(Debug, Deserialize)]
1812struct RawPlanCapabilities {
1813 #[serde(default)]
1816 submit: Option<bool>,
1817 #[serde(flatten)]
1818 unknown: UnknownKeys,
1819}
1820
1821#[derive(Debug, Deserialize)]
1822struct RawSpawnCapabilities {
1823 #[serde(default)]
1824 allow: Vec<String>,
1825 #[serde(flatten)]
1826 unknown: UnknownKeys,
1827}
1828
1829#[derive(Debug, Deserialize)]
1830struct RawEnvCapabilities {
1831 #[serde(default)]
1832 allow: Vec<String>,
1833 #[serde(flatten)]
1834 unknown: UnknownKeys,
1835}
1836
1837#[derive(Debug, Deserialize)]
1838struct RawResourceLimits {
1839 #[serde(default)]
1840 memory_bytes: Option<usize>,
1841 #[serde(default)]
1842 table_elements: Option<usize>,
1843 #[serde(default)]
1844 instances: Option<usize>,
1845 #[serde(default)]
1846 deadline_seconds: Option<u64>,
1847 #[serde(flatten)]
1848 unknown: UnknownKeys,
1849}
1850
1851#[derive(Debug, Deserialize)]
1852struct RawStateCapabilities {
1853 #[serde(default)]
1854 store: Option<String>,
1855 #[serde(flatten)]
1856 unknown: UnknownKeys,
1857}
1858
1859#[derive(Debug, Deserialize)]
1860struct RawResourceCapabilities {
1861 #[serde(default)]
1862 max_processes: Option<u64>,
1863 #[serde(default)]
1864 max_open_files: Option<u64>,
1865 #[serde(default)]
1866 max_file_size_bytes: Option<u64>,
1867 #[serde(default)]
1868 cpu_seconds: Option<u64>,
1869 #[serde(default)]
1870 memory_bytes: Option<u64>,
1871 #[serde(default)]
1872 cgroup_memory_bytes: Option<u64>,
1873 #[serde(default)]
1874 cgroup_pids_max: Option<u64>,
1875 #[serde(default)]
1876 cgroup_cpu_percent: Option<u32>,
1877 #[serde(default)]
1878 cgroup_io_bytes_per_sec: Option<u64>,
1879 #[serde(default)]
1880 workdir_max_bytes: Option<u64>,
1881 #[serde(flatten)]
1882 unknown: UnknownKeys,
1883}
1884
1885#[derive(Debug, Deserialize)]
1886struct RawNetworkCapabilities {
1887 #[serde(default)]
1888 allow: Vec<String>,
1889 #[serde(default)]
1890 unix_sockets: bool,
1891 #[serde(flatten)]
1892 unknown: UnknownKeys,
1893}
1894
1895#[derive(Debug, Deserialize)]
1896struct RawPeerFetchCapabilities {
1897 #[serde(default)]
1898 allow: Vec<String>,
1899 #[serde(flatten)]
1900 unknown: UnknownKeys,
1901}
1902
1903#[derive(Debug, Deserialize)]
1904struct RawFilesystemCapabilities {
1905 #[serde(default)]
1906 scope: Option<String>,
1907 #[serde(default)]
1911 workdir_exec: bool,
1912 #[serde(default)]
1915 read_only: Vec<String>,
1916 #[serde(flatten)]
1917 unknown: UnknownKeys,
1918}
1919
1920#[derive(Debug, Deserialize)]
1921struct RawShellCapabilities {
1922 #[serde(default)]
1923 allow: Vec<String>,
1924 #[serde(default)]
1925 strip_env: Option<Vec<String>>,
1926 #[serde(default)]
1927 baseline_env: Option<Vec<String>>,
1928 #[serde(default)]
1929 interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
1930 #[serde(default)]
1931 staged_runtime: Vec<RawStagedRuntimeGrant>,
1932 #[serde(flatten)]
1933 unknown: UnknownKeys,
1934}
1935
1936#[derive(Debug, Deserialize)]
1937struct RawInterpreterRuntimeGrant {
1938 #[serde(default)]
1939 binary: Option<String>,
1940 #[serde(default)]
1941 dirs: Vec<RawInterpreterRuntimeDir>,
1942 #[serde(flatten)]
1943 unknown: UnknownKeys,
1944}
1945
1946#[derive(Debug, Deserialize)]
1947struct RawInterpreterRuntimeDir {
1948 #[serde(default)]
1949 path: Option<String>,
1950 #[serde(default)]
1954 list_dir: Option<bool>,
1955 #[serde(flatten)]
1956 unknown: UnknownKeys,
1957}
1958
1959#[derive(Debug, Deserialize)]
1960struct RawStagedRuntimeGrant {
1961 #[serde(default)]
1962 binary: Option<String>,
1963 #[serde(default)]
1964 source_path: Option<String>,
1965 #[serde(default)]
1969 pin: Option<String>,
1970 #[serde(flatten)]
1971 unknown: UnknownKeys,
1972}
1973
1974#[derive(Debug, Deserialize)]
1975struct RawInferenceConfig {
1976 transport: Option<String>,
1977 #[serde(default, deserialize_with = "deserialize_present")]
1980 endpoint: Option<serde_yaml::Value>,
1981 model: Option<String>,
1982 #[serde(default, deserialize_with = "deserialize_present")]
1985 api_key: Option<serde_yaml::Value>,
1986 #[serde(default)]
1987 command: Option<String>,
1988 #[serde(default)]
1989 system_prompt: Option<String>,
1990 #[serde(default)]
1991 system_prompt_file: Option<String>,
1992 #[serde(default)]
1993 system_prompt_artifact: Option<String>,
1994 #[serde(default)]
1995 driver: Option<RawInferenceDriver>,
1996 #[serde(default)]
1997 provider: Option<RawInferenceDriver>,
1998 #[serde(default)]
1999 compaction: Option<RawCompactionConfig>,
2000 #[serde(default)]
2001 max_turns: Option<u32>,
2002 #[serde(default)]
2005 max_task_reopens: Option<u32>,
2006 #[serde(default)]
2007 max_tokens: Option<u32>,
2008 #[serde(default)]
2009 max_session_tokens: Option<u64>,
2010 #[serde(flatten)]
2011 unknown: UnknownKeys,
2012}
2013
2014#[derive(Debug, Deserialize)]
2015struct RawCompactionConfig {
2016 threshold: Option<f32>,
2017 model: Option<String>,
2018 system_prompt: Option<String>,
2019 system_prompt_file: Option<String>,
2020 dump_summaries: Option<bool>,
2021 #[serde(flatten)]
2022 unknown: UnknownKeys,
2023}
2024
2025#[derive(Debug, Deserialize)]
2026struct RawInferenceDriver {
2027 artifact: Option<String>,
2028 #[serde(default)]
2029 config: Option<serde_yaml::Value>,
2030 #[serde(flatten)]
2031 unknown: UnknownKeys,
2032}
2033
2034type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2041
2042trait RawBlock {
2049 const KNOWN_KEYS: &'static [&'static str];
2053
2054 fn unknown_keys(&self) -> &UnknownKeys;
2055
2056 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2064 let _ = (path, out);
2065 }
2066}
2067
2068impl RawBlock for RawRuntimeManifest {
2069 const KNOWN_KEYS: &'static [&'static str] = &[
2070 "name",
2071 "version",
2072 "artifacts",
2073 "capabilities",
2074 "inference",
2075 "context",
2076 "observability",
2077 "trace",
2078 "network",
2079 "lifecycle",
2080 "exports",
2081 "mur_version",
2082 "config",
2083 "runtime",
2084 "implementation",
2085 "execution",
2086 "requires_files",
2087 ];
2088 fn unknown_keys(&self) -> &UnknownKeys {
2089 &self.unknown
2090 }
2091
2092 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2093 let artifacts = child_path(path, "artifacts");
2094 for (index, artifact) in self.artifacts.iter().enumerate() {
2095 collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2096 }
2097 if let Some(capabilities) = &self.capabilities {
2098 collect_block(capabilities, &child_path(path, "capabilities"), out);
2099 }
2100 if let Some(inference) = &self.inference {
2101 collect_block(inference, &child_path(path, "inference"), out);
2102 }
2103 if let Some(context) = &self.context {
2104 collect_block(context, &child_path(path, "context"), out);
2105 }
2106 if let Some(observability) = &self.observability {
2107 collect_block(observability, &child_path(path, "observability"), out);
2108 }
2109 if let Some(trace) = &self.trace {
2110 collect_block(trace, &child_path(path, "trace"), out);
2111 }
2112 if let Some(network) = &self.network {
2113 collect_block(network, &child_path(path, "network"), out);
2114 }
2115 if let Some(lifecycle) = &self.lifecycle {
2116 collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2117 }
2118 if let Some(exports) = &self.exports {
2119 collect_block(exports, &child_path(path, "exports"), out);
2120 }
2121 }
2122}
2123
2124impl RawBlock for RawExports {
2125 const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files"];
2126 fn unknown_keys(&self) -> &UnknownKeys {
2127 &self.unknown
2128 }
2129
2130 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2131 if let Some(files) = &self.files {
2132 collect_block(files, &child_path(path, "files"), out);
2133 }
2134 if let Some(peer_files) = &self.peer_files {
2135 collect_block(peer_files, &child_path(path, "peer_files"), out);
2136 }
2137 }
2138}
2139
2140impl RawBlock for RawPeerFilesExport {
2141 const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2142 fn unknown_keys(&self) -> &UnknownKeys {
2143 &self.unknown
2144 }
2145}
2146
2147impl RawBlock for RawFileExport {
2148 const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2149 fn unknown_keys(&self) -> &UnknownKeys {
2150 &self.unknown
2151 }
2152}
2153
2154impl RawBlock for RawLifecycleConfig {
2155 const KNOWN_KEYS: &'static [&'static str] = &[
2156 "task_acceptance",
2157 "after_task",
2158 "queue_depth",
2159 "input_timeout_secs",
2160 "conversation",
2161 "max_task_reopens",
2162 "shell_grace_secs",
2163 "delegation_deadline_secs",
2164 ];
2165 fn unknown_keys(&self) -> &UnknownKeys {
2166 &self.unknown
2167 }
2168}
2169
2170impl RawBlock for RawNetworkConfig {
2171 const KNOWN_KEYS: &'static [&'static str] = &["internal_port"];
2172 fn unknown_keys(&self) -> &UnknownKeys {
2173 &self.unknown
2174 }
2175}
2176
2177impl RawBlock for RawContextConfig {
2178 const KNOWN_KEYS: &'static [&'static str] = &[
2179 "max_tokens",
2180 "record",
2181 "record_store",
2182 "seed_budget",
2183 "seed_overflow_margin",
2184 "retain",
2185 ];
2186 fn unknown_keys(&self) -> &UnknownKeys {
2187 &self.unknown
2188 }
2189}
2190
2191impl RawBlock for RawObservabilityConfig {
2192 const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
2193 fn unknown_keys(&self) -> &UnknownKeys {
2194 &self.unknown
2195 }
2196
2197 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2198 if let Some(eval) = &self.eval {
2199 collect_block(eval, &child_path(path, "eval"), out);
2200 }
2201 }
2202}
2203
2204impl RawBlock for RawTraceConfig {
2205 const KNOWN_KEYS: &'static [&'static str] = &["capture", "include_tool_output", "retain"];
2206 fn unknown_keys(&self) -> &UnknownKeys {
2207 &self.unknown
2208 }
2209}
2210
2211impl RawBlock for RawEvalConfig {
2212 const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
2213 fn unknown_keys(&self) -> &UnknownKeys {
2214 &self.unknown
2215 }
2216
2217 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2218 let scorers = child_path(path, "scorers");
2219 for (index, scorer) in self.scorers.iter().enumerate() {
2220 collect_block(scorer, &format!("{scorers}[{index}]"), out);
2221 }
2222 }
2223}
2224
2225impl RawBlock for RawScorerConfig {
2226 const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
2227 fn unknown_keys(&self) -> &UnknownKeys {
2228 &self.unknown
2229 }
2230}
2231
2232impl RawBlock for RawArtifact {
2233 const KNOWN_KEYS: &'static [&'static str] = &[
2234 "name",
2235 "version",
2236 "runtime",
2237 "source",
2238 "local_source",
2239 "prompt_payload",
2240 "capabilities",
2241 "on_overflow",
2242 "config",
2243 "gateway",
2244 ];
2245 fn unknown_keys(&self) -> &UnknownKeys {
2246 &self.unknown
2247 }
2248
2249 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2250 if let Some(capabilities) = &self.capabilities {
2251 collect_block(capabilities, &child_path(path, "capabilities"), out);
2252 }
2253 if let Some(Some(gateway)) = &self.gateway {
2254 collect_block(gateway, &child_path(path, "gateway"), out);
2255 }
2256 }
2257}
2258
2259impl RawBlock for RawArtifactGateway {
2260 const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
2261 fn unknown_keys(&self) -> &UnknownKeys {
2262 &self.unknown
2263 }
2264}
2265
2266impl RawBlock for RawCapabilities {
2267 const KNOWN_KEYS: &'static [&'static str] = &[
2268 "network",
2269 "peer_fetch",
2270 "filesystem",
2271 "shell",
2272 "spawn",
2273 "env",
2274 "limits",
2275 "resources",
2276 "state",
2277 "task_io",
2278 "conversation",
2279 "plan",
2280 "containment",
2281 ];
2282 fn unknown_keys(&self) -> &UnknownKeys {
2283 &self.unknown
2284 }
2285
2286 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2290 if let Some(network) = &self.network {
2291 collect_block(network, &child_path(path, "network"), out);
2292 }
2293 if let Some(peer_fetch) = &self.peer_fetch {
2294 collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
2295 }
2296 if let Some(filesystem) = &self.filesystem {
2297 collect_block(filesystem, &child_path(path, "filesystem"), out);
2298 }
2299 if let Some(shell) = &self.shell {
2300 collect_block(shell, &child_path(path, "shell"), out);
2301 }
2302 if let Some(spawn) = &self.spawn {
2303 collect_block(spawn, &child_path(path, "spawn"), out);
2304 }
2305 if let Some(env) = &self.env {
2306 collect_block(env, &child_path(path, "env"), out);
2307 }
2308 if let Some(limits) = &self.limits {
2309 collect_block(limits, &child_path(path, "limits"), out);
2310 }
2311 if let Some(resources) = &self.resources {
2312 collect_block(resources, &child_path(path, "resources"), out);
2313 }
2314 if let Some(state) = &self.state {
2315 collect_block(state, &child_path(path, "state"), out);
2316 }
2317 if let Some(task_io) = &self.task_io {
2318 collect_block(task_io, &child_path(path, "task_io"), out);
2319 }
2320 if let Some(conversation) = &self.conversation {
2321 collect_block(conversation, &child_path(path, "conversation"), out);
2322 }
2323 if let Some(plan) = &self.plan {
2324 collect_block(plan, &child_path(path, "plan"), out);
2325 }
2326 }
2327}
2328
2329impl RawBlock for RawTaskIoCapabilities {
2330 const KNOWN_KEYS: &'static [&'static str] = &["read"];
2331 fn unknown_keys(&self) -> &UnknownKeys {
2332 &self.unknown
2333 }
2334}
2335
2336impl RawBlock for RawConversationCapabilities {
2337 const KNOWN_KEYS: &'static [&'static str] = &["read"];
2338 fn unknown_keys(&self) -> &UnknownKeys {
2339 &self.unknown
2340 }
2341}
2342
2343impl RawBlock for RawPlanCapabilities {
2344 const KNOWN_KEYS: &'static [&'static str] = &["submit"];
2345 fn unknown_keys(&self) -> &UnknownKeys {
2346 &self.unknown
2347 }
2348}
2349
2350impl RawBlock for RawSpawnCapabilities {
2351 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2352 fn unknown_keys(&self) -> &UnknownKeys {
2353 &self.unknown
2354 }
2355}
2356
2357impl RawBlock for RawEnvCapabilities {
2358 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2359 fn unknown_keys(&self) -> &UnknownKeys {
2360 &self.unknown
2361 }
2362}
2363
2364impl RawBlock for RawResourceLimits {
2365 const KNOWN_KEYS: &'static [&'static str] = &[
2366 "memory_bytes",
2367 "table_elements",
2368 "instances",
2369 "deadline_seconds",
2370 ];
2371 fn unknown_keys(&self) -> &UnknownKeys {
2372 &self.unknown
2373 }
2374}
2375
2376impl RawBlock for RawStateCapabilities {
2377 const KNOWN_KEYS: &'static [&'static str] = &["store"];
2378 fn unknown_keys(&self) -> &UnknownKeys {
2379 &self.unknown
2380 }
2381}
2382
2383impl RawBlock for RawResourceCapabilities {
2384 const KNOWN_KEYS: &'static [&'static str] = &[
2385 "max_processes",
2386 "max_open_files",
2387 "max_file_size_bytes",
2388 "cpu_seconds",
2389 "memory_bytes",
2390 "cgroup_memory_bytes",
2391 "cgroup_pids_max",
2392 "cgroup_cpu_percent",
2393 "cgroup_io_bytes_per_sec",
2394 "workdir_max_bytes",
2395 ];
2396 fn unknown_keys(&self) -> &UnknownKeys {
2397 &self.unknown
2398 }
2399}
2400
2401impl RawBlock for RawNetworkCapabilities {
2402 const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
2403 fn unknown_keys(&self) -> &UnknownKeys {
2404 &self.unknown
2405 }
2406}
2407
2408impl RawBlock for RawPeerFetchCapabilities {
2409 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2410 fn unknown_keys(&self) -> &UnknownKeys {
2411 &self.unknown
2412 }
2413}
2414
2415impl RawBlock for RawFilesystemCapabilities {
2416 const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only"];
2417 fn unknown_keys(&self) -> &UnknownKeys {
2418 &self.unknown
2419 }
2420}
2421
2422impl RawBlock for RawShellCapabilities {
2423 const KNOWN_KEYS: &'static [&'static str] = &[
2424 "allow",
2425 "strip_env",
2426 "baseline_env",
2427 "interpreter_runtime",
2428 "staged_runtime",
2429 ];
2430 fn unknown_keys(&self) -> &UnknownKeys {
2431 &self.unknown
2432 }
2433
2434 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2435 let interpreter_runtime = child_path(path, "interpreter_runtime");
2436 for (index, grant) in self.interpreter_runtime.iter().enumerate() {
2437 collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
2438 }
2439 let staged_runtime = child_path(path, "staged_runtime");
2440 for (index, grant) in self.staged_runtime.iter().enumerate() {
2441 collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
2442 }
2443 }
2444}
2445
2446impl RawBlock for RawInterpreterRuntimeGrant {
2447 const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
2448 fn unknown_keys(&self) -> &UnknownKeys {
2449 &self.unknown
2450 }
2451
2452 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2453 let dirs = child_path(path, "dirs");
2454 for (index, dir) in self.dirs.iter().enumerate() {
2455 collect_block(dir, &format!("{dirs}[{index}]"), out);
2456 }
2457 }
2458}
2459
2460impl RawBlock for RawInterpreterRuntimeDir {
2461 const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
2462 fn unknown_keys(&self) -> &UnknownKeys {
2463 &self.unknown
2464 }
2465}
2466
2467impl RawBlock for RawStagedRuntimeGrant {
2468 const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
2469 fn unknown_keys(&self) -> &UnknownKeys {
2470 &self.unknown
2471 }
2472}
2473
2474impl RawBlock for RawInferenceConfig {
2475 const KNOWN_KEYS: &'static [&'static str] = &[
2476 "transport",
2477 "endpoint",
2478 "model",
2479 "api_key",
2480 "command",
2481 "system_prompt",
2482 "system_prompt_file",
2483 "system_prompt_artifact",
2484 "driver",
2485 "provider",
2486 "compaction",
2487 "max_turns",
2488 "max_task_reopens",
2489 "max_tokens",
2490 "max_session_tokens",
2491 ];
2492 fn unknown_keys(&self) -> &UnknownKeys {
2493 &self.unknown
2494 }
2495
2496 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2497 if let Some(driver) = &self.driver {
2498 collect_block(driver, &child_path(path, "driver"), out);
2499 }
2500 if let Some(provider) = &self.provider {
2501 collect_block(provider, &child_path(path, "provider"), out);
2502 }
2503 if let Some(compaction) = &self.compaction {
2504 collect_block(compaction, &child_path(path, "compaction"), out);
2505 }
2506 }
2507}
2508
2509impl RawBlock for RawCompactionConfig {
2510 const KNOWN_KEYS: &'static [&'static str] = &[
2511 "threshold",
2512 "model",
2513 "system_prompt",
2514 "system_prompt_file",
2515 "dump_summaries",
2516 ];
2517 fn unknown_keys(&self) -> &UnknownKeys {
2518 &self.unknown
2519 }
2520}
2521
2522impl RawBlock for RawInferenceDriver {
2523 const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
2524 fn unknown_keys(&self) -> &UnknownKeys {
2525 &self.unknown
2526 }
2527}
2528
2529fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
2536 let mut out = Vec::new();
2537 collect_block(raw, "", &mut out);
2538 out
2539}
2540
2541fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
2547 for key in block.unknown_keys().keys() {
2548 out.push(UnknownManifestKey {
2549 key: key.clone(),
2550 block_path: path.to_string(),
2551 nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
2552 });
2553 }
2554 block.walk_children(path, out);
2555}
2556
2557fn child_path(parent: &str, field: &str) -> String {
2561 if parent.is_empty() {
2562 return field.to_string();
2563 }
2564 format!("{parent}.{field}")
2565}
2566
2567pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
2574 fs::read_to_string(path).map_err(|source| {
2575 if source.kind() == std::io::ErrorKind::NotFound {
2576 return RuntimeManifestError::NotFound(path.display().to_string());
2577 }
2578
2579 RuntimeManifestError::Io {
2580 path: path.display().to_string(),
2581 source,
2582 }
2583 })
2584}
2585
2586#[must_use = "validated runtime manifest is required before run"]
2587pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
2588 let content = read_runtime_manifest_text(path)?;
2589
2590 RuntimeManifest::from_yaml_str(&content)
2591}
2592
2593#[derive(Debug, Clone, PartialEq, Eq)]
2595pub struct ReferencedEnvVariable {
2596 pub field: String,
2598 pub variable: String,
2600}
2601
2602impl ReferencedEnvVariable {
2603 pub fn is_gateway_credential(&self) -> bool {
2606 self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
2607 }
2608}
2609
2610#[derive(Deserialize)]
2618struct ReferencedManifestFields {
2619 #[serde(default)]
2620 artifacts: Vec<ReferencedArtifactFields>,
2621}
2622
2623#[derive(Deserialize)]
2624struct ReferencedArtifactFields {
2625 #[serde(default)]
2626 name: Option<String>,
2627 #[serde(default)]
2628 gateway: Option<ReferencedGatewayFields>,
2629}
2630
2631#[derive(Deserialize)]
2632struct ReferencedGatewayFields {
2633 #[serde(default)]
2634 api_key: Option<String>,
2635}
2636
2637pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
2645 let Ok(raw) = serde_yaml::from_str::<ReferencedManifestFields>(manifest_yaml) else {
2646 return Vec::new();
2647 };
2648
2649 let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
2650 for artifact in raw.artifacts {
2651 let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
2652 continue;
2653 };
2654 if let Some(variable) = parse_env_reference(api_key.trim()) {
2655 let name = artifact.name.unwrap_or_default();
2656 referenced.push(ReferencedEnvVariable {
2657 field: format!("artifacts.{}.gateway.api_key", name.trim()),
2658 variable: variable.to_string(),
2659 });
2660 }
2661 }
2662
2663 referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
2664 referenced.dedup();
2665 referenced
2666}
2667
2668#[derive(Clone, Copy, PartialEq, Eq)]
2674enum SecretResolution {
2675 Resolve,
2677 Skip,
2680}
2681
2682impl RuntimeManifest {
2683 pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
2684 Self::parse(input, SecretResolution::Resolve)
2685 }
2686
2687 pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
2697 Self::parse(input, SecretResolution::Skip)
2698 }
2699
2700 fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
2701 let raw: RawRuntimeManifest = serde_yaml::from_str(input).map_err(|err| {
2702 if let Some(location) = err.location() {
2703 RuntimeManifestError::YamlSyntax(format!(
2704 "{}: YAML syntax error at line {}, column {}: {}",
2705 MANIFEST_FILENAME,
2706 location.line(),
2707 location.column(),
2708 err
2709 ))
2710 } else {
2711 RuntimeManifestError::YamlSyntax(format!(
2712 "{MANIFEST_FILENAME}: YAML syntax error: {err}"
2713 ))
2714 }
2715 })?;
2716
2717 let unknown_keys = collect_unknown_keys(&raw);
2720
2721 let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
2722 RuntimeManifestError::MissingField {
2723 field: "name".to_string(),
2724 }
2725 })?;
2726
2727 let version = raw
2728 .version
2729 .filter(|s| !s.trim().is_empty())
2730 .ok_or_else(|| RuntimeManifestError::MissingField {
2731 field: "version".to_string(),
2732 })?;
2733
2734 let artifacts = raw
2735 .artifacts
2736 .into_iter()
2737 .enumerate()
2738 .map(|(index, artifact)| {
2739 let name = artifact
2740 .name
2741 .filter(|s| !s.trim().is_empty())
2742 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
2743 index,
2744 message: "missing required field 'name'".to_string(),
2745 })?;
2746
2747 let source = artifact.source.filter(|s| !s.trim().is_empty());
2748
2749 let runtime = match artifact.runtime.as_deref() {
2750 None | Some("tool") => ArtifactRuntime::Tool,
2751 Some("wasm") | Some("native") => {
2752 return Err(RuntimeManifestError::InvalidArtifact {
2753 index,
2754 message: format!(
2755 "use 'runtime: tool'; implementation is declared in the \
2756 artifact's own manifest (got '{}')",
2757 artifact.runtime.as_deref().unwrap_or("")
2758 ),
2759 });
2760 }
2761 Some("driver") => ArtifactRuntime::Driver,
2762 Some("hook") => ArtifactRuntime::Hook,
2763 Some("skill") => ArtifactRuntime::Skill,
2764 Some(other) => {
2765 return Err(RuntimeManifestError::InvalidArtifact {
2766 index,
2767 message: format!(
2768 "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
2769 ),
2770 });
2771 }
2772 };
2773
2774 let local_source = artifact
2778 .local_source
2779 .unwrap_or(runtime == ArtifactRuntime::Skill);
2780 let prompt_payload = artifact
2781 .prompt_payload
2782 .unwrap_or(runtime == ArtifactRuntime::Skill);
2783
2784 if source.is_some() && !local_source {
2786 return Err(RuntimeManifestError::InvalidArtifact {
2787 index,
2788 message: format!(
2789 "artifact '{name}' declares 'source:' but does not declare \
2790 'local_source: true' (runtime: {})",
2791 runtime.as_str()
2792 ),
2793 });
2794 }
2795
2796 let version = if source.is_some() {
2799 if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
2800 eprintln!(
2801 "warning: artifact '{name}' declares both 'source:' and \
2802 'version: {explicit}'; version is ignored for local-source skills \
2803 (using 'local')"
2804 );
2805 }
2806 "local".to_string()
2807 } else {
2808 artifact
2809 .version
2810 .filter(|s| !s.trim().is_empty())
2811 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
2812 index,
2813 message: "missing required field 'version'".to_string(),
2814 })?
2815 };
2816
2817 let capabilities = match artifact.capabilities {
2823 None => None,
2824 Some(raw_caps) => {
2825 if runtime == ArtifactRuntime::Skill {
2826 return Err(RuntimeManifestError::InvalidArtifact {
2827 index,
2828 message: format!(
2829 "artifact '{name}' declares per-artifact 'capabilities:' but \
2830 has 'runtime: {}'; per-artifact capabilities are only \
2831 recognized on 'runtime: hook', 'runtime: tool', and \
2832 'runtime: driver' entries — use the capsule-wide top-level \
2833 'capabilities:' block instead",
2834 runtime.as_str()
2835 ),
2836 });
2837 }
2838 if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
2844 return Err(RuntimeManifestError::InvalidArtifact {
2845 index,
2846 message: format!(
2847 "artifact '{name}' declares 'capabilities.task_io:' but has \
2848 'runtime: {}'; the key grants the murmur:task-io/read host \
2849 import and is only recognized on 'runtime: hook' entries",
2850 runtime.as_str()
2851 ),
2852 });
2853 }
2854 if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
2858 return Err(RuntimeManifestError::InvalidArtifact {
2859 index,
2860 message: format!(
2861 "artifact '{name}' declares 'capabilities.conversation:' but \
2862 has 'runtime: {}'; the key grants the \
2863 murmur:conversation/read host import and is only recognized \
2864 on 'runtime: hook' entries",
2865 runtime.as_str()
2866 ),
2867 });
2868 }
2869 parse_capabilities(Some(raw_caps))?
2870 }
2871 };
2872
2873 let on_overflow = match artifact.on_overflow.as_deref() {
2879 None => HookOverflowPolicy::default(),
2880 Some(raw) => {
2881 if runtime != ArtifactRuntime::Hook {
2882 return Err(RuntimeManifestError::InvalidArtifact {
2883 index,
2884 message: format!(
2885 "artifact '{name}' declares 'on_overflow:' but has \
2886 'runtime: {}'; the key governs an async hook's job queue \
2887 and is only recognized on 'runtime: hook' entries",
2888 runtime.as_str()
2889 ),
2890 });
2891 }
2892 match raw {
2893 "drop" => HookOverflowPolicy::Drop,
2894 "block" => HookOverflowPolicy::Block,
2895 other => {
2896 return Err(RuntimeManifestError::InvalidArtifact {
2897 index,
2898 message: format!(
2899 "artifact '{name}' declares unknown on_overflow \
2900 '{other}'; expected: drop, block"
2901 ),
2902 });
2903 }
2904 }
2905 }
2906 };
2907
2908 if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
2915 return Err(RuntimeManifestError::InvalidArtifact {
2916 index,
2917 message: format!(
2918 "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
2919 is recognized only on 'runtime: hook', 'runtime: tool', and \
2920 'runtime: driver' entries",
2921 runtime.as_str()
2922 ),
2923 });
2924 }
2925
2926 let gateway = match artifact.gateway {
2927 None => None,
2928 Some(raw_gateway) => {
2929 if runtime == ArtifactRuntime::Skill {
2930 return Err(RuntimeManifestError::InvalidArtifact {
2931 index,
2932 message: format!(
2933 "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
2934 the key is recognized only on 'runtime: hook', \
2935 'runtime: tool', and 'runtime: driver' entries",
2936 runtime.as_str()
2937 ),
2938 });
2939 }
2940 Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
2941 }
2942 };
2943
2944 Ok(RuntimeArtifact {
2945 name,
2946 version,
2947 runtime,
2948 source,
2949 local_source,
2950 prompt_payload,
2951 capabilities,
2952 on_overflow,
2953 config: artifact.config,
2954 gateway,
2955 })
2956 })
2957 .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
2958
2959 if raw
2963 .capabilities
2964 .as_ref()
2965 .is_some_and(|caps| caps.task_io.is_some())
2966 {
2967 return Err(RuntimeManifestError::InvalidCapabilities {
2968 field: "capabilities.task_io".to_string(),
2969 message: "is recognized only on 'runtime: hook' artifact entries, not in the \
2970 capsule-wide capabilities block — the grant is per-hook"
2971 .to_string(),
2972 });
2973 }
2974 if raw.config.is_some() {
2979 return Err(RuntimeManifestError::InvalidCapabilities {
2980 field: "config".to_string(),
2981 message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
2982 'runtime: driver' entry that reads it — there is no capsule-wide \
2983 config block"
2984 .to_string(),
2985 });
2986 }
2987 let capabilities = parse_capabilities(raw.capabilities)?;
2988 let inference = parse_inference(raw.inference)?;
2989 validate_driver_gateways(&artifacts, inference.as_ref())?;
2990 validate_process_driver_entry(&artifacts, inference.as_ref())?;
2991
2992 if let Some(ref sp_art) = inference
2995 .as_ref()
2996 .and_then(|i| i.system_prompt_artifact.clone())
2997 {
2998 let matching = artifacts.iter().find(|a| &a.name == sp_art);
2999 match matching {
3000 None => {
3001 return Err(RuntimeManifestError::InvalidInferenceConfig {
3002 field: "inference.system_prompt_artifact".to_string(),
3003 message: format!("artifact '{sp_art}' is not declared in artifacts:"),
3004 });
3005 }
3006 Some(a) if !a.prompt_payload => {
3007 return Err(RuntimeManifestError::InvalidInferenceConfig {
3008 field: "inference.system_prompt_artifact".to_string(),
3009 message: format!(
3010 "artifact '{sp_art}' (runtime: {}) does not declare \
3011 'prompt_payload: true', which system_prompt_artifact requires",
3012 a.runtime.as_str()
3013 ),
3014 });
3015 }
3016 Some(_) => {} }
3018 }
3019 let context = parse_context(raw.context)?;
3020 let observability = parse_observability(raw.observability);
3021 let trace = raw
3022 .trace
3023 .map(|raw_trace| {
3024 let capture = resolve_trace_capture(
3025 raw_trace.capture.as_deref(),
3026 raw_trace.include_tool_output,
3027 )?;
3028 let retain = parse_trace_retain(raw_trace.retain)?;
3029 Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
3030 })
3031 .transpose()?;
3032 let network = raw.network.map(|n| NetworkConfig {
3033 internal_port: n.internal_port,
3034 });
3035 let lifecycle = raw.lifecycle.map(|raw_lc| {
3036 let defaults = LifecycleConfig::default();
3037 LifecycleConfig {
3038 task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
3039 after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
3040 queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
3041 input_timeout_secs: raw_lc.input_timeout_secs,
3042 conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
3043 max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
3044 shell_grace_secs: raw_lc.shell_grace_secs.unwrap_or(defaults.shell_grace_secs),
3045 delegation_deadline_secs: raw_lc
3046 .delegation_deadline_secs
3047 .unwrap_or(defaults.delegation_deadline_secs),
3048 }
3049 });
3050
3051 let exports = parse_exports(raw.exports)?;
3052
3053 Ok(Self {
3054 name,
3055 version,
3056 artifacts,
3057 capabilities,
3058 inference,
3059 context,
3060 observability,
3061 trace,
3062 network,
3063 lifecycle,
3064 exports,
3065 mur_version: raw.mur_version.filter(|s| !s.trim().is_empty()),
3066 unknown_keys,
3067 })
3068 }
3069}
3070
3071fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
3078 let Some(raw_exports) = raw else {
3079 return Ok(None);
3080 };
3081 let files = raw_exports.files.map(parse_file_export).transpose()?;
3082 let peer_files = raw_exports
3083 .peer_files
3084 .map(parse_peer_files_export)
3085 .transpose()?;
3086 Ok(Some(Exports { files, peer_files }))
3087}
3088
3089fn parse_peer_files_export(
3097 raw: RawPeerFilesExport,
3098) -> Result<PeerFilesExport, RuntimeManifestError> {
3099 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
3100 field: field.to_string(),
3101 message,
3102 };
3103
3104 let root = raw
3105 .root
3106 .map(|root| root.trim().to_string())
3107 .filter(|root| !root.is_empty())
3108 .ok_or_else(|| {
3109 invalid(
3110 "exports.peer_files.root",
3111 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
3112 )
3113 })?;
3114 check_export_root_shape("exports.peer_files.root", &root)?;
3115
3116 let max_ttl_secs = match raw.max_ttl {
3117 None => None,
3118 Some(value) => {
3119 let text = scalar_text(&value).ok_or_else(|| {
3120 invalid(
3121 "exports.peer_files.max_ttl",
3122 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
3123 )
3124 })?;
3125 let parsed = parse_duration_secs(&text)
3126 .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
3127 if parsed == 0 {
3128 return Err(invalid(
3129 "exports.peer_files.max_ttl",
3130 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
3131 ));
3132 }
3133 Some(parsed)
3134 }
3135 };
3136
3137 let max_bytes = match raw.max_bytes {
3138 None => DEFAULT_PEER_FILES_MAX_BYTES,
3139 Some(value) => {
3140 let text = scalar_text(&value).ok_or_else(|| {
3141 invalid(
3142 "exports.peer_files.max_bytes",
3143 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
3144 )
3145 })?;
3146 let parsed = parse_byte_size(&text)
3147 .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
3148 if parsed == 0 {
3149 return Err(invalid(
3150 "exports.peer_files.max_bytes",
3151 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
3152 ));
3153 }
3154 parsed
3155 }
3156 };
3157
3158 Ok(PeerFilesExport {
3159 root,
3160 max_ttl_secs,
3161 max_bytes,
3162 })
3163}
3164
3165fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_yaml::Value>, D::Error>
3173where
3174 D: serde::Deserializer<'de>,
3175{
3176 serde_yaml::Value::deserialize(deserializer).map(Some)
3177}
3178
3179fn scalar_text(value: &serde_yaml::Value) -> Option<String> {
3180 match value {
3181 serde_yaml::Value::String(text) => Some(text.clone()),
3182 serde_yaml::Value::Number(number) => Some(number.to_string()),
3183 _ => None,
3184 }
3185}
3186
3187fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
3191 for component in Path::new(root).components() {
3192 match component {
3193 std::path::Component::Normal(_) | std::path::Component::CurDir => {}
3194 std::path::Component::ParentDir => {
3195 return Err(RuntimeManifestError::InvalidExports {
3196 field: field.to_string(),
3197 message: format!(
3198 "'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}"
3199 ),
3200 });
3201 }
3202 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
3203 return Err(RuntimeManifestError::InvalidExports {
3204 field: field.to_string(),
3205 message: format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}"),
3206 });
3207 }
3208 }
3209 }
3210 Ok(())
3211}
3212
3213fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
3214 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
3215 field: field.to_string(),
3216 message,
3217 };
3218
3219 let root = raw
3220 .root
3221 .map(|root| root.trim().to_string())
3222 .filter(|root| !root.is_empty())
3223 .ok_or_else(|| {
3224 invalid(
3225 "exports.files.root",
3226 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
3227 )
3228 })?;
3229 check_export_root_shape("exports.files.root", &root)?;
3230
3231 let mode = match raw.mode.as_deref().map(str::trim) {
3232 None | Some("") => {
3233 return Err(invalid(
3234 "exports.files.mode",
3235 "is required and must be 'read-only'".to_string(),
3236 ));
3237 }
3238 Some("read-only") => ExportMode::ReadOnly,
3239 Some(other) => {
3240 return Err(invalid(
3241 "exports.files.mode",
3242 format!("'{other}' must be 'read-only'"),
3243 ));
3244 }
3245 };
3246
3247 let max_bytes = match raw.max_bytes {
3248 None => DEFAULT_EXPORT_MAX_BYTES,
3249 Some(value) => {
3250 let Some(text) = scalar_text(&value) else {
3251 return Err(invalid(
3252 "exports.files.max_bytes",
3253 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
3254 ));
3255 };
3256 let parsed = parse_byte_size(&text)
3257 .map_err(|message| invalid("exports.files.max_bytes", message))?;
3258 if parsed == 0 {
3259 return Err(invalid(
3260 "exports.files.max_bytes",
3261 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
3262 ));
3263 }
3264 parsed
3265 }
3266 };
3267
3268 Ok(FileExport {
3269 root,
3270 mode,
3271 max_bytes,
3272 })
3273}
3274
3275const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
3278
3279pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
3281
3282fn check_network_destination_shape(entry: &str) -> Result<(), String> {
3289 let trimmed = entry.trim();
3290 if trimmed.is_empty() {
3291 return Err(format!(
3292 "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3293 ));
3294 }
3295
3296 if trimmed.contains("://") {
3297 let url = Url::parse(trimmed).map_err(|error| {
3298 format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
3299 })?;
3300 let scheme = url.scheme().to_ascii_lowercase();
3301 if scheme != "http" && scheme != "https" {
3302 return Err(format!(
3303 "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
3304 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3305 ));
3306 }
3307 if url.host_str().is_none_or(str::is_empty) {
3308 return Err(format!(
3309 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3310 ));
3311 }
3312 if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
3313 return Err(format!(
3314 "'{entry}' must not include a path, query or fragment; \
3315 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3316 ));
3317 }
3318 return Ok(());
3319 }
3320
3321 if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
3322 return Err(format!(
3323 "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3324 ));
3325 }
3326 if let Some((host, port)) = trimmed.rsplit_once(':') {
3328 if !host.ends_with(']') {
3329 if host.is_empty() {
3330 return Err(format!(
3331 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3332 ));
3333 }
3334 if port.parse::<u16>().is_err() {
3335 return Err(format!(
3336 "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
3337 ));
3338 }
3339 }
3340 }
3341 Ok(())
3342}
3343
3344fn parse_capabilities(
3345 raw: Option<RawCapabilities>,
3346) -> Result<Option<Capabilities>, RuntimeManifestError> {
3347 let Some(raw_caps) = raw else {
3348 return Ok(None);
3349 };
3350
3351 let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
3352 allow: raw_network.allow,
3353 unix_sockets: raw_network.unix_sockets,
3354 });
3355
3356 let peer_fetch = raw_caps
3357 .peer_fetch
3358 .map(|raw_peer_fetch| {
3359 if raw_peer_fetch.allow.is_empty() {
3360 return Err(RuntimeManifestError::InvalidCapabilities {
3361 field: "capabilities.peer_fetch.allow".to_string(),
3362 message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
3363 });
3364 }
3365 for entry in &raw_peer_fetch.allow {
3366 check_network_destination_shape(entry).map_err(|message| {
3367 RuntimeManifestError::InvalidCapabilities {
3368 field: "capabilities.peer_fetch.allow".to_string(),
3369 message,
3370 }
3371 })?;
3372 }
3373 Ok(PeerFetchCapabilities {
3374 allow: raw_peer_fetch.allow,
3375 })
3376 })
3377 .transpose()?;
3378
3379 let filesystem = raw_caps
3380 .filesystem
3381 .map(|raw_filesystem| FilesystemCapabilities {
3382 scope: raw_filesystem.scope.and_then(|scope| {
3383 let trimmed = scope.trim();
3384 (!trimmed.is_empty()).then(|| trimmed.to_string())
3385 }),
3386 workdir_exec: raw_filesystem.workdir_exec,
3391 read_only: raw_filesystem
3395 .read_only
3396 .into_iter()
3397 .filter_map(|entry| {
3398 let trimmed = entry.trim();
3399 (!trimmed.is_empty()).then(|| trimmed.to_string())
3400 })
3401 .collect(),
3402 });
3403
3404 let shell = raw_caps
3405 .shell
3406 .map(|raw_shell| {
3407 if raw_shell.allow.is_empty() {
3408 return Err(RuntimeManifestError::InvalidCapabilities {
3409 field: "capabilities.shell.allow".to_string(),
3410 message: "must contain at least one binary".to_string(),
3411 });
3412 }
3413
3414 let interpreter_runtime =
3415 parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
3416 let staged_runtime = parse_staged_runtime(
3419 &raw_shell.allow,
3420 &interpreter_runtime,
3421 raw_shell.staged_runtime,
3422 )?;
3423
3424 Ok(ShellCapabilities {
3425 allow: raw_shell.allow,
3426 strip_env: raw_shell.strip_env,
3427 baseline_env: raw_shell.baseline_env,
3428 interpreter_runtime,
3429 staged_runtime,
3430 })
3431 })
3432 .transpose()?;
3433
3434 let spawn = raw_caps.spawn.map(|raw_spawn| SpawnCapabilities {
3435 allow: raw_spawn.allow,
3436 });
3437
3438 let env = raw_caps.env.map(|raw_env| EnvCapabilities {
3439 allow: raw_env.allow,
3440 });
3441
3442 let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
3443
3444 let resources = raw_caps
3445 .resources
3446 .map(parse_resource_capabilities)
3447 .transpose()?;
3448
3449 let state = raw_caps.state.map(parse_state_capabilities);
3450
3451 let task_io = raw_caps
3452 .task_io
3453 .map(|raw_task_io| {
3454 raw_task_io
3455 .read
3456 .map(|read| TaskIoCapabilities { read })
3457 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3458 field: "capabilities.task_io.read".to_string(),
3459 message: "must be set explicitly to true or false — a capability is never \
3460 inferred"
3461 .to_string(),
3462 })
3463 })
3464 .transpose()?;
3465
3466 let conversation = raw_caps
3467 .conversation
3468 .map(|raw_conversation| {
3469 raw_conversation
3470 .read
3471 .map(|read| ConversationCapabilities { read })
3472 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3473 field: "capabilities.conversation.read".to_string(),
3474 message: "must be set explicitly to true or false — a capability is never \
3475 inferred"
3476 .to_string(),
3477 })
3478 })
3479 .transpose()?;
3480
3481 let plan = raw_caps
3482 .plan
3483 .map(|raw_plan| {
3484 raw_plan
3485 .submit
3486 .map(|submit| PlanCapabilities { submit })
3487 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3488 field: "capabilities.plan.submit".to_string(),
3489 message: "must be set explicitly to true or false — a capability is never \
3490 inferred"
3491 .to_string(),
3492 })
3493 })
3494 .transpose()?;
3495
3496 let containment = raw_caps
3497 .containment
3498 .as_deref()
3499 .map(str::trim)
3500 .filter(|value| !value.is_empty())
3501 .map(|value| {
3502 value.parse::<ContainmentClass>().map_err(|error| {
3503 RuntimeManifestError::InvalidCapabilities {
3504 field: "capabilities.containment".to_string(),
3505 message: error.to_string(),
3506 }
3507 })
3508 })
3509 .transpose()?;
3510
3511 Ok(Some(Capabilities {
3512 network,
3513 peer_fetch,
3514 filesystem,
3515 shell,
3516 spawn,
3517 env,
3518 limits,
3519 resources,
3520 state,
3521 task_io,
3522 conversation,
3523 plan,
3524 containment,
3525 }))
3526}
3527
3528fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
3538 StateCapabilities {
3539 store: raw.store.map(|store| store.trim().to_string()),
3540 }
3541}
3542
3543fn parse_interpreter_runtime(
3549 allow: &[String],
3550 raw_grants: Vec<RawInterpreterRuntimeGrant>,
3551) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
3552 let mut grants = Vec::with_capacity(raw_grants.len());
3553 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
3554 let base = format!("capabilities.shell.interpreter_runtime[{index}]");
3555
3556 let binary = raw_grant
3557 .binary
3558 .filter(|b| !b.trim().is_empty())
3559 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3560 field: format!("{base}.binary"),
3561 message: "must name a binary".to_string(),
3562 })?;
3563
3564 if !allow.contains(&binary) {
3568 return Err(RuntimeManifestError::InvalidCapabilities {
3569 field: format!("{base}.binary"),
3570 message: format!(
3571 "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
3572 only narrow filesystem access for an already-allowlisted binary, never \
3573 grant exec"
3574 ),
3575 });
3576 }
3577
3578 if raw_grant.dirs.is_empty() {
3579 return Err(RuntimeManifestError::InvalidCapabilities {
3580 field: format!("{base}.dirs"),
3581 message: "must name at least one directory".to_string(),
3582 });
3583 }
3584
3585 let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
3586 for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
3587 let dir_base = format!("{base}.dirs[{dir_index}]");
3588
3589 let path = raw_dir
3590 .path
3591 .filter(|p| !p.trim().is_empty())
3592 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3593 field: format!("{dir_base}.path"),
3594 message: "must name a host directory".to_string(),
3595 })?;
3596 if !path.starts_with('/') {
3597 return Err(RuntimeManifestError::InvalidCapabilities {
3598 field: format!("{dir_base}.path"),
3599 message: format!(
3600 "'{path}' must be an absolute host path (start with '/') — these are \
3601 filesystem paths outside the workdir"
3602 ),
3603 });
3604 }
3605
3606 let list_dir =
3607 raw_dir
3608 .list_dir
3609 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3610 field: format!("{dir_base}.list_dir"),
3611 message: format!(
3612 "'{path}' must set list_dir explicitly to true or false — \
3613 enumerability is never inferred"
3614 ),
3615 })?;
3616
3617 dirs.push(InterpreterRuntimeDir { path, list_dir });
3618 }
3619
3620 grants.push(InterpreterRuntimeGrant { binary, dirs });
3621 }
3622
3623 Ok(grants)
3624}
3625
3626fn parse_staged_runtime(
3647 allow: &[String],
3648 interpreter_runtime: &[InterpreterRuntimeGrant],
3649 raw_grants: Vec<RawStagedRuntimeGrant>,
3650) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
3651 let mut grants = Vec::with_capacity(raw_grants.len());
3652 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
3653 let base = format!("capabilities.shell.staged_runtime[{index}]");
3654
3655 let binary = raw_grant
3656 .binary
3657 .filter(|b| !b.trim().is_empty())
3658 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3659 field: format!("{base}.binary"),
3660 message: "must name a binary".to_string(),
3661 })?;
3662
3663 if !allow.contains(&binary) {
3664 return Err(RuntimeManifestError::InvalidCapabilities {
3665 field: format!("{base}.binary"),
3666 message: format!(
3667 "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
3668 stage the runtime tree behind an already-allowlisted binary, never grant exec"
3669 ),
3670 });
3671 }
3672
3673 if interpreter_runtime
3674 .iter()
3675 .any(|grant| grant.binary == binary)
3676 {
3677 return Err(RuntimeManifestError::InvalidCapabilities {
3678 field: format!("{base}.binary"),
3679 message: format!(
3680 "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
3681 are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
3682 tree into the capsule's own root, which is what makes widening the capsule's \
3683 host filesystem scope unnecessary rather than something to add to it. Remove \
3684 the interpreter_runtime grant for '{binary}'"
3685 ),
3686 });
3687 }
3688
3689 let source_path = raw_grant
3690 .source_path
3691 .filter(|p| !p.trim().is_empty())
3692 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3693 field: format!("{base}.source_path"),
3694 message: "must name the host directory holding the pinned runtime tree".to_string(),
3695 })?;
3696 if !source_path.starts_with('/') {
3697 return Err(RuntimeManifestError::InvalidCapabilities {
3698 field: format!("{base}.source_path"),
3699 message: format!(
3700 "'{source_path}' must be an absolute host path (start with '/') — \
3701 staged-runtime sources are host filesystem paths outside the workdir"
3702 ),
3703 });
3704 }
3705
3706 let pin = raw_grant
3707 .pin
3708 .map(|p| p.trim().to_string())
3709 .filter(|p| !p.is_empty())
3710 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
3711 field: format!("{base}.pin"),
3712 message: format!(
3713 "'{source_path}' must declare a non-empty pin identifying which build this \
3714 tree is — it is never inferred, because it is the value a human compares \
3715 across two hosts to confirm the same runtime shipped to both"
3716 ),
3717 })?;
3718
3719 grants.push(StagedRuntimeGrant {
3720 binary,
3721 source_path,
3722 pin,
3723 });
3724 }
3725
3726 Ok(grants)
3727}
3728
3729fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
3734 let reject_zero_usize =
3735 |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
3736 match value {
3737 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
3738 field: format!("capabilities.limits.{field}"),
3739 message: "must be greater than zero".to_string(),
3740 }),
3741 _ => Ok(()),
3742 }
3743 };
3744
3745 reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
3746 reject_zero_usize(raw.table_elements, "table_elements")?;
3747 reject_zero_usize(raw.instances, "instances")?;
3748 if raw.deadline_seconds == Some(0) {
3749 return Err(RuntimeManifestError::InvalidCapabilities {
3750 field: "capabilities.limits.deadline_seconds".to_string(),
3751 message: "must be greater than zero".to_string(),
3752 });
3753 }
3754
3755 Ok(ResourceLimits {
3756 memory_bytes: raw.memory_bytes,
3757 table_elements: raw.table_elements,
3758 instances: raw.instances,
3759 deadline_seconds: raw.deadline_seconds,
3760 })
3761}
3762
3763fn parse_resource_capabilities(
3769 raw: RawResourceCapabilities,
3770) -> Result<ResourceCapabilities, RuntimeManifestError> {
3771 let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
3772 match value {
3773 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
3774 field: format!("capabilities.resources.{field}"),
3775 message: "must be greater than zero".to_string(),
3776 }),
3777 _ => Ok(()),
3778 }
3779 };
3780
3781 reject_zero(raw.max_processes, "max_processes")?;
3782 reject_zero(raw.max_open_files, "max_open_files")?;
3783 reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
3784 reject_zero(raw.cpu_seconds, "cpu_seconds")?;
3785 reject_zero(raw.memory_bytes, "memory_bytes")?;
3786 reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
3787 reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
3788 reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
3789 reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
3790 reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
3791
3792 Ok(ResourceCapabilities {
3793 max_processes: raw.max_processes,
3794 max_open_files: raw.max_open_files,
3795 max_file_size_bytes: raw.max_file_size_bytes,
3796 cpu_seconds: raw.cpu_seconds,
3797 memory_bytes: raw.memory_bytes,
3798 cgroup_memory_bytes: raw.cgroup_memory_bytes,
3799 cgroup_pids_max: raw.cgroup_pids_max,
3800 cgroup_cpu_percent: raw.cgroup_cpu_percent,
3801 cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
3802 workdir_max_bytes: raw.workdir_max_bytes,
3803 })
3804}
3805
3806const PROCESS_DRIVER_REQUIRED: &str =
3810 "missing required field; a transport: process capsule must name the process driver that \
3811 knows how to drive its harness. inference.command no longer selects a harness — it only \
3812 overrides the binary the driver names. Add inference.driver.artifact naming a process \
3813 driver, for example murmur-driver-claude-code";
3814
3815fn parse_inference(
3816 raw: Option<RawInferenceConfig>,
3817) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
3818 let Some(raw) = raw else {
3819 return Ok(None);
3820 };
3821
3822 let transport = raw
3823 .transport
3824 .map(|s| s.trim().to_string())
3825 .filter(|s| !s.is_empty())
3826 .unwrap_or_else(|| "http".to_string());
3827
3828 let compaction = parse_compaction(raw.compaction)?;
3829 let system_prompt = optional_trimmed_string(raw.system_prompt);
3830 let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
3831 let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
3832
3833 let prompt_sources_set = [
3834 system_prompt.is_some(),
3835 system_prompt_file.is_some(),
3836 system_prompt_artifact.is_some(),
3837 ]
3838 .iter()
3839 .filter(|&&b| b)
3840 .count();
3841 if prompt_sources_set > 1 {
3842 return Err(RuntimeManifestError::InvalidInferenceConfig {
3843 field: "inference.system_prompt".to_string(),
3844 message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
3845 may be set"
3846 .to_string(),
3847 });
3848 }
3849
3850 let max_turns = match raw.max_turns {
3851 None => 10,
3852 Some(0) => {
3853 return Err(RuntimeManifestError::InvalidInferenceConfig {
3854 field: "inference.max_turns".to_string(),
3855 message: "must be greater than 0".to_string(),
3856 });
3857 }
3858 Some(n) => n,
3859 };
3860
3861 if raw.max_task_reopens.is_some() {
3865 return Err(RuntimeManifestError::InvalidInferenceConfig {
3866 field: "inference.max_task_reopens".to_string(),
3867 message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
3868 .to_string(),
3869 });
3870 }
3871
3872 for (present, removed, moved_to) in [
3876 (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
3877 (raw.api_key.is_some(), "api_key", "gateway.api_key"),
3878 ] {
3879 if !present {
3880 continue;
3881 }
3882 let entry = raw
3883 .driver
3884 .as_ref()
3885 .or(raw.provider.as_ref())
3886 .and_then(|driver| driver.artifact.as_deref())
3887 .map(str::trim)
3888 .filter(|name| !name.is_empty())
3889 .map(|name| format!("on the artifacts: entry '{name}'"))
3890 .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
3891 return Err(RuntimeManifestError::InvalidInferenceConfig {
3892 field: format!("inference.{removed}"),
3893 message: format!("was removed; set {moved_to} {entry} instead"),
3894 });
3895 }
3896
3897 match transport.as_str() {
3898 "http" => {
3899 let model = required_inference_field(raw.model, "model")?;
3900
3901 let driver_raw = raw.driver.or(raw.provider).ok_or_else(|| {
3902 RuntimeManifestError::InvalidInferenceConfig {
3903 field: "inference.driver".to_string(),
3904 message: "missing required field".to_string(),
3905 }
3906 })?;
3907 let driver = parse_inference_driver(driver_raw)?;
3908
3909 if raw.max_tokens == Some(0) {
3913 return Err(RuntimeManifestError::InvalidInferenceConfig {
3914 field: "inference.max_tokens".to_string(),
3915 message: "must be greater than 0".to_string(),
3916 });
3917 }
3918 if raw.max_session_tokens == Some(0) {
3919 return Err(RuntimeManifestError::InvalidInferenceConfig {
3920 field: "inference.max_session_tokens".to_string(),
3921 message: "must be greater than 0".to_string(),
3922 });
3923 }
3924
3925 Ok(Some(InferenceConfig {
3926 transport,
3927 model,
3928 driver: Some(driver),
3929 command: None,
3930 compaction,
3931 system_prompt,
3932 system_prompt_file,
3933 system_prompt_artifact,
3934 max_turns,
3935 max_tokens: raw.max_tokens,
3936 max_session_tokens: raw.max_session_tokens,
3937 }))
3938 }
3939 "process" => {
3940 if raw.provider.is_some() {
3941 return Err(RuntimeManifestError::InvalidInferenceConfig {
3942 field: "inference.provider.artifact".to_string(),
3943 message: "is not valid with transport: process; name the process driver \
3944 under inference.driver"
3945 .to_string(),
3946 });
3947 }
3948 if raw.max_tokens.is_some() {
3951 return Err(RuntimeManifestError::InvalidInferenceConfig {
3952 field: "inference.max_tokens".to_string(),
3953 message: "is not valid with transport: process".to_string(),
3954 });
3955 }
3956
3957 if raw.max_session_tokens == Some(0) {
3961 return Err(RuntimeManifestError::InvalidInferenceConfig {
3962 field: "inference.max_session_tokens".to_string(),
3963 message: "must be greater than 0".to_string(),
3964 });
3965 }
3966
3967 let driver = raw.driver.map(parse_inference_driver).transpose()?;
3968 let command = optional_trimmed_string(raw.command);
3969 if driver.is_none() {
3973 return Err(RuntimeManifestError::InvalidInferenceConfig {
3974 field: "inference.driver.artifact".to_string(),
3975 message: PROCESS_DRIVER_REQUIRED.to_string(),
3976 });
3977 }
3978 let model = optional_trimmed_string(raw.model).unwrap_or_default();
3982
3983 Ok(Some(InferenceConfig {
3984 transport,
3985 model,
3986 driver,
3987 command,
3988 compaction,
3989 system_prompt,
3990 system_prompt_file,
3991 system_prompt_artifact,
3992 max_turns,
3993 max_tokens: None,
3994 max_session_tokens: raw.max_session_tokens,
3995 }))
3996 }
3997 other => Err(RuntimeManifestError::InvalidInferenceConfig {
3998 field: "inference.transport".to_string(),
3999 message: format!("unknown value '{other}'"),
4000 }),
4001 }
4002}
4003
4004fn parse_context(
4005 raw: Option<RawContextConfig>,
4006) -> Result<Option<ContextConfig>, RuntimeManifestError> {
4007 let Some(raw) = raw else {
4008 return Ok(None);
4009 };
4010
4011 if let Some(max_tokens) = raw.max_tokens {
4012 if max_tokens == 0 {
4013 return Err(RuntimeManifestError::InvalidInferenceConfig {
4014 field: "context.max_tokens".to_string(),
4015 message: "must be greater than 0".to_string(),
4016 });
4017 }
4018 }
4019
4020 let fraction = |field: &str, value: Option<f32>, default: f32| match value {
4021 None => Ok(default),
4022 Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
4023 Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
4024 field: field.to_string(),
4025 message: "must be between 0.0 and 1.0".to_string(),
4026 }),
4027 };
4028
4029 let record = match raw.record.as_deref().map(str::trim) {
4030 None => true,
4031 Some("on") => true,
4032 Some("off") => false,
4033 Some(other) => {
4034 return Err(RuntimeManifestError::InvalidInferenceConfig {
4035 field: "context.record".to_string(),
4036 message: format!("unknown value '{other}'; expected: on, off"),
4037 })
4038 }
4039 };
4040
4041 Ok(Some(ContextConfig {
4042 max_tokens: raw.max_tokens,
4043 record,
4044 record_store: raw.record_store.map(|store| store.trim().to_string()),
4045 seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
4046 seed_overflow_margin: fraction(
4047 "context.seed_overflow_margin",
4048 raw.seed_overflow_margin,
4049 DEFAULT_SEED_OVERFLOW_MARGIN,
4050 )?,
4051 retain: parse_context_retain(raw.retain)?,
4052 }))
4053}
4054
4055const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
4057
4058const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
4060
4061const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
4064 keep everything, which is what no policy means";
4065
4066fn parse_retain_block(
4072 block: &str,
4073 count_key: &str,
4074 accepted: &[&str],
4075 raw: serde_yaml::Value,
4076 invalid: &dyn Fn(String, String) -> RuntimeManifestError,
4077) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
4078 let mapping = match raw {
4079 serde_yaml::Value::Mapping(mapping) => mapping,
4080 serde_yaml::Value::Null => {
4082 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
4083 }
4084 other => {
4085 return Err(invalid(
4086 block.to_string(),
4087 format!(
4088 "must be a block of keys ({}), got '{other:?}'",
4089 accepted.join(", ")
4090 ),
4091 ))
4092 }
4093 };
4094 if mapping.is_empty() {
4095 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
4096 }
4097
4098 let mut count: Option<u32> = None;
4099 let mut max_age_secs: Option<u64> = None;
4100 for (key, value) in &mapping {
4101 let key = key.as_str().unwrap_or_default();
4102 let field = format!("{block}.{key}");
4103 if key == count_key {
4104 let parsed = value
4105 .as_u64()
4106 .and_then(|n| u32::try_from(n).ok())
4107 .ok_or_else(|| {
4108 invalid(
4109 field.clone(),
4110 "must be a whole number of at least 1".to_string(),
4111 )
4112 })?;
4113 if parsed == 0 {
4114 return Err(invalid(
4115 field,
4116 "must be at least 1 — a limit of zero would delete what retention exists \
4117 to keep; remove the key to leave it unbounded"
4118 .to_string(),
4119 ));
4120 }
4121 count = Some(parsed);
4122 } else if key == "max_age" {
4123 let text = scalar_text(value).ok_or_else(|| {
4124 invalid(
4125 field.clone(),
4126 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
4127 )
4128 })?;
4129 let parsed =
4130 parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
4131 if parsed == 0 {
4132 return Err(invalid(
4133 field,
4134 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
4135 ));
4136 }
4137 max_age_secs = Some(parsed);
4138 } else {
4139 return Err(invalid(
4140 block.to_string(),
4141 format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
4142 ));
4143 }
4144 }
4145 Ok((count, max_age_secs))
4146}
4147
4148fn parse_trace_retain(
4149 raw: Option<serde_yaml::Value>,
4150) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
4151 let Some(raw) = raw else {
4152 return Ok(None);
4153 };
4154 let (max_sessions, max_age_secs) = parse_retain_block(
4155 "trace.retain",
4156 "max_sessions",
4157 &TRACE_RETAIN_KEYS,
4158 raw,
4159 &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
4160 )?;
4161 Ok(Some(TraceRetainConfig {
4162 max_sessions,
4163 max_age_secs,
4164 }))
4165}
4166
4167fn parse_context_retain(
4168 raw: Option<serde_yaml::Value>,
4169) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
4170 let Some(raw) = raw else {
4171 return Ok(None);
4172 };
4173 let (max_messages, max_age_secs) = parse_retain_block(
4174 "context.retain",
4175 "max_messages",
4176 &CONTEXT_RETAIN_KEYS,
4177 raw,
4178 &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
4179 )?;
4180 Ok(Some(ContextRetainConfig {
4181 max_messages,
4182 max_age_secs,
4183 }))
4184}
4185
4186fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
4187 raw.map(|raw| ObservabilityConfig {
4188 otel_endpoint: raw
4189 .otel_endpoint
4190 .map(|value| value.trim().to_string())
4191 .filter(|value| !value.is_empty()),
4192 eval: raw.eval.map(parse_eval_config),
4193 })
4194}
4195
4196fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
4197 let scorers = raw
4198 .scorers
4199 .into_iter()
4200 .filter_map(|raw| {
4201 let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
4202 match raw.scorer_type.as_str() {
4203 "exit_ok" => Some(ScorerConfig::ExitOk { name }),
4204 "max_turns" => Some(ScorerConfig::MaxTurns {
4205 name,
4206 max: raw.max.unwrap_or(10) as u32,
4207 }),
4208 "max_tokens" => Some(ScorerConfig::MaxTokens {
4209 name,
4210 max: raw.max.unwrap_or(100_000),
4211 }),
4212 "tool_sequence" => Some(ScorerConfig::ToolSequence {
4213 name,
4214 expected: raw.expected.unwrap_or_default(),
4215 }),
4216 "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
4217 other => {
4218 eprintln!("[murmur-artifact] unknown scorer type '{other}' — skipping");
4219 None
4220 }
4221 }
4222 })
4223 .collect();
4224
4225 EvalConfig {
4226 dataset_id: raw.dataset_id,
4227 scorers,
4228 }
4229}
4230
4231fn parse_compaction(
4232 raw: Option<RawCompactionConfig>,
4233) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
4234 let Some(raw) = raw else {
4235 return Ok(None);
4236 };
4237
4238 if let Some(threshold) = raw.threshold {
4239 if threshold <= 0.0 || threshold > 1.0 {
4240 return Err(RuntimeManifestError::InvalidInferenceConfig {
4241 field: "inference.compaction.threshold".to_string(),
4242 message: format!("must be in (0.0, 1.0] but got {threshold}"),
4243 });
4244 }
4245 }
4246
4247 if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
4248 return Err(RuntimeManifestError::InvalidInferenceConfig {
4249 field: "inference.compaction.system_prompt".to_string(),
4250 message: "at most one of inference.compaction.system_prompt, \
4251 inference.compaction.system_prompt_file may be set"
4252 .to_string(),
4253 });
4254 }
4255
4256 Ok(Some(CompactionConfig {
4257 threshold: raw.threshold,
4258 model: raw.model,
4259 system_prompt: raw.system_prompt,
4260 system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
4261 dump_summaries: raw.dump_summaries,
4264 }))
4265}
4266
4267fn optional_trimmed_string(value: Option<String>) -> Option<String> {
4268 value
4269 .map(|s| s.trim().to_string())
4270 .filter(|s| !s.is_empty())
4271}
4272
4273fn required_inference_field(
4274 value: Option<String>,
4275 key: &str,
4276) -> Result<String, RuntimeManifestError> {
4277 value
4278 .map(|value| value.trim().to_string())
4279 .filter(|value| !value.is_empty())
4280 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
4281 field: format!("inference.{key}"),
4282 message: "missing required field".to_string(),
4283 })
4284}
4285
4286fn parse_artifact_gateway(
4294 index: usize,
4295 name: &str,
4296 raw: Option<RawArtifactGateway>,
4297 secrets: SecretResolution,
4298) -> Result<ArtifactGateway, RuntimeManifestError> {
4299 let (endpoint, api_key, keyless) = match raw {
4300 Some(raw) => (
4301 optional_trimmed_string(raw.endpoint),
4302 raw.api_key,
4303 raw.keyless,
4304 ),
4305 None => (None, None, None),
4306 };
4307 let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
4308 index,
4309 message: format!(
4310 "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
4311 the upstream URL the runtime sends this artifact's keyed requests to"
4312 ),
4313 })?;
4314 validate_gateway_endpoint(name, &endpoint)
4315 .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
4316
4317 let keyless = match keyless {
4318 None | Some(serde_yaml::Value::Null) | Some(serde_yaml::Value::Bool(false)) => false,
4319 Some(serde_yaml::Value::Bool(true)) => true,
4320 Some(_) => {
4321 return Err(RuntimeManifestError::InvalidArtifact {
4322 index,
4323 message: format!(
4324 "artifact '{name}' declares gateway.keyless with a value that is not a \
4325 boolean; write keyless: true for an upstream that takes no key"
4326 ),
4327 })
4328 }
4329 };
4330 if keyless && api_key.is_some() {
4331 return Err(RuntimeManifestError::InvalidArtifact {
4332 index,
4333 message: format!(
4334 "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
4335 gateway either presents a key or is keyless"
4336 ),
4337 });
4338 }
4339 if keyless {
4340 return Ok(ArtifactGateway {
4341 endpoint,
4342 api_key: None,
4343 keyless: true,
4344 });
4345 }
4346
4347 let written = api_key
4348 .flatten()
4349 .map(|value| value.trim().to_string())
4350 .filter(|value| !value.is_empty());
4351 let Some(written) = written else {
4352 return Err(RuntimeManifestError::GatewayWithoutCredential {
4353 index,
4354 name: name.to_string(),
4355 endpoint,
4356 });
4357 };
4358 let api_key = match secrets {
4359 SecretResolution::Skip => None,
4360 SecretResolution::Resolve => Some(match parse_env_reference(&written) {
4361 Some(variable) => ApiKeyReference::Environment(variable.to_string()),
4362 None => ApiKeyReference::Literal(written),
4363 }),
4364 };
4365
4366 Ok(ArtifactGateway {
4367 endpoint,
4368 api_key,
4369 keyless: false,
4370 })
4371}
4372
4373fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
4400 let shown = mask_endpoint_userinfo(endpoint);
4401 let refuse =
4402 |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
4403 if endpoint.contains("${") {
4404 return Err(refuse(
4405 "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
4406 literally"
4407 .to_string(),
4408 ));
4409 }
4410 let url = Url::parse(endpoint)
4411 .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
4412 let host = match url.scheme() {
4413 "http" | "https" => url
4414 .host_str()
4415 .filter(|host| !host.is_empty())
4416 .ok_or_else(|| refuse("which has no host".to_string()))?,
4417 other => {
4418 return Err(refuse(format!(
4419 "whose scheme '{other}' is unsupported — expected http or https"
4420 )))
4421 }
4422 };
4423 if !url.username().is_empty() || url.password().is_some() {
4424 return Err(refuse(format!(
4425 "which carries userinfo before '@' — the gateway never sends it, and this URL \
4426 addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
4427 the key through gateway.api_key"
4428 )));
4429 }
4430 if url.query().is_some() {
4431 return Err(refuse(
4432 "which carries a query — the gateway keeps each request's own query, so write the \
4433 upstream without one"
4434 .to_string(),
4435 ));
4436 }
4437 if url.fragment().is_some() {
4438 return Err(refuse(
4439 "which carries a fragment — write the upstream without one".to_string(),
4440 ));
4441 }
4442 if url.scheme() == "http" {
4443 let bare = host.trim_start_matches('[').trim_end_matches(']');
4444 let is_loopback = bare == "localhost"
4445 || bare
4446 .parse::<IpAddr>()
4447 .map(|ip| ip.is_loopback())
4448 .unwrap_or(false);
4449 if !is_loopback {
4450 return Err(refuse(format!(
4451 "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
4452 remote upstream, or http://localhost or a loopback IP literal for a local one"
4453 )));
4454 }
4455 }
4456 Ok(())
4457}
4458
4459fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
4463 let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
4464 let rest = &endpoint[authority_start..];
4465 let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
4466 match rest[..authority_len].rfind('@') {
4467 Some(at) => format!(
4468 "{}***{}",
4469 &endpoint[..authority_start],
4470 &endpoint[authority_start + at..]
4471 )
4472 .into(),
4473 None => endpoint.into(),
4474 }
4475}
4476
4477fn validate_driver_gateways(
4484 artifacts: &[RuntimeArtifact],
4485 inference: Option<&InferenceConfig>,
4486) -> Result<(), RuntimeManifestError> {
4487 let configured_driver = inference
4488 .filter(|inference| inference.transport == "http")
4489 .and_then(|inference| inference.driver.as_ref())
4490 .map(|driver| driver.artifact.as_str());
4491 let under_process = inference.is_some_and(|inference| inference.transport == "process");
4492
4493 for (index, artifact) in artifacts.iter().enumerate() {
4494 if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
4495 continue;
4496 }
4497 if under_process {
4498 return Err(RuntimeManifestError::InvalidArtifact {
4499 index,
4500 message: format!(
4501 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
4502 valid with transport: process — the CLI subprocess holds its own \
4503 credentials and a process driver is granted nothing",
4504 artifact.name
4505 ),
4506 });
4507 }
4508 if configured_driver != Some(artifact.name.as_str()) {
4509 return Err(RuntimeManifestError::InvalidArtifact {
4510 index,
4511 message: format!(
4512 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
4513 the configured inference driver; a driver's gateway is accepted only on the \
4514 entry inference.driver.artifact names under transport: http",
4515 artifact.name
4516 ),
4517 });
4518 }
4519 }
4520
4521 if let Some(driver) = configured_driver {
4522 let Some((index, entry)) = artifacts
4523 .iter()
4524 .enumerate()
4525 .find(|(_, artifact)| artifact.name == driver)
4526 else {
4527 return Err(RuntimeManifestError::InvalidInferenceConfig {
4528 field: "inference.driver.artifact".to_string(),
4529 message: format!("artifact '{driver}' is not declared in artifacts:"),
4530 });
4531 };
4532 if entry.gateway.is_none() {
4533 return Err(RuntimeManifestError::InvalidArtifact {
4534 index,
4535 message: format!(
4536 "artifact '{driver}' is the transport: http inference driver but declares no \
4537 gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
4538 on its entry"
4539 ),
4540 });
4541 }
4542 }
4543 Ok(())
4544}
4545
4546fn validate_process_driver_entry(
4554 artifacts: &[RuntimeArtifact],
4555 inference: Option<&InferenceConfig>,
4556) -> Result<(), RuntimeManifestError> {
4557 let Some(driver) = inference
4558 .filter(|inference| inference.transport == "process")
4559 .and_then(|inference| inference.driver.as_ref())
4560 .map(|driver| driver.artifact.as_str())
4561 else {
4562 return Ok(());
4563 };
4564 let Some((index, entry)) = artifacts
4565 .iter()
4566 .enumerate()
4567 .find(|(_, artifact)| artifact.name == driver)
4568 else {
4569 return Err(RuntimeManifestError::InvalidInferenceConfig {
4570 field: "inference.driver.artifact".to_string(),
4571 message: format!("artifact '{driver}' is not declared in artifacts:"),
4572 });
4573 };
4574 if entry.runtime != ArtifactRuntime::Driver {
4575 return Err(RuntimeManifestError::InvalidArtifact {
4576 index,
4577 message: format!(
4578 "artifact '{driver}' is the transport: process driver and must be declared with \
4579 runtime: driver"
4580 ),
4581 });
4582 }
4583 if entry.capabilities.is_some() || entry.config.is_some() {
4584 return Err(RuntimeManifestError::InvalidArtifact {
4585 index,
4586 message: format!(
4587 "artifact '{driver}' is the transport: process driver, which is granted nothing; \
4588 remove capabilities:/config: from its entry — driver settings go in \
4589 inference.driver.config"
4590 ),
4591 });
4592 }
4593 Ok(())
4594}
4595
4596fn parse_inference_driver(
4597 raw: RawInferenceDriver,
4598) -> Result<InferenceDriver, RuntimeManifestError> {
4599 let artifact = raw
4600 .artifact
4601 .map(|value| value.trim().to_string())
4602 .filter(|value| !value.is_empty())
4603 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
4604 field: "inference.driver.artifact".to_string(),
4605 message: "missing required field".to_string(),
4606 })?;
4607
4608 let config = parse_inference_driver_config(raw.config)?;
4609
4610 Ok(InferenceDriver { artifact, config })
4611}
4612
4613fn parse_inference_driver_config(
4614 raw: Option<serde_yaml::Value>,
4615) -> Result<Option<String>, RuntimeManifestError> {
4616 let Some(raw) = raw else {
4617 return Ok(None);
4618 };
4619
4620 if matches!(raw, serde_yaml::Value::Null) {
4621 return Ok(None);
4622 }
4623
4624 if !matches!(raw, serde_yaml::Value::Mapping(_)) {
4625 return Err(RuntimeManifestError::InvalidInferenceConfig {
4626 field: "inference.driver.config".to_string(),
4627 message: "expected mapping/object".to_string(),
4628 });
4629 }
4630
4631 let value =
4632 serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
4633 field: "inference.driver.config".to_string(),
4634 message: format!("failed to convert to JSON: {err}"),
4635 })?;
4636
4637 serde_json::to_string(&value).map(Some).map_err(|err| {
4638 RuntimeManifestError::InvalidInferenceConfig {
4639 field: "inference.driver.config".to_string(),
4640 message: format!("failed to encode JSON: {err}"),
4641 }
4642 })
4643}
4644
4645fn parse_env_reference(value: &str) -> Option<&str> {
4646 if !value.starts_with("${") || !value.ends_with('}') {
4647 return None;
4648 }
4649
4650 let variable = &value[2..value.len() - 1];
4651 is_valid_env_variable(variable).then_some(variable)
4652}
4653
4654fn is_valid_env_variable(value: &str) -> bool {
4655 let mut chars = value.chars();
4656 let Some(first) = chars.next() else {
4657 return false;
4658 };
4659
4660 if !(first == '_' || first.is_ascii_uppercase()) {
4661 return false;
4662 }
4663
4664 chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
4665}
4666
4667#[cfg(test)]
4668mod tests {
4669 use super::*;
4670 use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
4671
4672 fn manifest_with_trace(block: &str) -> String {
4673 format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
4674 }
4675
4676 #[test]
4679 fn absent_trace_block_stays_absent_and_defaults_to_meta() {
4680 let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
4681 assert_eq!(manifest.trace, None);
4682 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
4683 }
4684
4685 #[test]
4686 fn each_capture_mode_parses_from_the_trace_block() {
4687 for (yaml, expected) in [
4688 ("none", TraceCapture::None),
4689 ("meta", TraceCapture::Meta),
4690 ("content", TraceCapture::Content),
4691 ] {
4692 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
4693 " capture: {yaml}\n"
4694 )))
4695 .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
4696 assert_eq!(
4697 manifest.trace,
4698 Some(TraceConfig {
4699 capture: expected,
4700 retain: None
4701 })
4702 );
4703 }
4704 }
4705
4706 #[test]
4712 fn no_retain_block_anywhere_parses_as_no_policy() {
4713 let manifest = RuntimeManifest::from_yaml_str(
4714 "name: cap\nversion: 0.0.1\ntrace:\n capture: content\ncontext:\n record: on\n",
4715 )
4716 .unwrap();
4717 assert_eq!(manifest.trace.unwrap().retain, None);
4718 assert_eq!(manifest.context.unwrap().retain, None);
4719 }
4720
4721 #[test]
4722 fn trace_retain_parses_both_keys_and_the_day_suffix() {
4723 let manifest = RuntimeManifest::from_yaml_str(
4724 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 50\n max_age: 14d\n",
4725 )
4726 .unwrap();
4727 assert_eq!(
4728 manifest.trace.unwrap().retain,
4729 Some(TraceRetainConfig {
4730 max_sessions: Some(50),
4731 max_age_secs: Some(14 * 86_400),
4732 })
4733 );
4734 }
4735
4736 #[test]
4737 fn context_retain_parses_both_keys_and_either_alone() {
4738 let both = RuntimeManifest::from_yaml_str(
4739 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n max_messages: 2000\n max_age: 90d\n",
4740 )
4741 .unwrap();
4742 assert_eq!(
4743 both.context.unwrap().retain,
4744 Some(ContextRetainConfig {
4745 max_messages: Some(2000),
4746 max_age_secs: Some(90 * 86_400),
4747 })
4748 );
4749
4750 let count_only = RuntimeManifest::from_yaml_str(
4751 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 10\n",
4752 )
4753 .unwrap();
4754 assert_eq!(
4755 count_only.context.unwrap().retain,
4756 Some(ContextRetainConfig {
4757 max_messages: Some(10),
4758 max_age_secs: None,
4759 })
4760 );
4761 }
4762
4763 #[test]
4766 fn an_empty_retain_block_is_refused_naming_the_block() {
4767 for (yaml, expect_trace) in [
4768 (
4769 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain: {}\n",
4770 true,
4771 ),
4772 (
4773 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n",
4774 true,
4775 ),
4776 (
4777 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain: {}\n",
4778 false,
4779 ),
4780 (
4781 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n",
4782 false,
4783 ),
4784 ] {
4785 let err = RuntimeManifest::from_yaml_str(yaml)
4786 .expect_err("an empty retain block must be refused");
4787 let (field, message) = match (&err, expect_trace) {
4788 (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
4789 (field, message)
4790 }
4791 (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
4792 (field, message)
4793 }
4794 _ => panic!("wrong variant for {yaml:?}: {err:?}"),
4795 };
4796 assert_eq!(
4797 field,
4798 if expect_trace {
4799 "trace.retain"
4800 } else {
4801 "context.retain"
4802 }
4803 );
4804 assert!(message.contains("at least one key"), "{message}");
4805 }
4806 }
4807
4808 #[test]
4811 fn a_zero_retain_limit_is_refused_naming_the_key() {
4812 let trace = RuntimeManifest::from_yaml_str(
4813 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 0\n",
4814 )
4815 .expect_err("max_sessions: 0 must be refused");
4816 assert!(
4817 matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
4818 if field == "trace.retain.max_sessions"),
4819 "{trace:?}"
4820 );
4821
4822 let context = RuntimeManifest::from_yaml_str(
4823 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 0\n",
4824 )
4825 .expect_err("max_messages: 0 must be refused");
4826 assert!(
4827 matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
4828 if field == "context.retain.max_messages"),
4829 "{context:?}"
4830 );
4831
4832 let age = RuntimeManifest::from_yaml_str(
4833 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_age: 0\n",
4834 )
4835 .expect_err("max_age: 0 must be refused");
4836 assert!(
4837 matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
4838 if field == "trace.retain.max_age"),
4839 "{age:?}"
4840 );
4841 }
4842
4843 #[test]
4844 fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
4845 let err = RuntimeManifest::from_yaml_str(
4846 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_traces: 3\n",
4847 )
4848 .expect_err("an unknown key must be refused");
4849 match err {
4850 RuntimeManifestError::InvalidTraceConfig { field, message } => {
4851 assert_eq!(field, "trace.retain");
4852 assert!(message.contains("max_traces"), "{message}");
4853 assert!(message.contains("max_sessions"), "{message}");
4854 }
4855 other => panic!("expected InvalidTraceConfig, got {other:?}"),
4856 }
4857 }
4858
4859 #[test]
4860 fn an_unparseable_retain_duration_reports_the_accepted_form() {
4861 let err = RuntimeManifest::from_yaml_str(
4862 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_age: 5 weeks\n",
4863 )
4864 .expect_err("'5 weeks' must be refused");
4865 match err {
4866 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
4867 assert_eq!(field, "context.retain.max_age");
4868 assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
4869 }
4870 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
4871 }
4872 }
4873
4874 #[test]
4876 fn include_tool_output_still_resolves_to_content_and_meta() {
4877 let opted_in =
4878 RuntimeManifest::from_yaml_str(&manifest_with_trace(" include_tool_output: true\n"))
4879 .unwrap();
4880 assert_eq!(
4881 opted_in.trace,
4882 Some(TraceConfig {
4883 capture: TraceCapture::Content,
4884 retain: None
4885 })
4886 );
4887
4888 let opted_out =
4889 RuntimeManifest::from_yaml_str(&manifest_with_trace(" include_tool_output: false\n"))
4890 .unwrap();
4891 assert_eq!(
4892 opted_out.trace,
4893 Some(TraceConfig {
4894 capture: TraceCapture::Meta,
4895 retain: None
4896 })
4897 );
4898 }
4899
4900 #[test]
4901 fn setting_both_capture_keys_is_refused_naming_both() {
4902 let err = RuntimeManifest::from_yaml_str(&manifest_with_trace(
4903 " capture: content\n include_tool_output: true\n",
4904 ))
4905 .expect_err("both keys must be refused even when they agree");
4906 assert!(matches!(
4907 err,
4908 RuntimeManifestError::InvalidTraceConfig { .. }
4909 ));
4910 let rendered = err.to_string();
4911 assert!(rendered.contains("trace.capture"), "{rendered}");
4912 assert!(rendered.contains("trace.include_tool_output"), "{rendered}");
4913 }
4914
4915 #[test]
4916 fn unparseable_capture_value_names_the_field_and_accepted_values() {
4917 let err = RuntimeManifest::from_yaml_str(&manifest_with_trace(" capture: verbose\n"))
4918 .expect_err("'verbose' is not a capture mode");
4919 let rendered = err.to_string();
4920 assert!(rendered.contains("trace.capture"), "{rendered}");
4921 for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
4922 assert!(rendered.contains(accepted), "{rendered}");
4923 }
4924 }
4925
4926 #[test]
4927 fn resources_block_is_optional_and_absent_stays_absent() {
4928 let manifest = RuntimeManifest::from_yaml_str(
4929 r#"
4930name: cap
4931version: 0.0.1
4932capabilities:
4933 shell:
4934 allow: [bash]
4935"#,
4936 )
4937 .unwrap();
4938
4939 let caps = manifest.capabilities.expect("shell block must parse");
4943 assert_eq!(caps.resources, None);
4944 }
4945
4946 #[test]
4947 fn resources_fields_parse_and_each_stays_independently_optional() {
4948 let manifest = RuntimeManifest::from_yaml_str(
4949 r#"
4950name: cap
4951version: 0.0.1
4952capabilities:
4953 resources:
4954 max_processes: 32
4955 max_open_files: 16
4956 max_file_size_bytes: 1048576
4957 cpu_seconds: 30
4958 memory_bytes: 268435456
4959 cgroup_memory_bytes: 536870912
4960 cgroup_pids_max: 64
4961 cgroup_cpu_percent: 50
4962 cgroup_io_bytes_per_sec: 1048576
4963 workdir_max_bytes: 2097152
4964"#,
4965 )
4966 .unwrap();
4967
4968 let resources = manifest
4969 .capabilities
4970 .and_then(|caps| caps.resources)
4971 .expect("resources block must parse");
4972 assert_eq!(resources.max_processes, Some(32));
4973 assert_eq!(resources.max_open_files, Some(16));
4974 assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
4975 assert_eq!(resources.cpu_seconds, Some(30));
4976 assert_eq!(resources.memory_bytes, Some(268_435_456));
4977 assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
4978 assert_eq!(resources.cgroup_pids_max, Some(64));
4979 assert_eq!(resources.cgroup_cpu_percent, Some(50));
4980 assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
4981 assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
4982 }
4983
4984 #[test]
4985 fn state_block_is_optional_and_absent_stays_absent() {
4986 let manifest = RuntimeManifest::from_yaml_str(
4987 r#"
4988name: cap
4989version: 0.0.1
4990artifacts:
4991 - name: notes-tool
4992 version: 1.0.0
4993 runtime: tool
4994 capabilities:
4995 filesystem:
4996 scope: cache
4997"#,
4998 )
4999 .unwrap();
5000
5001 let caps = manifest.artifacts[0]
5003 .capabilities
5004 .as_ref()
5005 .expect("filesystem block must parse");
5006 assert_eq!(caps.state, None);
5007 }
5008
5009 #[test]
5013 fn state_block_declared_empty_grants_the_capsule_named_store() {
5014 let manifest = RuntimeManifest::from_yaml_str(
5015 r#"
5016name: cap
5017version: 0.0.1
5018artifacts:
5019 - name: notes-tool
5020 version: 1.0.0
5021 runtime: tool
5022 capabilities:
5023 state: {}
5024"#,
5025 )
5026 .unwrap();
5027
5028 let state = manifest.artifacts[0]
5029 .capabilities
5030 .as_ref()
5031 .and_then(|caps| caps.state.as_ref())
5032 .expect("state block must parse");
5033 assert_eq!(state.store, None);
5034 }
5035
5036 #[test]
5037 fn state_store_name_parses_and_stays_independently_optional() {
5038 let manifest = RuntimeManifest::from_yaml_str(
5039 r#"
5040name: cap
5041version: 0.0.1
5042artifacts:
5043 - name: notes-tool
5044 version: 1.0.0
5045 runtime: tool
5046 capabilities:
5047 state:
5048 store: " shey "
5049 network:
5050 allow: [https://api.example.com]
5051"#,
5052 )
5053 .unwrap();
5054
5055 let caps = manifest.artifacts[0]
5056 .capabilities
5057 .as_ref()
5058 .expect("capabilities must parse");
5059 assert_eq!(
5062 caps.state.as_ref().and_then(|state| state.store.as_deref()),
5063 Some("shey")
5064 );
5065 assert_eq!(
5067 caps.network
5068 .as_ref()
5069 .map(|network| network.allow.as_slice()),
5070 Some(["https://api.example.com".to_string()].as_slice())
5071 );
5072 assert_eq!(caps.filesystem, None);
5073 }
5074
5075 #[test]
5079 fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
5080 let manifest = RuntimeManifest::from_yaml_str(
5081 r#"
5082name: cap
5083version: 0.0.1
5084artifacts:
5085 - name: notes-tool
5086 version: 1.0.0
5087 runtime: tool
5088 capabilities:
5089 state:
5090 store: ""
5091"#,
5092 )
5093 .unwrap();
5094
5095 assert_eq!(
5096 manifest.artifacts[0]
5097 .capabilities
5098 .as_ref()
5099 .and_then(|caps| caps.state.as_ref())
5100 .and_then(|state| state.store.as_deref()),
5101 Some("")
5102 );
5103 }
5104
5105 #[test]
5106 fn resources_partial_block_leaves_undeclared_fields_none() {
5107 let manifest = RuntimeManifest::from_yaml_str(
5108 r#"
5109name: cap
5110version: 0.0.1
5111capabilities:
5112 resources:
5113 max_open_files: 16
5114"#,
5115 )
5116 .unwrap();
5117
5118 let resources = manifest
5119 .capabilities
5120 .and_then(|caps| caps.resources)
5121 .expect("resources block must parse");
5122 assert_eq!(resources.max_open_files, Some(16));
5123 assert_eq!(resources.max_processes, None);
5124 assert_eq!(resources.workdir_max_bytes, None);
5125 }
5126
5127 #[test]
5131 fn resources_zero_is_rejected_on_every_field() {
5132 for field in [
5133 "max_processes",
5134 "max_open_files",
5135 "max_file_size_bytes",
5136 "cpu_seconds",
5137 "memory_bytes",
5138 "cgroup_memory_bytes",
5139 "cgroup_pids_max",
5140 "cgroup_cpu_percent",
5141 "cgroup_io_bytes_per_sec",
5142 "workdir_max_bytes",
5143 ] {
5144 let yaml =
5145 format!("name: cap\nversion: 0.0.1\ncapabilities:\n resources:\n {field}: 0\n");
5146 let error =
5147 RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
5148
5149 match error {
5150 RuntimeManifestError::InvalidCapabilities {
5151 field: reported,
5152 message,
5153 } => {
5154 assert_eq!(reported, format!("capabilities.resources.{field}"));
5155 assert_eq!(message, "must be greater than zero");
5156 }
5157 other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
5158 }
5159 }
5160 }
5161
5162 #[test]
5163 fn artifact_runtime_defaults_to_tool() {
5164 let manifest = RuntimeManifest::from_yaml_str(
5165 r#"
5166name: cap
5167version: 0.0.1
5168artifacts:
5169 - name: echo
5170 version: 1.2.3
5171"#,
5172 )
5173 .unwrap();
5174
5175 assert_eq!(manifest.artifacts.len(), 1);
5176 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
5177 }
5178
5179 #[test]
5180 fn artifact_runtime_tool_explicit() {
5181 let manifest = RuntimeManifest::from_yaml_str(
5182 r#"
5183name: cap
5184version: 0.0.1
5185artifacts:
5186 - name: echo
5187 version: 1.2.3
5188 runtime: tool
5189"#,
5190 )
5191 .unwrap();
5192
5193 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
5194 }
5195
5196 #[test]
5197 fn artifact_runtime_wasm_is_error() {
5198 let err = RuntimeManifest::from_yaml_str(
5199 r#"
5200name: cap
5201version: 0.0.1
5202artifacts:
5203 - name: echo
5204 version: 1.2.3
5205 runtime: wasm
5206"#,
5207 )
5208 .unwrap_err();
5209
5210 let msg = err.to_string();
5211 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
5212 assert!(msg.contains("wasm"), "error was: {msg}");
5213 }
5214
5215 #[test]
5216 fn artifact_runtime_native_is_error() {
5217 let err = RuntimeManifest::from_yaml_str(
5218 r#"
5219name: cap
5220version: 0.0.1
5221artifacts:
5222 - name: echo
5223 version: 1.2.3
5224 runtime: native
5225"#,
5226 )
5227 .unwrap_err();
5228
5229 let msg = err.to_string();
5230 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
5231 assert!(msg.contains("native"), "error was: {msg}");
5232 }
5233
5234 #[test]
5235 fn invalid_artifact_reports_index() {
5236 let err = RuntimeManifest::from_yaml_str(
5237 r#"
5238name: cap
5239version: 0.0.1
5240artifacts:
5241 - name: ""
5242 version: 1.2.3
5243"#,
5244 )
5245 .unwrap_err();
5246
5247 let msg = err.to_string();
5248 assert!(msg.contains("index 0"));
5249 assert!(msg.contains("missing required field 'name'"));
5250 }
5251
5252 #[test]
5253 fn capabilities_parse_when_all_fields_present() {
5254 let manifest = RuntimeManifest::from_yaml_str(
5255 r#"
5256name: cap
5257version: 0.0.1
5258artifacts:
5259 - name: echo
5260 version: 1.2.3
5261capabilities:
5262 network:
5263 allow:
5264 - https://api.anthropic.com
5265 - http://127.0.0.1:8080
5266 filesystem:
5267 scope: ./sandbox
5268"#,
5269 )
5270 .unwrap();
5271
5272 let capabilities = manifest.capabilities.expect("capabilities should exist");
5273 assert_eq!(
5274 capabilities.network.unwrap().allow,
5275 vec![
5276 "https://api.anthropic.com".to_string(),
5277 "http://127.0.0.1:8080".to_string()
5278 ]
5279 );
5280 assert_eq!(
5281 capabilities.filesystem.unwrap().scope,
5282 Some("./sandbox".to_string())
5283 );
5284 }
5285
5286 #[test]
5287 fn env_capabilities_parse_allowlist() {
5288 let manifest = RuntimeManifest::from_yaml_str(
5289 r#"
5290name: cap
5291version: 0.0.1
5292artifacts:
5293 - name: echo
5294 version: 1.2.3
5295capabilities:
5296 env:
5297 allow:
5298 - MY_APP_REGION
5299 - GITHUB_TOKEN
5300"#,
5301 )
5302 .unwrap();
5303
5304 let capabilities = manifest.capabilities.expect("capabilities should exist");
5305 assert_eq!(
5306 capabilities.env.unwrap().allow,
5307 vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
5308 );
5309 }
5310
5311 #[test]
5312 fn env_capabilities_absent_when_block_omitted() {
5313 let manifest = RuntimeManifest::from_yaml_str(
5314 r#"
5315name: cap
5316version: 0.0.1
5317artifacts:
5318 - name: echo
5319 version: 1.2.3
5320capabilities:
5321 network:
5322 allow:
5323 - https://api.anthropic.com
5324"#,
5325 )
5326 .unwrap();
5327
5328 assert!(manifest.capabilities.unwrap().env.is_none());
5329 }
5330
5331 #[test]
5334 fn env_capabilities_allow_empty_list_is_accepted() {
5335 let manifest = RuntimeManifest::from_yaml_str(
5336 r#"
5337name: cap
5338version: 0.0.1
5339artifacts:
5340 - name: echo
5341 version: 1.2.3
5342capabilities:
5343 env: {}
5344"#,
5345 )
5346 .unwrap();
5347
5348 assert_eq!(
5349 manifest.capabilities.unwrap().env,
5350 Some(EnvCapabilities { allow: vec![] })
5351 );
5352 }
5353
5354 #[test]
5355 fn missing_capabilities_block_is_backward_compatible() {
5356 let manifest = RuntimeManifest::from_yaml_str(
5357 r#"
5358name: cap
5359version: 0.0.1
5360artifacts:
5361 - name: echo
5362 version: 1.2.3
5363"#,
5364 )
5365 .unwrap();
5366
5367 assert!(manifest.capabilities.is_none());
5368 }
5369
5370 #[test]
5371 fn network_only_capabilities_parse() {
5372 let manifest = RuntimeManifest::from_yaml_str(
5373 r#"
5374name: cap
5375version: 0.0.1
5376artifacts:
5377 - name: echo
5378 version: 1.2.3
5379capabilities:
5380 network:
5381 allow:
5382 - https://allowed.example.com
5383"#,
5384 )
5385 .unwrap();
5386
5387 let capabilities = manifest.capabilities.unwrap();
5388 assert_eq!(
5389 capabilities.network.unwrap().allow,
5390 vec!["https://allowed.example.com".to_string()]
5391 );
5392 assert!(capabilities.filesystem.is_none());
5393 }
5394
5395 #[test]
5396 fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
5397 let manifest = RuntimeManifest::from_yaml_str(
5398 r#"
5399name: cap
5400version: 0.0.1
5401artifacts:
5402 - name: echo
5403 version: 1.2.3
5404capabilities:
5405 network: {}
5406"#,
5407 )
5408 .unwrap();
5409
5410 let capabilities = manifest.capabilities.unwrap();
5411 assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
5412 }
5413
5414 #[test]
5417 fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
5418 let manifest = RuntimeManifest::from_yaml_str(
5419 r#"
5420name: cap
5421version: 0.0.1
5422artifacts:
5423 - name: echo
5424 version: 1.2.3
5425capabilities:
5426 network:
5427 allow:
5428 - https://api.anthropic.com
5429"#,
5430 )
5431 .unwrap();
5432
5433 let network = manifest.capabilities.unwrap().network.unwrap();
5434 assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
5435 assert!(!network.unix_sockets);
5436 }
5437
5438 #[test]
5439 fn network_unix_sockets_parses_explicit_false() {
5440 let manifest = RuntimeManifest::from_yaml_str(
5441 r#"
5442name: cap
5443version: 0.0.1
5444artifacts:
5445 - name: echo
5446 version: 1.2.3
5447capabilities:
5448 network:
5449 unix_sockets: false
5450"#,
5451 )
5452 .unwrap();
5453
5454 assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
5455 }
5456
5457 #[test]
5458 fn network_unix_sockets_parses_explicit_true() {
5459 let manifest = RuntimeManifest::from_yaml_str(
5460 r#"
5461name: cap
5462version: 0.0.1
5463artifacts:
5464 - name: echo
5465 version: 1.2.3
5466capabilities:
5467 network:
5468 unix_sockets: true
5469"#,
5470 )
5471 .unwrap();
5472
5473 let network = manifest.capabilities.unwrap().network.unwrap();
5474 assert!(network.unix_sockets);
5475 assert_eq!(network.allow, Vec::<String>::new());
5477 }
5478
5479 #[test]
5483 fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
5484 let manifest = RuntimeManifest::from_yaml_str(
5485 r#"
5486name: cap
5487version: 0.0.1
5488capabilities:
5489 filesystem:
5490 read_only:
5491 - tests
5492 - " bench/fixtures "
5493 - ""
5494 - " "
5495"#,
5496 )
5497 .unwrap();
5498
5499 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
5500 assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
5501 }
5502
5503 #[test]
5505 fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
5506 let manifest = RuntimeManifest::from_yaml_str(
5507 r#"
5508name: cap
5509version: 0.0.1
5510capabilities:
5511 filesystem:
5512 scope: workdir
5513"#,
5514 )
5515 .unwrap();
5516
5517 assert!(manifest
5518 .capabilities
5519 .unwrap()
5520 .filesystem
5521 .unwrap()
5522 .read_only
5523 .is_empty());
5524 }
5525
5526 #[test]
5529 fn filesystem_read_only_does_not_judge_path_shape() {
5530 let manifest = RuntimeManifest::from_yaml_str(
5531 r#"
5532name: cap
5533version: 0.0.1
5534capabilities:
5535 filesystem:
5536 read_only:
5537 - /etc
5538 - ../outside
5539"#,
5540 )
5541 .expect("shape is judged by the runtime, not the parser");
5542
5543 assert_eq!(
5544 manifest.capabilities.unwrap().filesystem.unwrap().read_only,
5545 vec!["/etc", "../outside"]
5546 );
5547 }
5548
5549 #[test]
5553 fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
5554 let manifest = RuntimeManifest::from_yaml_str(
5555 r#"
5556name: cap
5557version: 0.0.1
5558artifacts:
5559 - name: echo
5560 version: 1.2.3
5561capabilities:
5562 filesystem:
5563 scope: workdir
5564"#,
5565 )
5566 .unwrap();
5567
5568 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
5569 assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
5570 assert!(!filesystem.workdir_exec);
5571 }
5572
5573 #[test]
5578 fn filesystem_block_stays_absent_when_undeclared() {
5579 let manifest = RuntimeManifest::from_yaml_str(
5580 r#"
5581name: cap
5582version: 0.0.1
5583artifacts:
5584 - name: echo
5585 version: 1.2.3
5586capabilities:
5587 network:
5588 allow:
5589 - https://api.anthropic.com
5590"#,
5591 )
5592 .unwrap();
5593
5594 assert!(manifest.capabilities.unwrap().filesystem.is_none());
5595 }
5596
5597 #[test]
5598 fn filesystem_workdir_exec_parses_explicit_false() {
5599 let manifest = RuntimeManifest::from_yaml_str(
5600 r#"
5601name: cap
5602version: 0.0.1
5603artifacts:
5604 - name: echo
5605 version: 1.2.3
5606capabilities:
5607 filesystem:
5608 workdir_exec: false
5609"#,
5610 )
5611 .unwrap();
5612
5613 assert!(
5614 !manifest
5615 .capabilities
5616 .unwrap()
5617 .filesystem
5618 .unwrap()
5619 .workdir_exec
5620 );
5621 }
5622
5623 #[test]
5624 fn filesystem_workdir_exec_parses_explicit_true() {
5625 let manifest = RuntimeManifest::from_yaml_str(
5626 r#"
5627name: cap
5628version: 0.0.1
5629artifacts:
5630 - name: echo
5631 version: 1.2.3
5632capabilities:
5633 filesystem:
5634 workdir_exec: true
5635"#,
5636 )
5637 .unwrap();
5638
5639 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
5640 assert!(filesystem.workdir_exec);
5641 assert_eq!(filesystem.scope, None);
5644 }
5645
5646 #[test]
5647 fn network_internal_port_parses() {
5648 let manifest = RuntimeManifest::from_yaml_str(
5649 r#"
5650name: worker
5651version: 0.1.0
5652artifacts: []
5653
5654network:
5655 internal_port: 58172
5656"#,
5657 )
5658 .unwrap();
5659
5660 assert_eq!(
5661 manifest.network.unwrap().internal_port,
5662 Some(58172),
5663 "network.internal_port should parse to Some(58172)"
5664 );
5665 }
5666
5667 #[test]
5668 fn network_internal_port_absent_when_omitted() {
5669 let manifest = RuntimeManifest::from_yaml_str(
5670 r#"
5671name: worker
5672version: 0.1.0
5673artifacts: []
5674"#,
5675 )
5676 .unwrap();
5677
5678 assert!(manifest.network.is_none());
5679 }
5680
5681 #[test]
5682 fn shell_capabilities_parse_with_allow_and_env_fields() {
5683 let manifest = RuntimeManifest::from_yaml_str(
5684 r#"
5685name: cap
5686version: 0.0.1
5687artifacts: []
5688capabilities:
5689 shell:
5690 allow:
5691 - bash
5692 - git
5693 strip_env:
5694 - "*_TOKEN"
5695 - SECRET_KEY
5696 baseline_env:
5697 - SSH_AUTH_SOCK
5698"#,
5699 )
5700 .unwrap();
5701
5702 let shell = manifest
5703 .capabilities
5704 .unwrap()
5705 .shell
5706 .expect("shell capabilities should be present");
5707 assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
5708 assert_eq!(
5709 shell.strip_env,
5710 Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
5711 );
5712 assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
5713 }
5714
5715 #[test]
5716 fn shell_capabilities_require_non_empty_allowlist() {
5717 let err = RuntimeManifest::from_yaml_str(
5718 r#"
5719name: cap
5720version: 0.0.1
5721artifacts: []
5722capabilities:
5723 shell:
5724 allow: []
5725"#,
5726 )
5727 .unwrap_err();
5728
5729 let msg = err.to_string();
5730 assert!(msg.contains("capabilities.shell.allow"));
5731 assert!(msg.contains("at least one"));
5732 }
5733
5734 #[test]
5735 fn interpreter_runtime_parses_accepted_shape() {
5736 let manifest = RuntimeManifest::from_yaml_str(
5737 r#"
5738name: cap
5739version: 0.0.1
5740artifacts: []
5741capabilities:
5742 shell:
5743 allow:
5744 - python3
5745 interpreter_runtime:
5746 - binary: python3
5747 dirs:
5748 - path: /usr/lib/python3.11
5749 list_dir: true
5750 - path: /usr/lib/python3.11/lib-dynload
5751 list_dir: false
5752"#,
5753 )
5754 .unwrap();
5755
5756 let shell = manifest.capabilities.unwrap().shell.unwrap();
5757 assert_eq!(
5758 shell.interpreter_runtime,
5759 vec![InterpreterRuntimeGrant {
5760 binary: "python3".to_string(),
5761 dirs: vec![
5762 InterpreterRuntimeDir {
5763 path: "/usr/lib/python3.11".to_string(),
5764 list_dir: true,
5765 },
5766 InterpreterRuntimeDir {
5767 path: "/usr/lib/python3.11/lib-dynload".to_string(),
5768 list_dir: false,
5769 },
5770 ],
5771 }]
5772 );
5773 }
5774
5775 #[test]
5776 fn interpreter_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.interpreter_runtime.is_empty());
5792 }
5793
5794 fn interpreter_runtime_reject(grant_yaml: &str) -> String {
5797 let yaml = format!(
5798 r#"
5799name: cap
5800version: 0.0.1
5801artifacts: []
5802capabilities:
5803 shell:
5804 allow:
5805 - python3
5806 interpreter_runtime:
5807{grant_yaml}
5808"#,
5809 );
5810 RuntimeManifest::from_yaml_str(&yaml)
5811 .expect_err("grant should be rejected")
5812 .to_string()
5813 }
5814
5815 #[test]
5816 fn interpreter_runtime_rejects_binary_not_in_allow() {
5817 let msg = interpreter_runtime_reject(
5818 " - binary: ruby\n dirs:\n - path: /usr/lib/ruby\n list_dir: true\n",
5819 );
5820 assert!(
5821 msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
5822 "{msg}"
5823 );
5824 assert!(msg.contains("ruby"), "{msg}");
5825 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
5826 }
5827
5828 #[test]
5829 fn interpreter_runtime_rejects_relative_path() {
5830 let msg = interpreter_runtime_reject(
5831 " - binary: python3\n dirs:\n - path: usr/lib/python3.11\n list_dir: true\n",
5832 );
5833 assert!(
5834 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
5835 "{msg}"
5836 );
5837 assert!(msg.contains("absolute"), "{msg}");
5838 }
5839
5840 fn manifest_with_plan(block: &str) -> String {
5844 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n{block}")
5845 }
5846
5847 #[test]
5848 fn plan_submit_true_grants_the_capsule() {
5849 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n"))
5850 .expect("a plan block parses");
5851 assert_eq!(
5852 manifest.capabilities.unwrap().plan,
5853 Some(PlanCapabilities { submit: true })
5854 );
5855 }
5856
5857 #[test]
5860 fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
5861 let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: false\n"))
5862 .expect("an explicit denial parses");
5863 assert_eq!(
5864 explicit.capabilities.unwrap().plan,
5865 Some(PlanCapabilities { submit: false })
5866 );
5867
5868 let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
5869 .expect("no capabilities block parses");
5870 assert!(absent.capabilities.is_none());
5871 }
5872
5873 #[test]
5876 fn plan_without_submit_is_rejected() {
5877 let err = RuntimeManifest::from_yaml_str(&manifest_with_plan(" {}\n"))
5878 .expect_err("an empty plan block is refused");
5879 let msg = err.to_string();
5880 assert!(msg.contains("capabilities.plan.submit"), "{msg}");
5881 assert!(msg.contains("never"), "{msg}");
5882 }
5883
5884 #[test]
5886 fn an_unknown_plan_key_is_reported_by_path() {
5887 let manifest =
5888 RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n depth: 3\n"))
5889 .expect("an unknown key is a warning, not a parse error");
5890 assert!(
5891 manifest
5892 .unknown_keys
5893 .iter()
5894 .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
5895 "{:?}",
5896 manifest.unknown_keys
5897 );
5898 }
5899
5900 fn manifest_with_task_io(runtime: &str, block: &str) -> String {
5904 format!(
5905 "name: cap\nversion: 0.0.1\nartifacts:\n - name: gate\n version: 1.2.3\n \
5906 runtime: {runtime}\n capabilities:\n task_io:\n{block}"
5907 )
5908 }
5909
5910 #[test]
5911 fn task_io_read_true_grants_the_hook() {
5912 let manifest =
5913 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: true\n"))
5914 .expect("task_io on a hook entry parses");
5915 assert_eq!(
5916 manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
5917 Some(TaskIoCapabilities { read: true })
5918 );
5919 }
5920
5921 #[test]
5924 fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
5925 let explicit =
5926 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: false\n"))
5927 .expect("an explicit denial parses");
5928 assert_eq!(
5929 explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
5930 Some(TaskIoCapabilities { read: false })
5931 );
5932
5933 let absent = RuntimeManifest::from_yaml_str(
5934 "name: cap\nversion: 0.0.1\nartifacts:\n - name: gate\n version: 1.2.3\n \
5935 runtime: hook\n",
5936 )
5937 .expect("no capabilities block parses");
5938 assert!(absent.artifacts[0].capabilities.is_none());
5939 }
5940
5941 #[test]
5944 fn task_io_without_read_is_rejected() {
5945 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " {}\n"))
5946 .unwrap_err();
5947 let msg = err.to_string();
5948 assert!(
5949 msg.contains("capabilities.task_io.read"),
5950 "error was: {msg}"
5951 );
5952 assert!(msg.contains("explicitly"), "error was: {msg}");
5953 }
5954
5955 #[test]
5958 fn task_io_outside_a_hook_entry_is_rejected() {
5959 for runtime in ["tool", "driver", "skill"] {
5960 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
5961 runtime,
5962 " read: true\n",
5963 ))
5964 .unwrap_err();
5965 let msg = err.to_string();
5966 assert!(
5967 msg.contains("capabilities") && msg.contains("runtime: hook"),
5968 "{runtime} entry: error was: {msg}"
5969 );
5970 }
5971 }
5972
5973 #[test]
5976 fn task_io_in_the_capsule_wide_block_is_rejected() {
5977 let err = RuntimeManifest::from_yaml_str(
5978 "name: cap\nversion: 0.0.1\ncapabilities:\n task_io:\n read: true\n",
5979 )
5980 .unwrap_err();
5981 let msg = err.to_string();
5982 assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
5983 assert!(msg.contains("runtime: hook"), "error was: {msg}");
5984 }
5985
5986 fn manifest_with_conversation(runtime: &str, block: &str) -> String {
5990 format!(
5991 "name: cap\nversion: 0.0.1\nartifacts:\n - name: memory\n version: 1.2.3\n \
5992 runtime: {runtime}\n capabilities:\n conversation:\n{block}"
5993 )
5994 }
5995
5996 #[test]
5997 fn conversation_read_lowers_onto_the_hook_entry() {
5998 for (declared, expected) in [("true", true), ("false", false)] {
5999 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
6000 "hook",
6001 &format!(" read: {declared}\n"),
6002 ))
6003 .expect("conversation on a hook entry parses");
6004 assert_eq!(
6005 manifest.artifacts[0]
6006 .capabilities
6007 .as_ref()
6008 .unwrap()
6009 .conversation,
6010 Some(ConversationCapabilities { read: expected })
6011 );
6012 }
6013 }
6014
6015 #[test]
6018 fn conversation_without_read_is_rejected() {
6019 let err =
6020 RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", " {}\n"))
6021 .unwrap_err();
6022 let msg = err.to_string();
6023 assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
6024 assert!(msg.contains("explicitly"), "was: {msg}");
6025 }
6026
6027 #[test]
6030 fn conversation_outside_a_hook_entry_is_rejected() {
6031 for runtime in ["tool", "driver", "skill"] {
6032 let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
6033 runtime,
6034 " read: true\n",
6035 ))
6036 .unwrap_err();
6037 let msg = err.to_string();
6038 assert!(
6039 msg.contains("capabilities") && msg.contains("runtime: hook"),
6040 "{runtime} entry: error was: {msg}"
6041 );
6042 }
6043 }
6044
6045 #[test]
6048 fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
6049 let manifest = RuntimeManifest::from_yaml_str(
6050 "name: cap\nversion: 0.0.1\ncapabilities:\n conversation:\n read: true\n",
6051 )
6052 .expect("a capsule-wide block is accepted");
6053 assert_eq!(
6054 manifest.capabilities.unwrap().conversation,
6055 Some(ConversationCapabilities { read: true })
6056 );
6057 }
6058
6059 #[test]
6064 fn a_context_block_records_by_default() {
6065 let context = context_of("context:\n max_tokens: 1000\n").unwrap();
6066 assert!(context.record);
6067 assert_eq!(context.record_store, None);
6068 }
6069
6070 #[test]
6071 fn record_off_and_on_are_the_two_accepted_values() {
6072 assert!(!context_of("context:\n record: off\n").unwrap().record);
6073 assert!(context_of("context:\n record: on\n").unwrap().record);
6074
6075 let err = RuntimeManifest::from_yaml_str(
6076 "name: cap\nversion: 0.0.1\ncontext:\n record: sometimes\n",
6077 )
6078 .unwrap_err();
6079 let msg = err.to_string();
6080 assert!(msg.contains("context.record"), "was: {msg}");
6081 assert!(msg.contains("sometimes"), "was: {msg}");
6082 }
6083
6084 #[test]
6087 fn record_store_is_kept_verbatim_and_trimmed() {
6088 assert_eq!(
6089 context_of("context:\n record_store: ' shey '\n")
6090 .unwrap()
6091 .record_store,
6092 Some("shey".to_string())
6093 );
6094 assert_eq!(
6095 context_of("context:\n record: off\n record_store: shey\n")
6096 .unwrap()
6097 .record_store,
6098 Some("shey".to_string()),
6099 "a store declared beside record: off is kept and simply never used"
6100 );
6101 }
6102
6103 #[test]
6104 fn interpreter_runtime_rejects_missing_list_dir() {
6105 let msg = interpreter_runtime_reject(
6106 " - binary: python3\n dirs:\n - path: /usr/lib/python3.11\n",
6107 );
6108 assert!(
6109 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
6110 "{msg}"
6111 );
6112 assert!(msg.contains("explicitly"), "{msg}");
6113 }
6114
6115 #[test]
6116 fn interpreter_runtime_rejects_empty_dirs() {
6117 let msg = interpreter_runtime_reject(" - binary: python3\n dirs: []\n");
6118 assert!(
6119 msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
6120 "{msg}"
6121 );
6122 assert!(msg.contains("at least one"), "{msg}");
6123 }
6124
6125 #[test]
6126 fn staged_runtime_parses_accepted_shape() {
6127 let manifest = RuntimeManifest::from_yaml_str(
6128 r#"
6129name: cap
6130version: 0.0.1
6131artifacts: []
6132capabilities:
6133 containment: sealed
6134 shell:
6135 allow:
6136 - python3
6137 - bash
6138 staged_runtime:
6139 - binary: python3
6140 source_path: /opt/testbed/conda/envs/django__django
6141 pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
6142"#,
6143 )
6144 .unwrap();
6145
6146 let shell = manifest.capabilities.unwrap().shell.unwrap();
6147 assert_eq!(
6148 shell.staged_runtime,
6149 vec![StagedRuntimeGrant {
6150 binary: "python3".to_string(),
6151 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
6152 pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
6153 }]
6154 );
6155 assert!(shell.interpreter_runtime.is_empty());
6158 }
6159
6160 #[test]
6161 fn staged_runtime_defaults_to_empty_when_absent() {
6162 let manifest = RuntimeManifest::from_yaml_str(
6163 r#"
6164name: cap
6165version: 0.0.1
6166artifacts: []
6167capabilities:
6168 shell:
6169 allow:
6170 - python3
6171"#,
6172 )
6173 .unwrap();
6174
6175 let shell = manifest.capabilities.unwrap().shell.unwrap();
6176 assert!(shell.staged_runtime.is_empty());
6177 }
6178
6179 fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
6183 let yaml = format!(
6184 r#"
6185name: cap
6186version: 0.0.1
6187artifacts: []
6188capabilities:
6189 shell:
6190 allow:
6191 - python3
6192{extra} staged_runtime:
6193{grant_yaml}
6194"#,
6195 );
6196 RuntimeManifest::from_yaml_str(&yaml)
6197 .expect_err("grant should be rejected")
6198 .to_string()
6199 }
6200
6201 #[test]
6202 fn staged_runtime_rejects_binary_not_in_allow() {
6203 let msg = staged_runtime_reject(
6204 " - binary: ruby\n source_path: /opt/ruby\n pin: ruby-3.2.2\n",
6205 "",
6206 );
6207 assert!(
6208 msg.contains("capabilities.shell.staged_runtime[0].binary"),
6209 "{msg}"
6210 );
6211 assert!(msg.contains("ruby"), "{msg}");
6212 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
6213 }
6214
6215 #[test]
6216 fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
6217 let msg = staged_runtime_reject(
6218 " - binary: python3\n source_path: /opt/py\n pin: cpython-3.9.19\n",
6219 " interpreter_runtime:\n - binary: python3\n dirs:\n - path: /usr/lib/python3.9\n list_dir: true\n",
6220 );
6221 assert!(
6222 msg.contains("capabilities.shell.staged_runtime[0].binary"),
6223 "{msg}"
6224 );
6225 assert!(msg.contains("python3"), "{msg}");
6226 assert!(msg.contains("interpreter_runtime"), "{msg}");
6227 assert!(msg.contains("mutually exclusive"), "{msg}");
6228 }
6229
6230 #[test]
6231 fn staged_runtime_rejects_relative_source_path() {
6232 let msg = staged_runtime_reject(
6233 " - binary: python3\n source_path: opt/testbed/conda\n pin: cpython-3.9.19\n",
6234 "",
6235 );
6236 assert!(
6237 msg.contains("capabilities.shell.staged_runtime[0].source_path"),
6238 "{msg}"
6239 );
6240 assert!(msg.contains("absolute"), "{msg}");
6241 }
6242
6243 #[test]
6244 fn staged_runtime_rejects_missing_or_empty_pin() {
6245 for grant in [
6248 " - binary: python3\n source_path: /opt/testbed/conda\n",
6249 " - binary: python3\n source_path: /opt/testbed/conda\n pin: \" \"\n",
6250 ] {
6251 let msg = staged_runtime_reject(grant, "");
6252 assert!(
6253 msg.contains("capabilities.shell.staged_runtime[0].pin"),
6254 "{msg}"
6255 );
6256 assert!(msg.contains("never inferred"), "{msg}");
6257 }
6258 }
6259
6260 #[test]
6261 fn parses_inference_block() {
6262 let manifest = RuntimeManifest::from_yaml_str(
6263 r#"
6264name: cap
6265version: 0.0.1
6266artifacts:
6267 - name: murmur-driver-anthropic
6268 version: 0.1.0
6269 runtime: driver
6270 gateway:
6271 endpoint: http://127.0.0.1:8080
6272 api_key: literal-key
6273inference:
6274 transport: http
6275 model: test-model
6276 driver:
6277 artifact: murmur-driver-anthropic
6278 config:
6279 temperature: 0.2
6280 headers:
6281 x-test: true
6282"#,
6283 )
6284 .unwrap();
6285
6286 assert_eq!(
6287 manifest.artifacts[0].gateway,
6288 Some(ArtifactGateway {
6289 endpoint: "http://127.0.0.1:8080".to_string(),
6290 api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
6291 keyless: false,
6292 })
6293 );
6294 let inference = manifest.inference.expect("inference should exist");
6295 assert_eq!(inference.transport, "http");
6296 assert_eq!(inference.model, "test-model");
6297 let driver = inference.driver.as_ref().expect("driver should be present");
6298 assert_eq!(driver.artifact, "murmur-driver-anthropic");
6299 assert_eq!(
6300 driver.config,
6301 Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
6302 );
6303 assert!(inference.system_prompt.is_none());
6304 assert!(inference.system_prompt_file.is_none());
6305 }
6306
6307 fn tool_gateway_manifest(block: &str) -> String {
6310 format!(
6311 "name: cap\nversion: 0.0.1\nartifacts:\n - name: web-search\n version: 0.1.0\n runtime: tool\n gateway:{block}\n"
6312 )
6313 }
6314
6315 fn gateway_endpoint_error(endpoint: &str) -> String {
6316 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
6317 "\n endpoint: \"{endpoint}\""
6318 )))
6319 .unwrap_err()
6320 .to_string()
6321 }
6322
6323 #[test]
6324 fn gateway_endpoint_https_is_accepted() {
6325 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
6326 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
6327 ))
6328 .unwrap();
6329 assert_eq!(
6330 manifest.artifacts[0].gateway,
6331 Some(ArtifactGateway {
6332 endpoint: "https://api.tavily.com".to_string(),
6333 api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
6334 keyless: false,
6335 })
6336 );
6337 }
6338
6339 #[test]
6340 fn gateway_endpoint_http_localhost_is_accepted() {
6341 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
6342 "\n endpoint: http://localhost:11434\n keyless: true",
6343 ))
6344 .unwrap();
6345 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
6346 assert_eq!(gateway.endpoint, "http://localhost:11434");
6347 assert_eq!(gateway.api_key, None);
6348 assert!(gateway.keyless);
6349 }
6350
6351 #[test]
6352 fn gateway_endpoint_http_loopback_ip_is_accepted() {
6353 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
6354 "\n endpoint: http://127.0.0.1:8080\n api_key: ${LOCAL_KEY}",
6355 ))
6356 .unwrap();
6357 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
6358 assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
6359 assert_eq!(
6360 gateway.api_key,
6361 Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
6362 );
6363 }
6364
6365 #[test]
6366 fn gateway_endpoint_without_scheme_is_refused() {
6367 let msg = gateway_endpoint_error("api.tavily.com");
6368 assert!(msg.contains("gateway.endpoint"), "{msg}");
6369 assert!(msg.contains("artifact 'web-search'"), "{msg}");
6370 assert!(msg.contains("does not parse as a URL"), "{msg}");
6371 }
6372
6373 #[test]
6374 fn gateway_endpoint_ftp_scheme_is_refused() {
6375 let msg = gateway_endpoint_error("ftp://example.com");
6376 assert!(msg.contains("gateway.endpoint"), "{msg}");
6377 assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
6378 }
6379
6380 #[test]
6381 fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
6382 let msg = gateway_endpoint_error("http://api.attacker.example.com");
6383 assert!(msg.contains("gateway.endpoint"), "{msg}");
6384 assert!(msg.contains("api.attacker.example.com"), "{msg}");
6385 assert!(msg.contains("non-loopback"), "{msg}");
6386 }
6387
6388 #[test]
6389 fn gateway_endpoint_with_a_query_is_refused() {
6390 let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
6391 assert!(msg.contains("gateway.endpoint"), "{msg}");
6392 assert!(msg.contains("carries a query"), "{msg}");
6393 }
6394
6395 #[test]
6396 fn gateway_endpoint_with_a_fragment_is_refused() {
6397 let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
6398 assert!(msg.contains("gateway.endpoint"), "{msg}");
6399 assert!(msg.contains("carries a fragment"), "{msg}");
6400 }
6401
6402 #[test]
6403 fn gateway_endpoint_with_userinfo_is_refused() {
6404 let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
6405 assert!(msg.contains("artifact 'web-search'"), "{msg}");
6406 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
6407 assert!(msg.contains("host 'api.example.com'"), "{msg}");
6408 assert!(msg.contains("gateway.api_key"), "{msg}");
6409 assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
6410 assert!(
6411 !msg.contains("sk-in-url"),
6412 "a password in the URL is never echoed: {msg}"
6413 );
6414 }
6415
6416 #[test]
6417 fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
6418 let msg = gateway_endpoint_error("https://user:sk-in-url@");
6419 assert!(msg.contains("does not parse as a URL"), "{msg}");
6420 assert!(
6421 !msg.contains("sk-in-url"),
6422 "a password in the URL is never echoed: {msg}"
6423 );
6424 }
6425
6426 #[test]
6427 fn gateway_endpoint_with_a_masquerading_host_is_refused() {
6428 let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
6429 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
6430 assert!(
6431 msg.contains("addresses host '127.0.0.1'"),
6432 "the message names the host the URL really addresses: {msg}"
6433 );
6434 }
6435
6436 #[test]
6437 fn gateway_endpoint_with_interpolation_is_refused() {
6438 let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
6439 assert!(msg.contains("artifact 'web-search'"), "{msg}");
6440 assert!(msg.contains("contains '${'"), "{msg}");
6441 assert!(msg.contains("not interpolated"), "{msg}");
6442 }
6443
6444 #[test]
6445 fn gateway_endpoint_http_unspecified_address_is_refused() {
6446 let msg = gateway_endpoint_error("http://0.0.0.0:8080");
6447 assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
6448 }
6449
6450 #[test]
6451 fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
6452 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
6453 "\n endpoint: http://[::1]:8080\n api_key: ${LOCAL_KEY}",
6454 ))
6455 .unwrap();
6456 assert_eq!(
6457 manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
6458 "http://[::1]:8080"
6459 );
6460 }
6461
6462 fn gateway_credential_error(block: &str) -> RuntimeManifestError {
6464 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
6465 }
6466
6467 fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
6468 match &error {
6469 RuntimeManifestError::GatewayWithoutCredential {
6470 index,
6471 name,
6472 endpoint: refused,
6473 } => {
6474 assert_eq!(*index, 0);
6475 assert_eq!(name, "web-search");
6476 assert_eq!(refused, endpoint);
6477 }
6478 other => panic!("expected GatewayWithoutCredential, got {other:?}"),
6479 }
6480 let msg = error.to_string();
6481 assert!(
6482 msg.contains(&format!(
6483 "artifact 'web-search' declares gateway.endpoint '{endpoint}' but binds no \
6484 credential"
6485 )),
6486 "{msg}"
6487 );
6488 }
6489
6490 #[test]
6491 fn gateway_credential_absent_is_refused() {
6492 assert_without_credential(
6493 gateway_credential_error("\n endpoint: https://api.tavily.com"),
6494 "https://api.tavily.com",
6495 );
6496 }
6497
6498 #[test]
6499 fn gateway_credential_blank_is_refused() {
6500 for key in ["\"\"", "\" \"", "''"] {
6501 assert_without_credential(
6502 gateway_credential_error(&format!(
6503 "\n endpoint: https://api.tavily.com\n api_key: {key}"
6504 )),
6505 "https://api.tavily.com",
6506 );
6507 }
6508 }
6509
6510 #[test]
6511 fn gateway_credential_null_is_refused() {
6512 for key in ["~", "null", ""] {
6513 assert_without_credential(
6514 gateway_credential_error(&format!(
6515 "\n endpoint: https://api.tavily.com\n api_key: {key}"
6516 )),
6517 "https://api.tavily.com",
6518 );
6519 }
6520 }
6521
6522 #[test]
6523 fn gateway_credential_keyless_false_is_refused() {
6524 assert_without_credential(
6525 gateway_credential_error(
6526 "\n endpoint: https://api.tavily.com\n keyless: false",
6527 ),
6528 "https://api.tavily.com",
6529 );
6530 }
6531
6532 #[test]
6533 fn gateway_credential_keyless_is_accepted() {
6534 for (parse, mode) in [
6535 (
6536 RuntimeManifest::from_yaml_str as fn(&str) -> _,
6537 "with secrets",
6538 ),
6539 (
6540 RuntimeManifest::from_yaml_str_without_secrets,
6541 "without secrets",
6542 ),
6543 ] {
6544 let manifest = parse(&tool_gateway_manifest(
6545 "\n endpoint: https://api.example.com\n keyless: true",
6546 ))
6547 .unwrap();
6548 assert_eq!(
6549 manifest.artifacts[0].gateway,
6550 Some(ArtifactGateway {
6551 endpoint: "https://api.example.com".to_string(),
6552 api_key: None,
6553 keyless: true,
6554 }),
6555 "{mode}"
6556 );
6557 }
6558 }
6559
6560 #[test]
6561 fn gateway_credential_keyless_not_a_boolean_is_refused() {
6562 for value in ["\"true\"", "yes please", "1"] {
6563 let error = gateway_credential_error(&format!(
6564 "\n endpoint: https://api.tavily.com\n keyless: {value}"
6565 ));
6566 assert!(
6567 matches!(
6568 error,
6569 RuntimeManifestError::InvalidArtifact { index: 0, .. }
6570 ),
6571 "{value}: {error:?}"
6572 );
6573 assert!(
6574 error
6575 .to_string()
6576 .contains("gateway.keyless with a value that is not a boolean"),
6577 "{error}"
6578 );
6579 }
6580 }
6581
6582 #[test]
6583 fn gateway_credential_and_keyless_together_are_refused() {
6584 for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
6585 let error = gateway_credential_error(&format!(
6586 "\n endpoint: https://api.tavily.com\n api_key: {key}\n keyless: true"
6587 ));
6588 assert!(
6589 matches!(
6590 error,
6591 RuntimeManifestError::InvalidArtifact { index: 0, .. }
6592 ),
6593 "{error:?}"
6594 );
6595 assert!(
6596 error.to_string().contains(
6597 "artifact 'web-search' declares both gateway.api_key and gateway.keyless: \
6598 true; a gateway either presents a key or is keyless"
6599 ),
6600 "{error}"
6601 );
6602 }
6603 }
6604
6605 #[test]
6606 fn gateway_credential_absent_is_refused_without_secrets() {
6607 for block in [
6608 "\n endpoint: https://api.tavily.com",
6609 "\n endpoint: https://api.tavily.com\n api_key: \"\"",
6610 "\n endpoint: https://api.tavily.com\n keyless: false",
6611 ] {
6612 assert_without_credential(
6613 RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
6614 .unwrap_err(),
6615 "https://api.tavily.com",
6616 );
6617 }
6618 let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
6619 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
6620 ))
6621 .unwrap();
6622 let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
6623 assert_eq!(gateway.api_key, None, "the key reference is left unread");
6624 assert!(!gateway.keyless, "a keyed gateway is never keyless");
6625 }
6626
6627 #[test]
6630 fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
6631 for endpoint in [
6632 "http://127.0.0.1:11434",
6633 "http://localhost:11434",
6634 "http://[::1]:11434",
6635 "https://10.0.0.5",
6636 "https://192.168.1.10:8443",
6637 ] {
6638 assert_without_credential(
6639 gateway_credential_error(&format!("\n endpoint: {endpoint}")),
6640 endpoint,
6641 );
6642 }
6643 }
6644
6645 #[test]
6646 fn gateway_endpoint_missing_from_the_block_is_refused() {
6647 for block in [
6648 "\n api_key: ${TAVILY_API_KEY}",
6649 "",
6650 " {}",
6651 "\n endpoint: \" \"",
6652 ] {
6653 let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
6654 .unwrap_err()
6655 .to_string();
6656 assert!(
6657 msg.contains("without 'gateway.endpoint'"),
6658 "{block:?}: {msg}"
6659 );
6660 assert!(msg.contains("artifact 'web-search'"), "{block:?}: {msg}");
6661 }
6662 }
6663
6664 #[test]
6665 fn gateway_block_unknown_key_is_reported() {
6666 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
6667 "\n endpoint: https://api.tavily.com\n keyless: true\n api_kee: x",
6668 ))
6669 .unwrap();
6670 let unknown = manifest.unknown_keys;
6671 assert_eq!(unknown.len(), 1, "{unknown:?}");
6672 assert_eq!(unknown[0].key, "api_kee");
6673 assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
6674 }
6675
6676 #[test]
6677 fn gateway_on_skill_entry_is_refused() {
6678 let msg = RuntimeManifest::from_yaml_str(
6679 "name: cap\nversion: 0.0.1\nartifacts:\n - name: notes\n version: 0.1.0\n runtime: skill\n gateway:\n endpoint: https://api.tavily.com\n api_key: test-key\n",
6680 )
6681 .unwrap_err()
6682 .to_string();
6683 assert!(
6684 msg.contains("artifact 'notes' declares 'gateway:'"),
6685 "{msg}"
6686 );
6687 assert!(msg.contains("runtime: skill"), "{msg}");
6688 }
6689
6690 #[test]
6691 fn gateway_on_hook_entry_is_accepted() {
6692 let manifest = RuntimeManifest::from_yaml_str(
6693 "name: cap\nversion: 0.0.1\nartifacts:\n - name: grafana\n version: 0.1.0\n runtime: hook\n gateway:\n endpoint: https://grafana.example.com/otlp\n api_key: glc_literal\n",
6694 )
6695 .unwrap();
6696 assert!(manifest.artifacts[0].gateway.is_some());
6697 }
6698
6699 fn http_driver_manifest(artifacts: &str) -> String {
6702 format!(
6703 "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur-driver-anthropic\n"
6704 )
6705 }
6706
6707 #[test]
6708 fn http_driver_entry_without_gateway_endpoint_is_refused() {
6709 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
6710 "\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver",
6711 ))
6712 .unwrap_err()
6713 .to_string();
6714 assert!(msg.contains("'murmur-driver-anthropic'"), "{msg}");
6715 assert!(msg.contains("gateway.endpoint"), "{msg}");
6716 }
6717
6718 #[test]
6719 fn http_driver_absent_from_artifacts_is_refused() {
6720 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
6721 .unwrap_err()
6722 .to_string();
6723 assert!(msg.contains("inference.driver.artifact"), "{msg}");
6724 assert!(msg.contains("not declared in artifacts:"), "{msg}");
6725 }
6726
6727 #[test]
6728 fn gateway_on_unconfigured_driver_entry_is_refused() {
6729 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
6730 "\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.anthropic.com\n api_key: test-key\n - name: murmur-driver-openai\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.openai.com\n api_key: test-key",
6731 ))
6732 .unwrap_err()
6733 .to_string();
6734 assert!(msg.contains("'murmur-driver-openai'"), "{msg}");
6735 assert!(
6736 msg.contains("accepted only on the entry inference.driver.artifact names"),
6737 "{msg}"
6738 );
6739
6740 let no_inference = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur-driver-openai\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.openai.com\n api_key: test-key\n";
6741 let msg = RuntimeManifest::from_yaml_str(no_inference)
6742 .unwrap_err()
6743 .to_string();
6744 assert!(msg.contains("not the configured inference driver"), "{msg}");
6745 }
6746
6747 #[test]
6748 fn gateway_on_driver_entry_under_process_transport_is_refused() {
6749 let msg = RuntimeManifest::from_yaml_str(
6750 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.anthropic.com\n api_key: test-key\ninference:\n transport: process\n driver:\n artifact: murmur-driver-anthropic\n",
6751 )
6752 .unwrap_err()
6753 .to_string();
6754 assert!(msg.contains("'murmur-driver-anthropic'"), "{msg}");
6755 assert!(msg.contains("transport: process"), "{msg}");
6756 }
6757
6758 #[test]
6759 fn inference_api_key_is_refused_naming_gateway_api_key() {
6760 let entry = "\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.anthropic.com\n api_key: test-key";
6761 for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
6762 let manifest = http_driver_manifest(entry)
6763 .replace(" model:", &format!(" api_key: {value}\n model:"));
6764 let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
6765 let msg = err.to_string();
6766 assert!(msg.contains("'inference.api_key'"), "{msg}");
6767 assert!(msg.contains("was removed"), "{msg}");
6768 assert!(
6769 msg.contains(
6770 "set gateway.api_key on the artifacts: entry 'murmur-driver-anthropic'"
6771 ),
6772 "{msg}"
6773 );
6774 assert!(!msg.contains("sk-literal"), "{msg}");
6775 }
6776
6777 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n api_key: ${KEY}\n";
6778 let msg = RuntimeManifest::from_yaml_str(process)
6779 .unwrap_err()
6780 .to_string();
6781 assert!(msg.contains("'inference.api_key'"), "{msg}");
6782 assert!(
6783 msg.contains("on the artifacts: entry inference.driver.artifact names"),
6784 "{msg}"
6785 );
6786 assert!(
6787 RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
6788 "the refusal does not depend on resolving secrets"
6789 );
6790 }
6791
6792 #[test]
6793 fn inference_endpoint_is_refused_naming_gateway_endpoint() {
6794 let manifest = http_driver_manifest(
6795 "\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.anthropic.com\n api_key: test-key",
6796 )
6797 .replace(" model:", " endpoint: https://api.anthropic.com\n model:");
6798 let msg = RuntimeManifest::from_yaml_str(&manifest)
6799 .unwrap_err()
6800 .to_string();
6801 assert!(msg.contains("'inference.endpoint'"), "{msg}");
6802 assert!(
6803 msg.contains("set gateway.endpoint on the artifacts: entry 'murmur-driver-anthropic'"),
6804 "{msg}"
6805 );
6806
6807 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n endpoint: http://localhost:8080\n";
6808 let msg = RuntimeManifest::from_yaml_str(process)
6809 .unwrap_err()
6810 .to_string();
6811 assert!(msg.contains("'inference.endpoint'"), "{msg}");
6812 assert!(msg.contains("was removed"), "{msg}");
6813 }
6814
6815 #[test]
6816 fn without_secrets_leaves_gateway_api_key_unread() {
6817 let manifest = tool_gateway_manifest(
6818 "\n endpoint: https://api.tavily.com\n api_key: tvly-literal",
6819 );
6820 let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
6821 assert_eq!(
6822 resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
6823 Some(ApiKeyReference::Literal("tvly-literal".to_string()))
6824 );
6825 let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
6826 let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
6827 assert_eq!(gateway.endpoint, "https://api.tavily.com");
6828 assert_eq!(gateway.api_key, None);
6829 }
6830
6831 #[test]
6832 fn referenced_env_variables_reads_gateway_api_key() {
6833 let manifest = "name: cap\nversion: 0.0.1\nartifacts:\n - name: web-search\n version: 0.1.0\n gateway:\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.anthropic.com\n api_key: ${ANTHROPIC_API_KEY}\n - name: literal\n version: 0.1.0\n gateway:\n endpoint: https://example.com\n api_key: not-a-reference\ninference:\n model: m\n driver:\n artifact: murmur-driver-anthropic\n";
6834 let referenced = referenced_env_variables(manifest);
6835 assert_eq!(
6836 referenced,
6837 vec![
6838 ReferencedEnvVariable {
6839 field: "artifacts.murmur-driver-anthropic.gateway.api_key".to_string(),
6840 variable: "ANTHROPIC_API_KEY".to_string(),
6841 },
6842 ReferencedEnvVariable {
6843 field: "artifacts.web-search.gateway.api_key".to_string(),
6844 variable: "TAVILY_API_KEY".to_string(),
6845 },
6846 ]
6847 );
6848 assert!(referenced
6849 .iter()
6850 .all(ReferencedEnvVariable::is_gateway_credential));
6851 }
6852
6853 #[test]
6854 fn parses_inline_system_prompt() {
6855 let manifest = RuntimeManifest::from_yaml_str(
6856 r#"
6857name: cap
6858version: 0.0.1
6859artifacts:
6860 - name: murmur-driver-anthropic
6861 version: 0.1.0
6862 runtime: driver
6863 gateway:
6864 endpoint: http://127.0.0.1:8080
6865 keyless: true
6866inference:
6867 model: test-model
6868 system_prompt: "Always begin with CONFIRMED:"
6869 driver:
6870 artifact: murmur-driver-anthropic
6871"#,
6872 )
6873 .unwrap();
6874
6875 let inference = manifest.inference.unwrap();
6876 assert_eq!(
6877 inference.system_prompt,
6878 Some("Always begin with CONFIRMED:".to_string())
6879 );
6880 assert!(inference.system_prompt_file.is_none());
6881 }
6882
6883 #[test]
6884 fn parses_compaction_system_prompt() {
6885 let manifest = RuntimeManifest::from_yaml_str(
6886 r#"
6887name: cap
6888version: 0.0.1
6889artifacts:
6890 - name: murmur-driver-anthropic
6891 version: 0.1.0
6892 runtime: driver
6893 gateway:
6894 endpoint: http://127.0.0.1:8080
6895 keyless: true
6896inference:
6897 model: test-model
6898 compaction:
6899 model: compaction-model
6900 system_prompt: "task = X, currently editing Y, already tried Z."
6901 driver:
6902 artifact: murmur-driver-anthropic
6903"#,
6904 )
6905 .unwrap();
6906
6907 let compaction = manifest.inference.unwrap().compaction.unwrap();
6908 assert_eq!(
6909 compaction.system_prompt,
6910 Some("task = X, currently editing Y, already tried Z.".to_string())
6911 );
6912 assert_eq!(compaction.model, Some("compaction-model".to_string()));
6913 }
6914
6915 #[test]
6918 fn compaction_system_prompt_absent_stays_none() {
6919 let manifest = RuntimeManifest::from_yaml_str(
6920 r#"
6921name: cap
6922version: 0.0.1
6923artifacts:
6924 - name: murmur-driver-anthropic
6925 version: 0.1.0
6926 runtime: driver
6927 gateway:
6928 endpoint: http://127.0.0.1:8080
6929 keyless: true
6930inference:
6931 model: test-model
6932 compaction:
6933 model: compaction-model
6934 driver:
6935 artifact: murmur-driver-anthropic
6936"#,
6937 )
6938 .unwrap();
6939
6940 let compaction = manifest.inference.unwrap().compaction.unwrap();
6941 assert!(compaction.system_prompt.is_none());
6942 assert!(compaction.system_prompt_file.is_none());
6943 assert_eq!(compaction.model, Some("compaction-model".to_string()));
6944 }
6945
6946 #[test]
6950 fn parses_compaction_system_prompt_file() {
6951 let manifest = RuntimeManifest::from_yaml_str(
6952 r#"
6953name: cap
6954version: 0.0.1
6955artifacts:
6956 - name: murmur-driver-anthropic
6957 version: 0.1.0
6958 runtime: driver
6959 gateway:
6960 endpoint: http://127.0.0.1:8080
6961 keyless: true
6962inference:
6963 model: test-model
6964 compaction:
6965 model: compaction-model
6966 system_prompt_file: "compaction-instructions.md"
6967 driver:
6968 artifact: murmur-driver-anthropic
6969"#,
6970 )
6971 .unwrap();
6972
6973 let compaction = manifest.inference.unwrap().compaction.unwrap();
6974 assert_eq!(
6975 compaction.system_prompt_file,
6976 Some("compaction-instructions.md".to_string())
6977 );
6978 assert!(compaction.system_prompt.is_none());
6979 assert_eq!(compaction.model, Some("compaction-model".to_string()));
6980 }
6981
6982 #[test]
6985 fn compaction_system_prompt_file_independent_of_model() {
6986 let manifest = RuntimeManifest::from_yaml_str(
6987 r#"
6988name: cap
6989version: 0.0.1
6990artifacts:
6991 - name: murmur-driver-anthropic
6992 version: 0.1.0
6993 runtime: driver
6994 gateway:
6995 endpoint: http://127.0.0.1:8080
6996 keyless: true
6997inference:
6998 model: test-model
6999 compaction:
7000 threshold: 0.5
7001 system_prompt_file: "compaction-instructions.md"
7002 driver:
7003 artifact: murmur-driver-anthropic
7004"#,
7005 )
7006 .unwrap();
7007
7008 let compaction = manifest.inference.unwrap().compaction.unwrap();
7009 assert_eq!(
7010 compaction.system_prompt_file,
7011 Some("compaction-instructions.md".to_string())
7012 );
7013 assert!(compaction.model.is_none());
7014 assert_eq!(compaction.threshold, Some(0.5));
7015 }
7016
7017 #[test]
7021 fn parses_compaction_dump_summaries() {
7022 let parse = |line: &str| {
7023 RuntimeManifest::from_yaml_str(&format!(
7024 r#"
7025name: cap
7026version: 0.0.1
7027artifacts:
7028 - name: murmur-driver-anthropic
7029 version: 0.1.0
7030 runtime: driver
7031 gateway:
7032 endpoint: http://127.0.0.1:8080
7033 keyless: true
7034inference:
7035 model: test-model
7036 compaction:
7037 model: compaction-model
7038{line} driver:
7039 artifact: murmur-driver-anthropic
7040"#
7041 ))
7042 .unwrap()
7043 .inference
7044 .unwrap()
7045 .compaction
7046 .unwrap()
7047 };
7048
7049 assert_eq!(
7050 parse(" dump_summaries: true\n").dump_summaries,
7051 Some(true)
7052 );
7053 assert_eq!(
7054 parse(" dump_summaries: false\n").dump_summaries,
7055 Some(false)
7056 );
7057 assert_eq!(parse("").dump_summaries, None);
7058 }
7059
7060 #[test]
7064 fn compaction_dump_summaries_does_not_interact_with_other_fields() {
7065 let compaction = RuntimeManifest::from_yaml_str(
7066 r#"
7067name: cap
7068version: 0.0.1
7069artifacts:
7070 - name: murmur-driver-anthropic
7071 version: 0.1.0
7072 runtime: driver
7073 gateway:
7074 endpoint: http://127.0.0.1:8080
7075 keyless: true
7076inference:
7077 model: test-model
7078 compaction:
7079 threshold: 0.5
7080 model: compaction-model
7081 system_prompt_file: "compaction-instructions.md"
7082 dump_summaries: true
7083 driver:
7084 artifact: murmur-driver-anthropic
7085"#,
7086 )
7087 .unwrap()
7088 .inference
7089 .unwrap()
7090 .compaction
7091 .unwrap();
7092
7093 assert_eq!(compaction.dump_summaries, Some(true));
7094 assert_eq!(compaction.threshold, Some(0.5));
7095 assert_eq!(compaction.model, Some("compaction-model".to_string()));
7096 assert_eq!(
7097 compaction.system_prompt_file,
7098 Some("compaction-instructions.md".to_string())
7099 );
7100 assert!(compaction.system_prompt.is_none());
7101
7102 let err = RuntimeManifest::from_yaml_str(
7104 r#"
7105name: cap
7106version: 0.0.1
7107artifacts:
7108 - name: murmur-driver-anthropic
7109 version: 0.1.0
7110 runtime: driver
7111 gateway:
7112 endpoint: http://127.0.0.1:8080
7113 keyless: true
7114inference:
7115 model: test-model
7116 compaction:
7117 dump_summaries: true
7118 system_prompt: "inline"
7119 system_prompt_file: "compaction-instructions.md"
7120 driver:
7121 artifact: murmur-driver-anthropic
7122"#,
7123 )
7124 .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
7125 assert!(matches!(
7126 err,
7127 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
7128 if field == "inference.compaction.system_prompt"
7129 ));
7130
7131 let err = RuntimeManifest::from_yaml_str(
7133 r#"
7134name: cap
7135version: 0.0.1
7136artifacts:
7137 - name: murmur-driver-anthropic
7138 version: 0.1.0
7139 runtime: driver
7140 gateway:
7141 endpoint: http://127.0.0.1:8080
7142 keyless: true
7143inference:
7144 model: test-model
7145 compaction:
7146 dump_summaries: true
7147 threshold: 1.5
7148 driver:
7149 artifact: murmur-driver-anthropic
7150"#,
7151 )
7152 .expect_err("dump_summaries must not suppress the threshold range check");
7153 assert!(matches!(
7154 err,
7155 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
7156 if field == "inference.compaction.threshold"
7157 ));
7158 }
7159
7160 #[test]
7164 fn compaction_prompt_sources_are_mutually_exclusive() {
7165 let err = RuntimeManifest::from_yaml_str(
7166 r#"
7167name: cap
7168version: 0.0.1
7169artifacts:
7170 - name: murmur-driver-anthropic
7171 version: 0.1.0
7172 runtime: driver
7173 gateway:
7174 endpoint: http://127.0.0.1:8080
7175 keyless: true
7176inference:
7177 model: test-model
7178 compaction:
7179 system_prompt: "inline"
7180 system_prompt_file: "compaction-instructions.md"
7181 driver:
7182 artifact: murmur-driver-anthropic
7183"#,
7184 )
7185 .expect_err("both compaction prompt sources set must fail");
7186
7187 match err {
7188 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
7189 assert_eq!(field, "inference.compaction.system_prompt");
7190 assert_eq!(
7191 message,
7192 "at most one of inference.compaction.system_prompt, \
7193 inference.compaction.system_prompt_file may be set"
7194 );
7195 }
7196 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
7197 }
7198 }
7199
7200 #[test]
7204 fn top_level_and_compaction_prompt_sources_do_not_collide() {
7205 let manifest = RuntimeManifest::from_yaml_str(
7206 r#"
7207name: cap
7208version: 0.0.1
7209artifacts:
7210 - name: murmur-driver-anthropic
7211 version: 0.1.0
7212 runtime: driver
7213 gateway:
7214 endpoint: http://127.0.0.1:8080
7215 keyless: true
7216inference:
7217 model: test-model
7218 system_prompt: "be helpful"
7219 compaction:
7220 system_prompt_file: "compaction-instructions.md"
7221 driver:
7222 artifact: murmur-driver-anthropic
7223"#,
7224 )
7225 .unwrap();
7226
7227 let inference = manifest.inference.unwrap();
7228 assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
7229 assert_eq!(
7230 inference.compaction.unwrap().system_prompt_file,
7231 Some("compaction-instructions.md".to_string())
7232 );
7233 }
7234
7235 #[test]
7236 fn parses_system_prompt_file() {
7237 let manifest = RuntimeManifest::from_yaml_str(
7238 r#"
7239name: cap
7240version: 0.0.1
7241artifacts:
7242 - name: murmur-driver-anthropic
7243 version: 0.1.0
7244 runtime: driver
7245 gateway:
7246 endpoint: http://127.0.0.1:8080
7247 keyless: true
7248inference:
7249 model: test-model
7250 system_prompt_file: "conventions.md"
7251 driver:
7252 artifact: murmur-driver-anthropic
7253"#,
7254 )
7255 .unwrap();
7256
7257 let inference = manifest.inference.unwrap();
7258 assert_eq!(
7259 inference.system_prompt_file,
7260 Some("conventions.md".to_string())
7261 );
7262 assert!(inference.system_prompt.is_none());
7263 }
7264
7265 #[test]
7266 fn rejects_both_inline_and_file_system_prompt() {
7267 let err = RuntimeManifest::from_yaml_str(
7268 r#"
7269name: cap
7270version: 0.0.1
7271artifacts:
7272 - name: murmur-driver-anthropic
7273 version: 0.1.0
7274 runtime: driver
7275 gateway:
7276 endpoint: http://127.0.0.1:8080
7277 keyless: true
7278inference:
7279 model: test-model
7280 system_prompt: "inline"
7281 system_prompt_file: "conventions.md"
7282 driver:
7283 artifact: murmur-driver-anthropic
7284"#,
7285 )
7286 .unwrap_err();
7287
7288 let msg = err.to_string();
7289 assert!(msg.contains("inference.system_prompt"));
7290 assert!(msg.contains("system_prompt_file"));
7291 }
7292
7293 #[test]
7296 fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
7297 let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
7298 let yaml = format!(
7299 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n api_key: ${{{key}}}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur-driver-anthropic\n"
7300 );
7301
7302 assert_eq!(
7303 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
7304 .gateway
7305 .clone()
7306 .unwrap()
7307 .api_key,
7308 Some(ApiKeyReference::Environment(key.to_string()))
7309 );
7310 assert_eq!(
7311 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
7312 .unwrap()
7313 .artifacts[0]
7314 .gateway
7315 .clone()
7316 .unwrap()
7317 .api_key,
7318 None
7319 );
7320 }
7321
7322 #[test]
7323 fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
7324 let literal = ["sk-", "ant-", "debug"].concat();
7325 let debug = format!(
7326 "{:?} {:?}",
7327 ApiKeyReference::Environment("SOME_KEY".to_string()),
7328 ApiKeyReference::Literal(literal.clone())
7329 );
7330 assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
7331 assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
7332 assert!(!debug.contains(&literal), "{debug}");
7333 }
7334
7335 #[test]
7336 fn parse_without_secrets_drops_a_literal_api_key() {
7337 let literal = ["sk-", "ant-", "literal"].concat();
7340 let yaml = format!(
7341 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n api_key: {literal}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur-driver-anthropic\n"
7342 );
7343
7344 assert_eq!(
7345 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
7346 .gateway
7347 .clone()
7348 .unwrap()
7349 .api_key,
7350 Some(ApiKeyReference::Literal(literal))
7351 );
7352 assert_eq!(
7353 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
7354 .unwrap()
7355 .artifacts[0]
7356 .gateway
7357 .clone()
7358 .unwrap()
7359 .api_key,
7360 None
7361 );
7362 }
7363
7364 #[test]
7367 fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
7368 let process_with_key = format!(
7369 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n model: test-model\n api_key: {}\n",
7370 ["sk-", "ant-", "secret"].concat()
7371 );
7372 let http_without_model = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\ninference:\n transport: http\n driver:\n artifact: murmur-driver-anthropic\n";
7373
7374 for yaml in [process_with_key.as_str(), http_without_model] {
7375 let resolving = RuntimeManifest::from_yaml_str(yaml)
7376 .unwrap_err()
7377 .to_string();
7378 let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
7379 .unwrap_err()
7380 .to_string();
7381 assert_eq!(resolving, without_secrets);
7382 }
7383 }
7384
7385 #[test]
7389 fn referenced_env_variables_reports_a_reference_and_nothing_else() {
7390 let referencing = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n api_key: ${MURMUR_TEST_REFERENCED_KEY}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur-driver-anthropic\n";
7391 let referenced = referenced_env_variables(referencing);
7392 assert_eq!(referenced.len(), 1);
7393 assert_eq!(
7394 referenced[0].field,
7395 "artifacts.murmur-driver-anthropic.gateway.api_key"
7396 );
7397 assert!(referenced[0].is_gateway_credential());
7398 assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
7399
7400 let literal = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n api_key: not-a-reference\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur-driver-anthropic\n";
7401 assert!(referenced_env_variables(literal).is_empty());
7402
7403 let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
7404 assert!(referenced_env_variables(no_inference).is_empty());
7405
7406 let unparseable = "name: broken\nversion: [\n";
7407 assert!(referenced_env_variables(unparseable).is_empty());
7408 }
7409
7410 #[test]
7411 fn accepts_legacy_inference_provider_field() {
7412 let manifest = RuntimeManifest::from_yaml_str(
7413 r#"
7414name: cap
7415version: 0.0.1
7416artifacts:
7417 - name: murmur-driver-openai
7418 version: 0.1.0
7419 runtime: driver
7420 gateway:
7421 endpoint: http://127.0.0.1:8080
7422 keyless: true
7423inference:
7424 transport: http
7425 model: test-model
7426 provider:
7427 artifact: murmur-driver-openai
7428"#,
7429 )
7430 .unwrap();
7431
7432 assert_eq!(
7433 manifest
7434 .inference
7435 .unwrap()
7436 .driver
7437 .as_ref()
7438 .unwrap()
7439 .artifact,
7440 "murmur-driver-openai"
7441 );
7442 }
7443
7444 #[test]
7445 fn prefers_driver_field_over_legacy_provider_field() {
7446 let manifest = RuntimeManifest::from_yaml_str(
7447 r#"
7448name: cap
7449version: 0.0.1
7450artifacts:
7451 - name: murmur-driver-anthropic
7452 version: 0.1.0
7453 runtime: driver
7454 gateway:
7455 endpoint: http://127.0.0.1:8080
7456 keyless: true
7457inference:
7458 transport: http
7459 model: test-model
7460 driver:
7461 artifact: murmur-driver-anthropic
7462 provider:
7463 artifact: murmur-driver-openai
7464"#,
7465 )
7466 .unwrap();
7467
7468 assert_eq!(
7469 manifest
7470 .inference
7471 .unwrap()
7472 .driver
7473 .as_ref()
7474 .unwrap()
7475 .artifact,
7476 "murmur-driver-anthropic"
7477 );
7478 }
7479
7480 #[test]
7481 fn inference_requires_driver_artifact() {
7482 let err = RuntimeManifest::from_yaml_str(
7483 r#"
7484name: cap
7485version: 0.0.1
7486artifacts: []
7487inference:
7488 transport: http
7489 endpoint: http://127.0.0.1:8080
7490 model: test-model
7491"#,
7492 )
7493 .unwrap_err();
7494
7495 assert!(err.to_string().contains("inference.driver"));
7496 }
7497
7498 #[test]
7499 fn test_runtime_driver_deserializes() {
7500 let manifest = RuntimeManifest::from_yaml_str(
7501 r#"
7502name: cap
7503version: 0.0.1
7504artifacts:
7505 - name: my-provider
7506 version: 1.0.0
7507 runtime: driver
7508"#,
7509 )
7510 .unwrap();
7511
7512 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
7513 }
7514
7515 #[test]
7516 fn test_runtime_unknown_is_error() {
7517 let err = RuntimeManifest::from_yaml_str(
7518 r#"
7519name: cap
7520version: 0.0.1
7521artifacts:
7522 - name: foo
7523 version: 1.0.0
7524 runtime: unknown_type
7525"#,
7526 )
7527 .unwrap_err();
7528
7529 let msg = err.to_string();
7530 assert!(msg.contains("runtime"), "error should mention 'runtime'");
7531 assert!(
7532 msg.contains("unknown_type"),
7533 "error should identify the bad value"
7534 );
7535 }
7536
7537 #[test]
7538 fn test_runtime_all_variants_roundtrip() {
7539 for variant in [
7540 ArtifactRuntime::Tool,
7541 ArtifactRuntime::Driver,
7542 ArtifactRuntime::Hook,
7543 ArtifactRuntime::Skill,
7544 ] {
7545 let json = serde_json::to_string(&variant).unwrap();
7546 let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
7547 assert_eq!(variant, back);
7548 }
7549 }
7550
7551 #[test]
7552 fn skill_source_without_version_defaults_to_local() {
7553 let manifest = RuntimeManifest::from_yaml_str(
7554 r#"
7555name: cap
7556version: 0.0.1
7557artifacts:
7558 - name: my-skill
7559 source: ./skills/my-skill/skill.md
7560 runtime: skill
7561"#,
7562 )
7563 .unwrap();
7564
7565 assert_eq!(manifest.artifacts.len(), 1);
7566 let artifact = &manifest.artifacts[0];
7567 assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
7568 assert_eq!(
7569 artifact.source.as_deref(),
7570 Some("./skills/my-skill/skill.md")
7571 );
7572 assert_eq!(artifact.version, "local");
7573 }
7574
7575 #[test]
7576 fn skill_source_directory_path_is_accepted() {
7577 let manifest = RuntimeManifest::from_yaml_str(
7578 r#"
7579name: cap
7580version: 0.0.1
7581artifacts:
7582 - name: my-skill
7583 source: ./skills/my-skill/
7584 runtime: skill
7585"#,
7586 )
7587 .unwrap();
7588
7589 assert_eq!(
7590 manifest.artifacts[0].source.as_deref(),
7591 Some("./skills/my-skill/")
7592 );
7593 assert_eq!(manifest.artifacts[0].version, "local");
7594 }
7595
7596 #[test]
7597 fn skill_source_with_explicit_version_substitutes_local() {
7598 let manifest = RuntimeManifest::from_yaml_str(
7600 r#"
7601name: cap
7602version: 0.0.1
7603artifacts:
7604 - name: my-skill
7605 source: ./skills/my-skill/skill.md
7606 version: 9.9.9
7607 runtime: skill
7608"#,
7609 )
7610 .unwrap();
7611
7612 assert_eq!(manifest.artifacts[0].version, "local");
7613 }
7614
7615 #[test]
7616 fn local_source_true_allows_source_on_non_skill_runtime() {
7617 for runtime in ["tool", "driver", "hook"] {
7618 let yaml = format!(
7619 r#"
7620name: cap
7621version: 0.0.1
7622artifacts:
7623 - name: my-thing
7624 source: ./local/path
7625 local_source: true
7626 runtime: {runtime}
7627"#
7628 );
7629 let manifest = RuntimeManifest::from_yaml_str(&yaml)
7630 .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
7631 let artifact = &manifest.artifacts[0];
7632 assert!(artifact.local_source, "runtime {runtime}");
7633 assert_eq!(artifact.source.as_deref(), Some("./local/path"));
7634 assert_eq!(artifact.version, "local", "runtime {runtime}");
7635 }
7636 }
7637
7638 #[test]
7639 fn local_source_false_on_skill_with_source_is_error() {
7640 let err = RuntimeManifest::from_yaml_str(
7643 r#"
7644name: cap
7645version: 0.0.1
7646artifacts:
7647 - name: my-skill
7648 source: ./skills/my-skill
7649 local_source: false
7650 runtime: skill
7651"#,
7652 )
7653 .unwrap_err();
7654 let msg = err.to_string();
7655 assert!(msg.contains("'local_source: true'"), "error was: {msg}");
7656 assert!(msg.contains("my-skill"), "error was: {msg}");
7657 }
7658
7659 #[test]
7660 fn local_source_defaults_from_role_when_absent() {
7661 let manifest = RuntimeManifest::from_yaml_str(
7662 r#"
7663name: cap
7664version: 0.0.1
7665artifacts:
7666 - name: my-skill
7667 source: ./skills/my-skill
7668 runtime: skill
7669 - name: my-tool
7670 version: 1.0.0
7671 runtime: tool
7672"#,
7673 )
7674 .unwrap();
7675 assert!(manifest.artifacts[0].local_source, "skill defaults to true");
7676 assert!(
7677 !manifest.artifacts[1].local_source,
7678 "tool defaults to false"
7679 );
7680 }
7681
7682 #[test]
7683 fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
7684 let manifest = RuntimeManifest::from_yaml_str(
7685 r#"
7686name: cap
7687version: 0.0.1
7688artifacts:
7689 - name: my-tool
7690 version: 1.0.0
7691 runtime: tool
7692 prompt_payload: true
7693 - name: murmur-driver-anthropic
7694 version: 0.1.0
7695 runtime: driver
7696 gateway:
7697 endpoint: http://127.0.0.1:8080
7698 keyless: true
7699inference:
7700 model: test-model
7701 system_prompt_artifact: my-tool
7702 driver:
7703 artifact: murmur-driver-anthropic
7704"#,
7705 )
7706 .unwrap();
7707 assert!(manifest.artifacts[0].prompt_payload);
7708 }
7709
7710 #[test]
7711 fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
7712 let err = RuntimeManifest::from_yaml_str(
7713 r#"
7714name: cap
7715version: 0.0.1
7716artifacts:
7717 - name: my-skill
7718 version: 1.0.0
7719 runtime: skill
7720 prompt_payload: false
7721 - name: murmur-driver-anthropic
7722 version: 0.1.0
7723 runtime: driver
7724 gateway:
7725 endpoint: http://127.0.0.1:8080
7726 keyless: true
7727inference:
7728 model: test-model
7729 system_prompt_artifact: my-skill
7730 driver:
7731 artifact: murmur-driver-anthropic
7732"#,
7733 )
7734 .unwrap_err();
7735 let msg = err.to_string();
7736 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
7737 }
7738
7739 #[test]
7740 fn prompt_payload_defaults_from_role_when_absent() {
7741 let manifest = RuntimeManifest::from_yaml_str(
7742 r#"
7743name: cap
7744version: 0.0.1
7745artifacts:
7746 - name: my-skill
7747 version: 1.0.0
7748 runtime: skill
7749 - name: my-tool
7750 version: 1.0.0
7751 runtime: tool
7752"#,
7753 )
7754 .unwrap();
7755 assert!(
7756 manifest.artifacts[0].prompt_payload,
7757 "skill defaults to true"
7758 );
7759 assert!(
7760 !manifest.artifacts[1].prompt_payload,
7761 "tool defaults to false"
7762 );
7763 }
7764
7765 #[test]
7766 fn source_on_non_skill_runtime_is_error() {
7767 for runtime in ["tool", "driver", "hook"] {
7768 let yaml = format!(
7769 r#"
7770name: cap
7771version: 0.0.1
7772artifacts:
7773 - name: my-thing
7774 source: ./local/path
7775 runtime: {runtime}
7776"#
7777 );
7778 let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
7779 let msg = err.to_string();
7780 assert!(
7781 msg.contains("'local_source: true'"),
7782 "runtime {runtime}: error was: {msg}"
7783 );
7784 assert!(
7785 msg.contains("my-thing"),
7786 "runtime {runtime}: error was: {msg}"
7787 );
7788 }
7789 }
7790
7791 #[test]
7792 fn registry_skill_still_requires_version() {
7793 let err = RuntimeManifest::from_yaml_str(
7794 r#"
7795name: cap
7796version: 0.0.1
7797artifacts:
7798 - name: my-skill
7799 runtime: skill
7800"#,
7801 )
7802 .unwrap_err();
7803
7804 let msg = err.to_string();
7805 assert!(
7806 msg.contains("missing required field 'version'"),
7807 "error was: {msg}"
7808 );
7809 }
7810
7811 #[test]
7812 fn rejects_unknown_inference_transport() {
7813 let err = RuntimeManifest::from_yaml_str(
7814 r#"
7815name: cap
7816version: 0.0.1
7817artifacts: []
7818inference:
7819 transport: grpc
7820 model: test-model
7821 driver:
7822 artifact: murmur-driver-anthropic
7823"#,
7824 )
7825 .unwrap_err();
7826
7827 let msg = err.to_string();
7828 assert!(msg.contains("inference.transport"), "error was: {msg}");
7829 assert!(msg.contains("unknown value"), "error was: {msg}");
7830 assert!(msg.contains("grpc"), "error was: {msg}");
7831 }
7832
7833 #[test]
7836 fn process_transport_with_command_and_no_driver_is_refused() {
7837 let err = RuntimeManifest::from_yaml_str(
7838 r#"
7839name: cap
7840version: 0.0.1
7841artifacts: []
7842inference:
7843 transport: process
7844 command: some-cli
7845 model: some-model
7846 max_turns: 20
7847"#,
7848 )
7849 .unwrap_err();
7850
7851 let msg = err.to_string();
7852 assert!(
7853 msg.contains("inference.driver.artifact"),
7854 "error was: {msg}"
7855 );
7856 assert!(msg.contains("inference.command"), "error was: {msg}");
7857 assert!(
7858 msg.contains("murmur-driver-claude-code"),
7859 "error was: {msg}"
7860 );
7861 }
7862
7863 #[test]
7864 fn process_transport_with_neither_driver_nor_command_is_refused() {
7865 let err = RuntimeManifest::from_yaml_str(
7866 r#"
7867name: cap
7868version: 0.0.1
7869artifacts: []
7870inference:
7871 transport: process
7872 model: some-model
7873"#,
7874 )
7875 .unwrap_err();
7876
7877 let msg = err.to_string();
7878 assert!(
7879 msg.contains("inference.driver.artifact"),
7880 "error was: {msg}"
7881 );
7882 assert!(
7883 msg.contains("murmur-driver-claude-code"),
7884 "error was: {msg}"
7885 );
7886 }
7887
7888 #[test]
7889 fn process_transport_allows_absent_model() {
7890 let manifest =
7893 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
7894 .expect("process transport should parse without a model");
7895 let inference = manifest.inference.expect("inference present");
7896 assert_eq!(inference.transport, "process");
7897 assert!(inference.command.is_none());
7898 assert_eq!(
7899 inference.model, "",
7900 "absent model resolves to empty (harness default)"
7901 );
7902 }
7903
7904 fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
7905 format!(
7906 "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n transport: process\n \
7907 driver:\n artifact: fixture-process-driver\n{inference_extra}"
7908 )
7909 }
7910
7911 const PROCESS_DRIVER_ENTRY: &str =
7912 " - name: fixture-process-driver\n version: 0.1.0\n runtime: driver";
7913
7914 #[test]
7915 fn process_transport_accepts_driver() {
7916 let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
7917 PROCESS_DRIVER_ENTRY,
7918 " config:\n profile: work\n command: /usr/local/bin/fixture-cli\n",
7919 ))
7920 .unwrap();
7921
7922 let inference = manifest.inference.expect("inference should exist");
7923 assert_eq!(inference.transport, "process");
7924 let driver = inference.driver.expect("driver should be parsed");
7925 assert_eq!(driver.artifact, "fixture-process-driver");
7926 assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
7927 assert_eq!(
7928 inference.command.as_deref(),
7929 Some("/usr/local/bin/fixture-cli")
7930 );
7931 }
7932
7933 #[test]
7934 fn process_transport_accepts_driver_without_command() {
7935 let manifest =
7936 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
7937 .unwrap();
7938
7939 let inference = manifest.inference.expect("inference should exist");
7940 assert_eq!(
7941 inference.driver.map(|d| d.artifact).as_deref(),
7942 Some("fixture-process-driver")
7943 );
7944 assert!(inference.command.is_none());
7945 assert_eq!(inference.model, "");
7946 }
7947
7948 #[test]
7949 fn process_transport_rejects_provider_alias() {
7950 let msg = RuntimeManifest::from_yaml_str(
7951 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n \
7952 command: fixture-cli\n provider:\n artifact: fixture-process-driver\n",
7953 )
7954 .unwrap_err()
7955 .to_string();
7956 assert!(msg.contains("inference.provider.artifact"), "{msg}");
7957 assert!(msg.contains("inference.driver"), "{msg}");
7958 }
7959
7960 #[test]
7961 fn process_driver_entry_must_be_declared() {
7962 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
7963 " - name: other\n version: 0.1.0\n runtime: tool",
7964 "",
7965 ))
7966 .unwrap_err()
7967 .to_string();
7968 assert!(msg.contains("inference.driver.artifact"), "{msg}");
7969 assert!(
7970 msg.contains("artifact 'fixture-process-driver' is not declared in artifacts:"),
7971 "{msg}"
7972 );
7973 }
7974
7975 #[test]
7976 fn process_driver_entry_must_be_runtime_driver() {
7977 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
7978 " - name: fixture-process-driver\n version: 0.1.0\n runtime: tool",
7979 "",
7980 ))
7981 .unwrap_err()
7982 .to_string();
7983 assert!(
7984 msg.contains("must be declared with runtime: driver"),
7985 "{msg}"
7986 );
7987 }
7988
7989 #[test]
7990 fn process_driver_entry_refuses_capabilities() {
7991 let entry =
7992 format!("{PROCESS_DRIVER_ENTRY}\n capabilities:\n env:\n allow: [HOME]");
7993 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
7994 .unwrap_err()
7995 .to_string();
7996 assert!(msg.contains("granted nothing"), "{msg}");
7997 assert!(msg.contains("inference.driver.config"), "{msg}");
7998 }
7999
8000 #[test]
8001 fn process_driver_entry_refuses_config() {
8002 let entry = format!("{PROCESS_DRIVER_ENTRY}\n config:\n profile: work");
8003 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
8004 .unwrap_err()
8005 .to_string();
8006 assert!(msg.contains("granted nothing"), "{msg}");
8007 assert!(msg.contains("remove capabilities:/config:"), "{msg}");
8008 }
8009
8010 #[test]
8011 fn process_transport_rejects_endpoint() {
8012 let err = RuntimeManifest::from_yaml_str(
8013 r#"
8014name: cap
8015version: 0.0.1
8016artifacts: []
8017inference:
8018 transport: process
8019 command: claude
8020 model: claude-haiku-4-5-20251001
8021 endpoint: http://localhost:8080
8022"#,
8023 )
8024 .unwrap_err();
8025
8026 let msg = err.to_string();
8027 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
8028 assert!(msg.contains("was removed"), "error was: {msg}");
8029 }
8030
8031 #[test]
8032 fn process_transport_rejects_api_key() {
8033 let key = ["sk-", "ant-", "secret"].concat();
8036 let err = RuntimeManifest::from_yaml_str(&format!(
8037 r#"
8038name: cap
8039version: 0.0.1
8040artifacts: []
8041inference:
8042 transport: process
8043 command: claude
8044 model: claude-haiku-4-5-20251001
8045 api_key: {key}
8046"#
8047 ))
8048 .unwrap_err();
8049
8050 let msg = err.to_string();
8051 assert!(msg.contains("inference.api_key"), "error was: {msg}");
8052 assert!(msg.contains("was removed"), "error was: {msg}");
8053 assert!(!msg.contains(&key), "error was: {msg}");
8054 }
8055
8056 #[test]
8057 fn process_transport_rejects_max_tokens() {
8058 let err = RuntimeManifest::from_yaml_str(
8061 r#"
8062name: cap
8063version: 0.0.1
8064artifacts: []
8065inference:
8066 transport: process
8067 command: claude
8068 model: claude-haiku-4-5-20251001
8069 max_tokens: 4096
8070"#,
8071 )
8072 .unwrap_err();
8073
8074 let msg = err.to_string();
8075 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
8076 assert!(
8077 msg.contains("not valid with transport: process"),
8078 "error was: {msg}"
8079 );
8080 }
8081
8082 #[test]
8086 fn process_transport_carries_max_session_tokens() {
8087 let manifest = RuntimeManifest::from_yaml_str(
8088 r#"
8089name: cap
8090version: 0.0.1
8091artifacts:
8092 - name: murmur-driver-claude-code
8093 version: 0.1.0
8094 runtime: driver
8095inference:
8096 transport: process
8097 driver:
8098 artifact: murmur-driver-claude-code
8099 model: claude-haiku-4-5-20251001
8100 max_session_tokens: 200000
8101"#,
8102 )
8103 .unwrap();
8104
8105 assert_eq!(
8106 manifest.inference.unwrap().max_session_tokens,
8107 Some(200_000)
8108 );
8109 }
8110
8111 #[test]
8112 fn process_transport_rejects_a_zero_max_session_tokens() {
8113 let err = RuntimeManifest::from_yaml_str(
8114 r#"
8115name: cap
8116version: 0.0.1
8117artifacts:
8118 - name: murmur-driver-claude-code
8119 version: 0.1.0
8120 runtime: driver
8121inference:
8122 transport: process
8123 driver:
8124 artifact: murmur-driver-claude-code
8125 model: claude-haiku-4-5-20251001
8126 max_session_tokens: 0
8127"#,
8128 )
8129 .unwrap_err();
8130
8131 let msg = err.to_string();
8132 assert!(
8133 msg.contains("inference.max_session_tokens"),
8134 "error was: {msg}"
8135 );
8136 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
8137 }
8138
8139 #[test]
8140 fn inference_max_session_tokens_round_trips() {
8141 let manifest = RuntimeManifest::from_yaml_str(
8142 r#"
8143name: cap
8144version: 0.0.1
8145artifacts:
8146 - name: murmur-driver-anthropic
8147 version: 0.1.0
8148 runtime: driver
8149 gateway:
8150 endpoint: https://api.anthropic.com
8151 api_key: test-key
8152inference:
8153 transport: http
8154 model: claude-opus-4-5
8155 max_session_tokens: 5000000000
8156 driver:
8157 artifact: murmur-driver-anthropic
8158"#,
8159 )
8160 .unwrap();
8161
8162 assert_eq!(
8163 manifest.inference.unwrap().max_session_tokens,
8164 Some(5_000_000_000)
8165 );
8166 }
8167
8168 #[test]
8169 fn inference_max_session_tokens_absent_is_none() {
8170 let manifest = RuntimeManifest::from_yaml_str(
8171 r#"
8172name: cap
8173version: 0.0.1
8174artifacts:
8175 - name: murmur-driver-anthropic
8176 version: 0.1.0
8177 runtime: driver
8178 gateway:
8179 endpoint: https://api.anthropic.com
8180 api_key: test-key
8181inference:
8182 transport: http
8183 model: claude-opus-4-5
8184 driver:
8185 artifact: murmur-driver-anthropic
8186"#,
8187 )
8188 .unwrap();
8189
8190 assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
8191 }
8192
8193 #[test]
8194 fn inference_max_session_tokens_zero_is_rejected() {
8195 let err = RuntimeManifest::from_yaml_str(
8196 r#"
8197name: cap
8198version: 0.0.1
8199artifacts:
8200 - name: murmur-driver-anthropic
8201 version: 0.1.0
8202 runtime: driver
8203 gateway:
8204 endpoint: https://api.anthropic.com
8205 api_key: test-key
8206inference:
8207 transport: http
8208 model: claude-opus-4-5
8209 max_session_tokens: 0
8210 driver:
8211 artifact: murmur-driver-anthropic
8212"#,
8213 )
8214 .unwrap_err();
8215
8216 let msg = err.to_string();
8217 assert!(
8218 msg.contains("inference.max_session_tokens"),
8219 "error was: {msg}"
8220 );
8221 assert!(msg.contains("greater than 0"), "error was: {msg}");
8222 }
8223
8224 #[test]
8225 fn inference_max_tokens_round_trips() {
8226 let manifest = RuntimeManifest::from_yaml_str(
8227 r#"
8228name: cap
8229version: 0.0.1
8230artifacts:
8231 - name: murmur-driver-anthropic
8232 version: 0.1.0
8233 runtime: driver
8234 gateway:
8235 endpoint: https://api.anthropic.com
8236 api_key: test-key
8237inference:
8238 transport: http
8239 model: claude-opus-4-5
8240 max_tokens: 4096
8241 driver:
8242 artifact: murmur-driver-anthropic
8243"#,
8244 )
8245 .unwrap();
8246
8247 let inference = manifest.inference.unwrap();
8248 assert_eq!(inference.max_tokens, Some(4096));
8249 }
8250
8251 #[test]
8252 fn inference_max_tokens_absent_is_none() {
8253 let manifest = RuntimeManifest::from_yaml_str(
8256 r#"
8257name: cap
8258version: 0.0.1
8259artifacts:
8260 - name: murmur-driver-anthropic
8261 version: 0.1.0
8262 runtime: driver
8263 gateway:
8264 endpoint: https://api.anthropic.com
8265 api_key: test-key
8266inference:
8267 transport: http
8268 model: claude-opus-4-5
8269 driver:
8270 artifact: murmur-driver-anthropic
8271"#,
8272 )
8273 .unwrap();
8274
8275 assert_eq!(manifest.inference.unwrap().max_tokens, None);
8276 }
8277
8278 #[test]
8279 fn inference_max_tokens_zero_is_rejected() {
8280 let err = RuntimeManifest::from_yaml_str(
8281 r#"
8282name: cap
8283version: 0.0.1
8284artifacts:
8285 - name: murmur-driver-anthropic
8286 version: 0.1.0
8287 runtime: driver
8288 gateway:
8289 endpoint: https://api.anthropic.com
8290 api_key: test-key
8291inference:
8292 transport: http
8293 model: claude-opus-4-5
8294 max_tokens: 0
8295 driver:
8296 artifact: murmur-driver-anthropic
8297"#,
8298 )
8299 .unwrap_err();
8300
8301 let msg = err.to_string();
8302 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
8303 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
8304 }
8305
8306 #[test]
8307 fn inference_max_tokens_large_value_is_accepted() {
8308 let manifest = RuntimeManifest::from_yaml_str(
8311 r#"
8312name: cap
8313version: 0.0.1
8314artifacts:
8315 - name: murmur-driver-anthropic
8316 version: 0.1.0
8317 runtime: driver
8318 gateway:
8319 endpoint: https://api.anthropic.com
8320 api_key: test-key
8321inference:
8322 transport: http
8323 model: claude-opus-4-5
8324 max_tokens: 999999
8325 driver:
8326 artifact: murmur-driver-anthropic
8327"#,
8328 )
8329 .unwrap();
8330
8331 assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
8332 }
8333
8334 #[test]
8335 fn inference_and_context_max_tokens_are_independent() {
8336 let manifest = RuntimeManifest::from_yaml_str(
8339 r#"
8340name: cap
8341version: 0.0.1
8342artifacts:
8343 - name: murmur-driver-anthropic
8344 version: 0.1.0
8345 runtime: driver
8346 gateway:
8347 endpoint: https://api.anthropic.com
8348 api_key: test-key
8349inference:
8350 transport: http
8351 model: claude-opus-4-5
8352 max_tokens: 4096
8353 driver:
8354 artifact: murmur-driver-anthropic
8355context:
8356 max_tokens: 200000
8357"#,
8358 )
8359 .unwrap();
8360
8361 assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
8362 assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
8363 }
8364
8365 #[test]
8366 fn hook_config_defaults_when_fields_absent() {
8367 let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
8368 assert_eq!(config.binding, HookBinding::All);
8369 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
8370 assert_eq!(config.commit_policy, HookCommitPolicy::None);
8371 }
8372
8373 #[test]
8374 fn hook_config_parses_all_fields() {
8375 let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
8376 let config = parse_hook_config_from_yaml(yaml).unwrap();
8377 assert_eq!(config.binding, HookBinding::OnCompaction);
8378 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
8379 assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
8380 }
8381
8382 #[test]
8383 fn hook_config_parses_on_task_start() {
8384 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
8385 let config = parse_hook_config_from_yaml(yaml).unwrap();
8386 assert_eq!(config.binding, HookBinding::OnTaskStart);
8387 }
8388
8389 #[test]
8390 fn hook_config_parses_on_task_end() {
8391 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
8392 let config = parse_hook_config_from_yaml(yaml).unwrap();
8393 assert_eq!(config.binding, HookBinding::OnTaskEnd);
8394 }
8395
8396 #[test]
8397 fn hook_config_unknown_binding_lists_task_events() {
8398 let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
8399 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
8400 assert!(err.contains("on-task-start"), "error was: {err}");
8401 assert!(err.contains("on-task-end"), "error was: {err}");
8402 }
8403
8404 #[test]
8405 fn hook_config_async_with_commit_is_rejected() {
8406 let yaml =
8407 "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
8408 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
8409 assert!(err.contains("async-with-commit"), "error was: {err}");
8410 }
8411
8412 #[test]
8413 fn hook_config_on_stage_async_is_rejected() {
8414 let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
8415 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
8416 assert!(err.contains("on-stage"), "error was: {err}");
8417 assert!(err.contains("blocking"), "error was: {err}");
8418 }
8419
8420 #[test]
8421 fn hook_config_async_none_is_valid() {
8422 let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
8423 let config = parse_hook_config_from_yaml(yaml).unwrap();
8424 assert_eq!(config.execution_mode, HookExecutionMode::Async);
8425 assert_eq!(config.commit_policy, HookCommitPolicy::None);
8426 }
8427
8428 #[test]
8429 fn hook_config_parses_reopen_task_commit_policy() {
8430 let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
8431 let config = parse_hook_config_from_yaml(yaml).unwrap();
8432 assert_eq!(config.binding, HookBinding::OnTaskEnd);
8433 assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
8434 }
8435
8436 #[test]
8437 fn hook_config_unknown_commit_policy_lists_reopen_task() {
8438 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
8439 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
8440 assert!(err.contains("reopen-task"), "error was: {err}");
8441 }
8442
8443 #[test]
8444 fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
8445 for binding in ["on-shell", "on-tool-call"] {
8446 let yaml =
8447 format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
8448 let config = parse_hook_config_from_yaml(&yaml).unwrap();
8449 assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
8450 }
8451 assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
8452 }
8453
8454 #[test]
8457 fn hook_config_deny_without_a_binding_is_rejected() {
8458 let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
8459 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
8460 assert!(
8461 err.contains("requires an explicit binding"),
8462 "error was: {err}"
8463 );
8464 assert!(err.contains("on-shell"), "error was: {err}");
8465 assert!(err.contains("on-tool-call"), "error was: {err}");
8466 }
8467
8468 #[test]
8470 fn hook_config_deny_on_an_ungated_binding_is_rejected() {
8471 let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
8472 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
8473 assert!(err.contains("is not valid for binding"), "error was: {err}");
8474 assert!(err.contains("replace-context"), "error was: {err}");
8475 }
8476
8477 #[test]
8479 fn hook_config_async_deny_is_rejected() {
8480 let yaml =
8481 "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
8482 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
8483 assert!(err.contains("async-with-commit"), "error was: {err}");
8484 assert!(err.contains("deny"), "error was: {err}");
8485 }
8486
8487 #[test]
8488 fn hook_config_unknown_commit_policy_lists_deny() {
8489 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
8490 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
8491 assert!(err.contains("deny"), "error was: {err}");
8492 }
8493
8494 #[test]
8497 fn hook_config_async_reopen_task_is_rejected() {
8498 let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
8499 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
8500 assert!(err.contains("async-with-commit"), "error was: {err}");
8501 assert!(err.contains("reopen-task"), "error was: {err}");
8502 }
8503
8504 #[test]
8510 fn hook_config_non_committing_binding_rejects_any_commit_policy() {
8511 for binding in ["on-session-start", "on-session-end"] {
8512 let yaml = format!(
8513 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
8514 );
8515 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
8516 assert!(err.contains(binding), "error was: {err}");
8517 assert!(err.contains("replace-context"), "error was: {err}");
8518 assert!(
8519 err.contains("honors commit_policy 'none'"),
8520 "error was: {err}"
8521 );
8522 }
8523 }
8524
8525 #[test]
8528 fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
8529 for (binding, declared, honored) in [
8530 ("on-stage", "reopen-task", "write-manifests"),
8531 ("on-compaction", "write-manifests", "replace-context"),
8532 ("on-task-end", "replace-context", "reopen-task"),
8533 ("on-task-start", "replace-context", "seed-context"),
8534 ("on-shell", "replace-context", "deny"),
8535 ("on-tool-call", "replace-context", "deny"),
8536 ] {
8537 let yaml = format!(
8538 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
8539 );
8540 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
8541 assert!(err.contains(binding), "error was: {err}");
8542 assert!(
8543 err.contains(&format!("commit_policy '{declared}' is not valid")),
8544 "error was: {err}"
8545 );
8546 assert!(
8547 err.contains(&format!("honors commit_policy '{honored}'")),
8548 "error was: {err}"
8549 );
8550 }
8551 }
8552
8553 #[test]
8556 fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
8557 for declared in ["replace-context", "write-manifests", "reopen-task"] {
8558 let yaml = format!(
8559 "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
8560 );
8561 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
8562 assert!(err.contains("on-inference"), "error was: {err}");
8563 assert!(err.contains(declared), "error was: {err}");
8564 assert!(
8565 err.contains("honors commit_policy 'none'"),
8566 "error was: {err}"
8567 );
8568 assert!(err.contains("artifact"), "error was: {err}");
8569 }
8570 }
8571
8572 #[test]
8575 fn hook_config_matching_binding_and_commit_policy_is_valid() {
8576 for (binding, policy, expected) in [
8577 (
8578 "on-stage",
8579 "write-manifests",
8580 HookCommitPolicy::WriteManifests,
8581 ),
8582 (
8583 "on-compaction",
8584 "replace-context",
8585 HookCommitPolicy::ReplaceContext,
8586 ),
8587 ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
8588 ("on-shell", "deny", HookCommitPolicy::Deny),
8589 ("on-tool-call", "deny", HookCommitPolicy::Deny),
8590 ] {
8591 let yaml =
8592 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
8593 let config = parse_hook_config_from_yaml(&yaml).unwrap();
8594 assert_eq!(config.commit_policy, expected);
8595 }
8596 }
8597
8598 #[test]
8601 fn hook_config_commit_policy_none_is_valid_for_every_binding() {
8602 for binding in [
8603 "on-stage",
8604 "on-session-start",
8605 "on-task-start",
8606 "on-inference",
8607 "on-tool-call",
8608 "on-shell",
8609 "on-compaction",
8610 "on-task-end",
8611 "on-session-end",
8612 ] {
8613 let yaml =
8614 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
8615 let config = parse_hook_config_from_yaml(&yaml).unwrap();
8616 assert_eq!(config.commit_policy, HookCommitPolicy::None);
8617 }
8618 }
8619
8620 #[test]
8625 fn hook_config_omitted_binding_accepts_every_commit_policy() {
8626 for (policy, expected) in [
8627 ("replace-context", HookCommitPolicy::ReplaceContext),
8628 ("write-manifests", HookCommitPolicy::WriteManifests),
8629 ("reopen-task", HookCommitPolicy::ReopenTask),
8630 ("none", HookCommitPolicy::None),
8631 ] {
8632 let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
8633 let config = parse_hook_config_from_yaml(&yaml).unwrap();
8634 assert_eq!(config.binding, HookBinding::All);
8635 assert_eq!(config.commit_policy, expected);
8636 }
8637 }
8638
8639 #[test]
8644 fn hook_config_shipped_default_artifact_manifests_still_parse() {
8645 let shipped: [(&str, &str, HookConfig); 8] = [
8646 (
8647 "murmur-hook-compact",
8648 "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
8649 HookConfig {
8650 binding: HookBinding::OnCompaction,
8651 execution_mode: HookExecutionMode::Blocking,
8652 commit_policy: HookCommitPolicy::ReplaceContext,
8653 },
8654 ),
8655 (
8656 "murmur-hook-shell-desc",
8657 "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
8658 HookConfig {
8659 binding: HookBinding::OnStage,
8660 execution_mode: HookExecutionMode::Blocking,
8661 commit_policy: HookCommitPolicy::WriteManifests,
8662 },
8663 ),
8664 (
8665 "murmur-hook-regression-verifier",
8667 "execution_mode: blocking\ncommit_policy: reopen-task\n",
8668 HookConfig {
8669 binding: HookBinding::All,
8670 execution_mode: HookExecutionMode::Blocking,
8671 commit_policy: HookCommitPolicy::ReopenTask,
8672 },
8673 ),
8674 (
8675 "murmur-hook-memory",
8676 "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
8677 HookConfig {
8678 binding: HookBinding::OnTaskStart,
8679 execution_mode: HookExecutionMode::Blocking,
8680 commit_policy: HookCommitPolicy::SeedContext,
8681 },
8682 ),
8683 (
8684 "murmur-hook-debug",
8685 "execution_mode: async\ncommit_policy: none\n",
8686 HookConfig {
8687 binding: HookBinding::All,
8688 execution_mode: HookExecutionMode::Async,
8689 commit_policy: HookCommitPolicy::None,
8690 },
8691 ),
8692 (
8693 "murmur-hook-diff-summary",
8694 "execution_mode: blocking\ncommit_policy: none\n",
8695 HookConfig {
8696 binding: HookBinding::All,
8697 execution_mode: HookExecutionMode::Blocking,
8698 commit_policy: HookCommitPolicy::None,
8699 },
8700 ),
8701 ("murmur-hook-eval", "", HookConfig::default()),
8702 ("murmur-hook-grafana", "", HookConfig::default()),
8703 ];
8704
8705 for (name, fields, expected) in shipped {
8706 let yaml = format!("name: {name}\nruntime: hook\n{fields}");
8707 let config = parse_hook_config_from_yaml(&yaml)
8708 .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
8709 assert_eq!(config, expected, "shipped hook {name}");
8710 }
8711 }
8712
8713 #[test]
8717 fn commit_policy_for_binding_matches_the_declarable_pairs() {
8718 assert_eq!(
8719 commit_policy_for_binding(&HookBinding::OnStage),
8720 Some(HookCommitPolicy::WriteManifests)
8721 );
8722 assert_eq!(
8723 commit_policy_for_binding(&HookBinding::OnCompaction),
8724 Some(HookCommitPolicy::ReplaceContext)
8725 );
8726 assert_eq!(
8727 commit_policy_for_binding(&HookBinding::OnTaskEnd),
8728 Some(HookCommitPolicy::ReopenTask)
8729 );
8730 assert_eq!(
8731 commit_policy_for_binding(&HookBinding::OnTaskStart),
8732 Some(HookCommitPolicy::SeedContext)
8733 );
8734 assert_eq!(
8735 commit_policy_for_binding(&HookBinding::OnToolCall),
8736 Some(HookCommitPolicy::Deny)
8737 );
8738 assert_eq!(
8739 commit_policy_for_binding(&HookBinding::OnShell),
8740 Some(HookCommitPolicy::Deny)
8741 );
8742 for binding in [
8746 HookBinding::OnSessionStart,
8747 HookBinding::OnInference,
8748 HookBinding::OnSessionEnd,
8749 HookBinding::All,
8750 ] {
8751 assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
8752 }
8753 }
8754
8755 #[test]
8758 fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
8759 let config = parse_hook_config_from_yaml(
8760 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
8761 commit_policy: seed-context\n",
8762 )
8763 .expect("on-task-start + seed-context is the declarable pairing");
8764 assert_eq!(config.binding, HookBinding::OnTaskStart);
8765 assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
8766
8767 let error = parse_hook_config_from_yaml(
8768 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
8769 commit_policy: seed-context\n",
8770 )
8771 .expect_err("on-compaction cannot commit a seed");
8772 assert!(error.contains("on-compaction"), "{error}");
8773 assert!(error.contains("seed-context"), "{error}");
8774 }
8775
8776 #[test]
8779 fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
8780 let error = parse_hook_config_from_yaml(
8781 "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
8782 )
8783 .expect_err("a misspelled policy is rejected");
8784 assert!(error.contains("seed-context"), "{error}");
8785 }
8786
8787 fn context_of(yaml_block: &str) -> Option<ContextConfig> {
8790 RuntimeManifest::from_yaml_str(&format!(
8791 "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
8792 ))
8793 .expect("the manifest under test parses")
8794 .context
8795 }
8796
8797 #[test]
8798 fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
8799 let context = context_of(
8800 "context:\n max_tokens: 200000\n seed_budget: 0.10\n seed_overflow_margin: 0.10\n",
8801 )
8802 .expect("a declared context block parses to Some");
8803 assert_eq!(context.max_tokens, Some(200_000));
8804 assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
8805 assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
8806 }
8807
8808 #[test]
8809 fn seed_budget_and_overflow_margin_default_when_omitted() {
8810 let context = context_of("context:\n max_tokens: 200000\n")
8811 .expect("a declared context block parses to Some");
8812 assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
8813 assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
8814 }
8815
8816 #[test]
8819 fn seed_budget_defaults_do_not_create_a_context_block() {
8820 assert_eq!(context_of(""), None);
8821 }
8822
8823 #[test]
8824 fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
8825 for (block, field) in [
8826 ("context:\n seed_budget: 1.5\n", "context.seed_budget"),
8827 ("context:\n seed_budget: -0.1\n", "context.seed_budget"),
8828 (
8829 "context:\n seed_overflow_margin: 2.0\n",
8830 "context.seed_overflow_margin",
8831 ),
8832 ] {
8833 let error = RuntimeManifest::from_yaml_str(&format!(
8834 "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
8835 ))
8836 .expect_err("an out-of-range fraction is rejected");
8837 match error {
8838 RuntimeManifestError::InvalidInferenceConfig {
8839 field: got,
8840 ref message,
8841 } => {
8842 assert_eq!(got, field);
8843 assert!(message.contains("0.0 and 1.0"), "{message}");
8844 }
8845 other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
8846 }
8847 }
8848 }
8849
8850 #[test]
8851 fn parse_tool_implementation_defaults_to_wasm() {
8852 let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
8853 assert_eq!(impl_, ArtifactImplementation::Wasm);
8854 }
8855
8856 #[test]
8857 fn parse_tool_implementation_native() {
8858 let impl_ = parse_tool_implementation_from_yaml(
8859 "name: my-tool\nruntime: tool\nimplementation: native\n",
8860 );
8861 assert_eq!(impl_, ArtifactImplementation::Native);
8862 }
8863
8864 #[test]
8865 fn mur_version_parses_correctly() {
8866 let manifest = RuntimeManifest::from_yaml_str(
8867 "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"0.4.5\"\n",
8868 )
8869 .unwrap();
8870 assert_eq!(manifest.mur_version, Some("0.4.5".to_string()));
8871 }
8872
8873 #[test]
8874 fn mur_version_absent_is_none() {
8875 let manifest =
8876 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
8877 assert_eq!(manifest.mur_version, None);
8878 }
8879
8880 #[test]
8881 fn mur_version_empty_string_is_none() {
8882 let manifest = RuntimeManifest::from_yaml_str(
8883 "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"\"\n",
8884 )
8885 .unwrap();
8886 assert_eq!(manifest.mur_version, None);
8887 }
8888
8889 #[test]
8890 fn inference_max_turns_defaults_to_10() {
8891 let manifest = RuntimeManifest::from_yaml_str(
8892 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\ninference:\n model: test-model\n driver:\n artifact: murmur-driver-anthropic\n",
8893 ).unwrap();
8894 assert_eq!(manifest.inference.unwrap().max_turns, 10);
8895 }
8896
8897 #[test]
8898 fn inference_max_turns_explicit_value() {
8899 let manifest = RuntimeManifest::from_yaml_str(
8900 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\ninference:\n model: test-model\n max_turns: 20\n driver:\n artifact: murmur-driver-anthropic\n",
8901 ).unwrap();
8902 assert_eq!(manifest.inference.unwrap().max_turns, 20);
8903 }
8904
8905 #[test]
8906 fn inference_max_turns_zero_is_rejected() {
8907 let err = RuntimeManifest::from_yaml_str(
8908 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\ninference:\n model: test-model\n max_turns: 0\n driver:\n artifact: murmur-driver-anthropic\n",
8909 ).unwrap_err();
8910 let msg = err.to_string();
8911 assert!(msg.contains("inference.max_turns"), "error was: {msg}");
8912 assert!(msg.contains("greater than 0"), "error was: {msg}");
8913 }
8914
8915 #[test]
8917 fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
8918 let manifest = RuntimeManifest::from_yaml_str(
8919 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 45\n",
8920 )
8921 .unwrap();
8922 let lifecycle = manifest.lifecycle.unwrap();
8923 assert_eq!(lifecycle.shell_grace_secs, 45);
8924 assert_eq!(
8926 lifecycle.task_acceptance,
8927 LifecycleConfig::default().task_acceptance
8928 );
8929 assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
8930 assert_eq!(
8931 lifecycle.queue_depth,
8932 LifecycleConfig::default().queue_depth
8933 );
8934 assert_eq!(lifecycle.input_timeout_secs, None);
8935 assert_eq!(
8936 lifecycle.conversation_mode,
8937 LifecycleConfig::default().conversation_mode
8938 );
8939 assert_eq!(
8940 lifecycle.max_task_reopens,
8941 LifecycleConfig::default().max_task_reopens
8942 );
8943 }
8944
8945 #[test]
8946 fn lifecycle_shell_grace_secs_defaults_to_10() {
8947 let manifest = RuntimeManifest::from_yaml_str(
8948 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
8949 )
8950 .unwrap();
8951 let lifecycle = manifest.lifecycle.unwrap();
8952 assert_eq!(lifecycle.shell_grace_secs, 10);
8953 assert_eq!(lifecycle.queue_depth, 3);
8954
8955 let without_block =
8956 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
8957 assert_eq!(without_block.effective_lifecycle().shell_grace_secs, 10);
8958 }
8959
8960 #[test]
8963 fn lifecycle_delegation_deadline_secs_defaults_to_600() {
8964 let without_block =
8965 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
8966 assert_eq!(
8967 without_block.effective_lifecycle().delegation_deadline_secs,
8968 600
8969 );
8970
8971 let without_key = RuntimeManifest::from_yaml_str(
8972 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
8973 )
8974 .unwrap();
8975 let lifecycle = without_key.lifecycle.unwrap();
8976 assert_eq!(lifecycle.delegation_deadline_secs, 600);
8977 assert_eq!(lifecycle.queue_depth, 3);
8978 }
8979
8980 #[test]
8981 fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
8982 let manifest = RuntimeManifest::from_yaml_str(
8983 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
8984 delegation_deadline_secs: 30\n",
8985 )
8986 .unwrap();
8987 let lifecycle = manifest.lifecycle.unwrap();
8988 assert_eq!(lifecycle.delegation_deadline_secs, 30);
8989 assert_eq!(
8991 lifecycle.shell_grace_secs,
8992 LifecycleConfig::default().shell_grace_secs
8993 );
8994 assert_eq!(
8995 lifecycle.max_task_reopens,
8996 LifecycleConfig::default().max_task_reopens
8997 );
8998 }
8999
9000 #[test]
9002 fn lifecycle_delegation_deadline_secs_is_a_known_key() {
9003 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
9004 let manifest = RuntimeManifest::from_yaml_str(
9005 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
9006 delegation_deadline_secs: 30\n delegation_deadline: 30\n",
9007 )
9008 .unwrap();
9009 let reported: Vec<(String, String)> = manifest
9010 .unknown_keys
9011 .iter()
9012 .map(|key| (key.key.clone(), key.block_path.clone()))
9013 .collect();
9014 assert_eq!(
9015 reported,
9016 vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
9017 "the declared key is known and only the misspelt neighbour is reported"
9018 );
9019 assert_eq!(
9020 manifest.lifecycle.unwrap().delegation_deadline_secs,
9021 30,
9022 "the misspelt neighbour does not disturb the declared value"
9023 );
9024 }
9025
9026 #[test]
9029 fn lifecycle_shell_grace_secs_zero_is_accepted() {
9030 let manifest = RuntimeManifest::from_yaml_str(
9031 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 0\n",
9032 )
9033 .unwrap();
9034 let lifecycle = manifest.lifecycle.unwrap();
9035 assert_eq!(lifecycle.shell_grace_secs, 0);
9036 assert_eq!(
9037 lifecycle.max_task_reopens,
9038 LifecycleConfig::default().max_task_reopens
9039 );
9040 }
9041
9042 #[test]
9043 fn lifecycle_max_task_reopens_defaults_to_1() {
9044 let manifest = RuntimeManifest::from_yaml_str(
9045 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n after_task: sleep\n",
9046 )
9047 .unwrap();
9048 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
9049 }
9050
9051 #[test]
9053 fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
9054 let manifest =
9055 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
9056 assert!(manifest.lifecycle.is_none());
9057 assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
9058 }
9059
9060 #[test]
9061 fn lifecycle_max_task_reopens_explicit_value() {
9062 let manifest = RuntimeManifest::from_yaml_str(
9063 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 3\n",
9064 )
9065 .unwrap();
9066 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
9067 }
9068
9069 #[test]
9071 fn lifecycle_max_task_reopens_zero_is_accepted() {
9072 let manifest = RuntimeManifest::from_yaml_str(
9073 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 0\n",
9074 )
9075 .unwrap();
9076 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
9077 }
9078
9079 #[test]
9081 fn lifecycle_max_task_reopens_with_process_transport() {
9082 let manifest = RuntimeManifest::from_yaml_str(
9083 "name: cap\nversion: 0.0.1\nartifacts:\n - name: fixture-process-driver\n version: 0.1.0\n runtime: driver\ninference:\n transport: process\n driver:\n artifact: fixture-process-driver\nlifecycle:\n max_task_reopens: 2\n",
9084 ).unwrap();
9085 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
9086 }
9087
9088 #[test]
9089 fn inference_max_task_reopens_is_rejected_under_http_transport() {
9090 let err = RuntimeManifest::from_yaml_str(
9091 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\ninference:\n model: test-model\n max_task_reopens: 3\n driver:\n artifact: murmur-driver-anthropic\n",
9092 ).unwrap_err();
9093 assert!(
9094 matches!(
9095 &err,
9096 RuntimeManifestError::InvalidInferenceConfig { field, .. }
9097 if field == "inference.max_task_reopens"
9098 ),
9099 "error was: {err:?}"
9100 );
9101 let msg = err.to_string();
9102 assert!(
9103 msg.contains("lifecycle.max_task_reopens"),
9104 "error was: {msg}"
9105 );
9106 }
9107
9108 #[test]
9109 fn inference_max_task_reopens_is_rejected_under_process_transport() {
9110 let err = RuntimeManifest::from_yaml_str(
9111 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n max_task_reopens: 2\n",
9112 ).unwrap_err();
9113 assert!(
9114 matches!(
9115 &err,
9116 RuntimeManifestError::InvalidInferenceConfig { field, .. }
9117 if field == "inference.max_task_reopens"
9118 ),
9119 "error was: {err:?}"
9120 );
9121 let msg = err.to_string();
9122 assert!(
9123 msg.contains("lifecycle.max_task_reopens"),
9124 "error was: {msg}"
9125 );
9126 }
9127
9128 #[test]
9130 fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
9131 let err = RuntimeManifest::from_yaml_str(
9132 "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",
9133 ).unwrap_err();
9134 assert!(
9135 matches!(
9136 &err,
9137 RuntimeManifestError::InvalidInferenceConfig { field, .. }
9138 if field == "inference.max_task_reopens"
9139 ),
9140 "error was: {err:?}"
9141 );
9142 }
9143
9144 #[test]
9145 fn skill_is_llm_visible() {
9146 assert!(ArtifactRuntime::Skill.is_llm_visible());
9147 }
9148
9149 #[test]
9150 fn driver_and_hook_are_not_llm_visible() {
9151 assert!(!ArtifactRuntime::Driver.is_llm_visible());
9152 assert!(!ArtifactRuntime::Hook.is_llm_visible());
9153 }
9154
9155 #[test]
9156 fn parses_system_prompt_artifact() {
9157 let manifest = RuntimeManifest::from_yaml_str(
9158 r#"
9159name: cap
9160version: 0.0.1
9161artifacts:
9162 - name: my-skill
9163 version: 1.0.0
9164 runtime: skill
9165 - name: murmur-driver-anthropic
9166 version: 0.1.0
9167 runtime: driver
9168 gateway:
9169 endpoint: http://127.0.0.1:8080
9170 keyless: true
9171inference:
9172 model: test-model
9173 system_prompt_artifact: my-skill
9174 driver:
9175 artifact: murmur-driver-anthropic
9176"#,
9177 )
9178 .unwrap();
9179
9180 let inference = manifest.inference.unwrap();
9181 assert_eq!(
9182 inference.system_prompt_artifact,
9183 Some("my-skill".to_string())
9184 );
9185 assert!(inference.system_prompt.is_none());
9186 assert!(inference.system_prompt_file.is_none());
9187 }
9188
9189 #[test]
9190 fn rejects_system_prompt_artifact_with_inline_prompt() {
9191 let err = RuntimeManifest::from_yaml_str(
9192 r#"
9193name: cap
9194version: 0.0.1
9195artifacts:
9196 - name: my-skill
9197 version: 1.0.0
9198 runtime: skill
9199 - name: murmur-driver-anthropic
9200 version: 0.1.0
9201 runtime: driver
9202 gateway:
9203 endpoint: http://127.0.0.1:8080
9204 keyless: true
9205inference:
9206 model: test-model
9207 system_prompt: "inline"
9208 system_prompt_artifact: my-skill
9209 driver:
9210 artifact: murmur-driver-anthropic
9211"#,
9212 )
9213 .unwrap_err();
9214
9215 let msg = err.to_string();
9216 assert!(msg.contains("at most one"), "error was: {msg}");
9217 }
9218
9219 #[test]
9220 fn rejects_system_prompt_artifact_naming_non_skill() {
9221 let err = RuntimeManifest::from_yaml_str(
9222 r#"
9223name: cap
9224version: 0.0.1
9225artifacts:
9226 - name: my-tool
9227 version: 1.0.0
9228 runtime: tool
9229 - name: murmur-driver-anthropic
9230 version: 0.1.0
9231 runtime: driver
9232 gateway:
9233 endpoint: http://127.0.0.1:8080
9234 keyless: true
9235inference:
9236 model: test-model
9237 system_prompt_artifact: my-tool
9238 driver:
9239 artifact: murmur-driver-anthropic
9240"#,
9241 )
9242 .unwrap_err();
9243
9244 let msg = err.to_string();
9245 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
9246 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
9247 }
9248
9249 #[test]
9250 fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
9251 let err = RuntimeManifest::from_yaml_str(
9252 r#"
9253name: cap
9254version: 0.0.1
9255artifacts:
9256 - name: murmur-driver-anthropic
9257 version: 0.1.0
9258 runtime: driver
9259 gateway:
9260 endpoint: http://127.0.0.1:8080
9261 keyless: true
9262inference:
9263 model: test-model
9264 system_prompt_artifact: missing-skill
9265 driver:
9266 artifact: murmur-driver-anthropic
9267"#,
9268 )
9269 .unwrap_err();
9270
9271 let msg = err.to_string();
9272 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
9273 assert!(msg.contains("not declared"), "error was: {msg}");
9274 }
9275
9276 #[test]
9277 fn rejects_all_three_system_prompt_sources() {
9278 let err = RuntimeManifest::from_yaml_str(
9279 r#"
9280name: cap
9281version: 0.0.1
9282artifacts:
9283 - name: my-skill
9284 version: 1.0.0
9285 runtime: skill
9286 - name: murmur-driver-anthropic
9287 version: 0.1.0
9288 runtime: driver
9289 gateway:
9290 endpoint: http://127.0.0.1:8080
9291 keyless: true
9292inference:
9293 model: test-model
9294 system_prompt: "inline"
9295 system_prompt_file: "conventions.md"
9296 system_prompt_artifact: my-skill
9297 driver:
9298 artifact: murmur-driver-anthropic
9299"#,
9300 )
9301 .unwrap_err();
9302
9303 let msg = err.to_string();
9304 assert!(msg.contains("at most one"), "error was: {msg}");
9305 }
9306
9307 #[test]
9310 fn hook_artifact_capabilities_parse_network_and_filesystem() {
9311 let manifest = RuntimeManifest::from_yaml_str(
9312 r#"
9313name: cap
9314version: 0.0.1
9315artifacts:
9316 - name: telemetry-hook
9317 version: 1.0.0
9318 runtime: hook
9319 capabilities:
9320 network:
9321 allow:
9322 - https://telemetry.example.com
9323 filesystem:
9324 scope: hook-state
9325"#,
9326 )
9327 .unwrap();
9328
9329 let caps = manifest.artifacts[0]
9330 .capabilities
9331 .as_ref()
9332 .expect("hook entry carries a capability grant");
9333 assert_eq!(
9334 caps.network.as_ref().unwrap().allow,
9335 vec!["https://telemetry.example.com".to_string()]
9336 );
9337 assert_eq!(
9338 caps.filesystem.as_ref().unwrap().scope,
9339 Some("hook-state".to_string())
9340 );
9341 }
9342
9343 #[test]
9346 fn hook_artifact_without_capabilities_is_none() {
9347 let manifest = RuntimeManifest::from_yaml_str(
9348 r#"
9349name: cap
9350version: 0.0.1
9351artifacts:
9352 - name: quiet-hook
9353 version: 1.0.0
9354 runtime: hook
9355"#,
9356 )
9357 .unwrap();
9358
9359 assert!(manifest.artifacts[0].capabilities.is_none());
9360 }
9361
9362 #[test]
9365 fn tool_and_driver_artifact_capabilities_parse() {
9366 for role in ["tool", "driver"] {
9367 let yaml = format!(
9368 r#"
9369name: cap
9370version: 0.0.1
9371artifacts:
9372 - name: scoped
9373 version: 1.0.0
9374 runtime: {role}
9375 capabilities:
9376 network:
9377 allow:
9378 - https://api.example.com
9379 filesystem:
9380 scope: cache
9381"#
9382 );
9383 let manifest = RuntimeManifest::from_yaml_str(&yaml)
9384 .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
9385
9386 let caps = manifest.artifacts[0]
9387 .capabilities
9388 .as_ref()
9389 .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
9390 assert_eq!(
9391 caps.network.as_ref().unwrap().allow,
9392 vec!["https://api.example.com".to_string()]
9393 );
9394 assert_eq!(
9395 caps.filesystem.as_ref().unwrap().scope,
9396 Some("cache".to_string())
9397 );
9398 }
9399 }
9400
9401 #[test]
9404 fn tool_artifact_without_capabilities_is_none() {
9405 let manifest = RuntimeManifest::from_yaml_str(
9406 r#"
9407name: cap
9408version: 0.0.1
9409artifacts:
9410 - name: plain-tool
9411 version: 1.0.0
9412 runtime: tool
9413"#,
9414 )
9415 .unwrap();
9416
9417 assert!(manifest.artifacts[0].capabilities.is_none());
9418 }
9419
9420 #[test]
9423 fn skill_artifact_capabilities_are_rejected() {
9424 let yaml = r#"
9425name: cap
9426version: 0.0.1
9427artifacts:
9428 - name: sneaky
9429 version: 1.0.0
9430 runtime: skill
9431 capabilities:
9432 network:
9433 allow:
9434 - https://evil.example.com
9435"#;
9436 let err = match RuntimeManifest::from_yaml_str(yaml) {
9437 Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
9438 Err(err) => err,
9439 };
9440 let msg = err.to_string();
9441 assert!(msg.contains("sneaky"), "error was: {msg}");
9442 assert!(
9443 msg.contains(
9444 "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
9445 entries"
9446 ),
9447 "error was: {msg}"
9448 );
9449 assert!(msg.contains("runtime: skill"), "error was: {msg}");
9450 }
9451
9452 #[test]
9457 fn artifact_config_is_carried_verbatim() {
9458 let manifest = RuntimeManifest::from_yaml_str(
9459 r#"
9460name: cap
9461version: 0.0.1
9462artifacts:
9463 - name: murmur-tool-corpus
9464 version: 0.1.0
9465 runtime: tool
9466 config:
9467 read_recent: { default: 20, max: 100 }
9468"#,
9469 )
9470 .unwrap();
9471
9472 let config = manifest.artifacts[0]
9473 .config
9474 .as_ref()
9475 .expect("a declared block parses");
9476 assert_eq!(
9477 config["read_recent"]["max"],
9478 serde_yaml::Value::Number(100.into())
9479 );
9480 }
9481
9482 #[test]
9484 fn an_undeclared_config_is_none() {
9485 let manifest = RuntimeManifest::from_yaml_str(
9486 r#"
9487name: cap
9488version: 0.0.1
9489artifacts:
9490 - name: git
9491 version: 1.0.0
9492 runtime: tool
9493"#,
9494 )
9495 .unwrap();
9496
9497 assert!(manifest.artifacts[0].config.is_none());
9498 }
9499
9500 #[test]
9504 fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
9505 let manifest = RuntimeManifest::from_yaml_str(
9506 r#"
9507name: cap
9508version: 0.0.1
9509artifacts:
9510 - name: git
9511 version: 1.0.0
9512 runtime: tool
9513 config:
9514"#,
9515 )
9516 .unwrap();
9517
9518 assert_eq!(
9519 manifest.artifacts[0].config,
9520 Some(serde_yaml::Value::Null),
9521 "an empty block must survive parsing as a declaration, not vanish into None"
9522 );
9523 }
9524
9525 #[test]
9528 fn skill_artifact_config_is_rejected() {
9529 let yaml = r#"
9530name: cap
9531version: 0.0.1
9532artifacts:
9533 - name: sneaky
9534 version: 1.0.0
9535 runtime: skill
9536 config:
9537 who: me
9538"#;
9539 let err = match RuntimeManifest::from_yaml_str(yaml) {
9540 Ok(_) => panic!("config: must be rejected for runtime: skill"),
9541 Err(err) => err,
9542 };
9543 assert!(matches!(
9544 err,
9545 RuntimeManifestError::InvalidArtifact { index: 0, .. }
9546 ));
9547 let msg = err.to_string();
9548 assert!(msg.contains("sneaky"), "error was: {msg}");
9549 assert!(msg.contains("runtime: skill"), "error was: {msg}");
9550 assert!(
9551 msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
9552 "error was: {msg}"
9553 );
9554 }
9555
9556 #[test]
9559 fn a_capsule_wide_config_block_is_rejected() {
9560 let yaml = r#"
9561name: cap
9562version: 0.0.1
9563config:
9564 who: capsule
9565artifacts:
9566 - name: git
9567 version: 1.0.0
9568 runtime: tool
9569"#;
9570 let err = match RuntimeManifest::from_yaml_str(yaml) {
9571 Ok(_) => panic!("a capsule-wide config block must be rejected"),
9572 Err(err) => err,
9573 };
9574 let msg = err.to_string();
9575 assert!(msg.contains("config"), "error was: {msg}");
9576 assert!(msg.contains("declared per artifact"), "error was: {msg}");
9577 }
9578
9579 #[test]
9583 fn hook_on_overflow_block_parses() {
9584 let manifest = RuntimeManifest::from_yaml_str(
9585 r#"
9586name: cap
9587version: 0.0.1
9588artifacts:
9589 - name: murmur-hook-grafana
9590 version: 1.0.0
9591 runtime: hook
9592 on_overflow: block
9593"#,
9594 )
9595 .unwrap();
9596
9597 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
9598 }
9599
9600 #[test]
9603 fn hook_on_overflow_defaults_to_drop() {
9604 let manifest = RuntimeManifest::from_yaml_str(
9605 r#"
9606name: cap
9607version: 0.0.1
9608artifacts:
9609 - name: murmur-hook-grafana
9610 version: 1.0.0
9611 runtime: hook
9612"#,
9613 )
9614 .unwrap();
9615
9616 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
9617 assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
9618 }
9619
9620 #[test]
9623 fn on_overflow_on_a_non_hook_artifact_is_rejected() {
9624 let yaml = r#"
9625name: cap
9626version: 0.0.1
9627artifacts:
9628 - name: git
9629 version: 1.0.0
9630 runtime: tool
9631 on_overflow: block
9632"#;
9633 let err = match RuntimeManifest::from_yaml_str(yaml) {
9634 Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
9635 Err(err) => err,
9636 };
9637 assert!(matches!(
9638 err,
9639 RuntimeManifestError::InvalidArtifact { index: 0, .. }
9640 ));
9641 let msg = err.to_string();
9642 assert!(msg.contains("git"), "error was: {msg}");
9643 assert!(
9644 msg.contains("only recognized on 'runtime: hook' entries"),
9645 "error was: {msg}"
9646 );
9647 }
9648
9649 #[test]
9651 fn unknown_on_overflow_value_is_rejected() {
9652 let yaml = r#"
9653name: cap
9654version: 0.0.1
9655artifacts:
9656 - name: murmur-hook-grafana
9657 version: 1.0.0
9658 runtime: hook
9659 on_overflow: spill
9660"#;
9661 let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
9662 let msg = err.to_string();
9663 assert!(msg.contains("spill"), "error was: {msg}");
9664 assert!(msg.contains("expected: drop, block"), "error was: {msg}");
9665 }
9666
9667 #[test]
9670 fn on_overflow_is_accepted_on_any_hook_entry() {
9671 let manifest = RuntimeManifest::from_yaml_str(
9672 r#"
9673name: cap
9674version: 0.0.1
9675artifacts:
9676 - name: murmur-hook-eval
9677 version: 1.0.0
9678 runtime: hook
9679 on_overflow: drop
9680 capabilities:
9681 network:
9682 allow:
9683 - https://telemetry.example.com
9684"#,
9685 )
9686 .unwrap();
9687
9688 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
9689 assert!(manifest.artifacts[0].capabilities.is_some());
9690 }
9691
9692 #[test]
9695 fn capsule_wide_and_hook_capabilities_coexist() {
9696 let manifest = RuntimeManifest::from_yaml_str(
9697 r#"
9698name: cap
9699version: 0.0.1
9700artifacts:
9701 - name: telemetry-hook
9702 version: 1.0.0
9703 runtime: hook
9704 capabilities:
9705 network:
9706 allow:
9707 - https://telemetry.example.com
9708capabilities:
9709 network:
9710 allow:
9711 - https://api.anthropic.com
9712"#,
9713 )
9714 .unwrap();
9715
9716 assert_eq!(
9717 manifest.capabilities.unwrap().network.unwrap().allow,
9718 vec!["https://api.anthropic.com".to_string()]
9719 );
9720 assert_eq!(
9721 manifest.artifacts[0]
9722 .capabilities
9723 .as_ref()
9724 .unwrap()
9725 .network
9726 .as_ref()
9727 .unwrap()
9728 .allow,
9729 vec!["https://telemetry.example.com".to_string()]
9730 );
9731 }
9732
9733 #[test]
9737 fn hook_own_manifest_capabilities_are_not_read_into_config() {
9738 let config = parse_hook_config_from_yaml(
9739 r#"
9740name: telemetry-hook
9741version: 1.0.0
9742binding: on-inference
9743execution_mode: blocking
9744capabilities:
9745 network:
9746 allow:
9747 - https://evil.example.com
9748 filesystem:
9749 scope: /
9750"#,
9751 )
9752 .expect("a self-declared capabilities block is ignored, not an error");
9753
9754 assert_eq!(
9755 config,
9756 HookConfig {
9757 binding: HookBinding::OnInference,
9758 execution_mode: HookExecutionMode::Blocking,
9759 commit_policy: HookCommitPolicy::None,
9760 }
9761 );
9762 }
9763
9764 #[test]
9767 fn containment_classes_order_weakest_to_strongest() {
9768 assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
9769 assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
9770 assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
9771 assert_eq!(
9772 ContainmentClass::ALL.iter().copied().max(),
9773 Some(ContainmentClass::Sealed)
9774 );
9775 }
9776
9777 #[test]
9778 fn containment_class_defaults_to_advisory() {
9779 assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
9780 }
9781
9782 #[test]
9783 fn containment_class_round_trips_through_string() {
9784 for class in ContainmentClass::ALL {
9785 assert_eq!(
9786 class.to_string().parse::<ContainmentClass>(),
9787 Ok(class),
9788 "{class} must survive Display -> FromStr"
9789 );
9790 }
9791 assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
9792 assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
9793 assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
9794 }
9795
9796 #[test]
9797 fn containment_class_rejects_unknown_names() {
9798 let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
9799 assert_eq!(err.value, "paranoid");
9800 assert_eq!(
9801 err.to_string(),
9802 "must be one of: advisory, scoped, sealed; got 'paranoid'"
9803 );
9804 assert!("Sealed".parse::<ContainmentClass>().is_err());
9806 }
9807
9808 #[test]
9809 fn containment_class_serializes_as_its_wire_name() {
9810 assert_eq!(
9811 serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
9812 "\"scoped\""
9813 );
9814 assert_eq!(
9815 serde_yaml::from_str::<ContainmentClass>("sealed").unwrap(),
9816 ContainmentClass::Sealed
9817 );
9818 }
9819
9820 #[test]
9821 fn effective_floor_is_advisory_when_nothing_is_declared() {
9822 assert_eq!(
9823 effective_containment_floor(None, None, None),
9824 ContainmentClass::Advisory
9825 );
9826 }
9827
9828 #[test]
9829 fn effective_floor_takes_the_strongest_across_every_presence_combination() {
9830 use ContainmentClass::{Advisory, Scoped, Sealed};
9831
9832 assert_eq!(
9834 effective_containment_floor(Some(Scoped), None, None),
9835 Scoped
9836 );
9837 assert_eq!(
9838 effective_containment_floor(None, Some(Scoped), None),
9839 Scoped
9840 );
9841 assert_eq!(
9842 effective_containment_floor(None, None, Some(Scoped)),
9843 Scoped
9844 );
9845 assert_eq!(
9846 effective_containment_floor(Some(Sealed), None, None),
9847 Sealed
9848 );
9849 assert_eq!(
9850 effective_containment_floor(None, Some(Sealed), None),
9851 Sealed
9852 );
9853 assert_eq!(
9854 effective_containment_floor(None, None, Some(Sealed)),
9855 Sealed
9856 );
9857
9858 assert_eq!(
9861 effective_containment_floor(Some(Advisory), Some(Sealed), None),
9862 Sealed
9863 );
9864 assert_eq!(
9865 effective_containment_floor(Some(Sealed), Some(Advisory), None),
9866 Sealed
9867 );
9868 assert_eq!(
9869 effective_containment_floor(Some(Advisory), None, Some(Scoped)),
9870 Scoped
9871 );
9872 assert_eq!(
9873 effective_containment_floor(Some(Scoped), None, Some(Advisory)),
9874 Scoped
9875 );
9876 assert_eq!(
9877 effective_containment_floor(None, Some(Advisory), Some(Scoped)),
9878 Scoped
9879 );
9880 assert_eq!(
9881 effective_containment_floor(None, Some(Scoped), Some(Advisory)),
9882 Scoped
9883 );
9884
9885 assert_eq!(
9887 effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
9888 Sealed
9889 );
9890 assert_eq!(
9891 effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
9892 Sealed
9893 );
9894 assert_eq!(
9895 effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
9896 Sealed
9897 );
9898 assert_eq!(
9899 effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
9900 Scoped
9901 );
9902 assert_eq!(
9903 effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
9904 Advisory
9905 );
9906 }
9907
9908 #[test]
9909 fn manifest_parses_each_declared_containment_class() {
9910 for class in ContainmentClass::ALL {
9911 let manifest = RuntimeManifest::from_yaml_str(&format!(
9912 r#"
9913name: cap
9914version: 0.0.1
9915capabilities:
9916 containment: {class}
9917"#
9918 ))
9919 .expect("a valid containment class parses");
9920
9921 assert_eq!(
9922 manifest.capabilities.unwrap().containment,
9923 Some(class),
9924 "capabilities.containment: {class}"
9925 );
9926 }
9927 }
9928
9929 #[test]
9930 fn manifest_without_containment_key_declares_nothing() {
9931 let manifest = RuntimeManifest::from_yaml_str(
9933 r#"
9934name: cap
9935version: 0.0.1
9936"#,
9937 )
9938 .unwrap();
9939 assert!(manifest.capabilities.is_none());
9940
9941 let manifest = RuntimeManifest::from_yaml_str(
9944 r#"
9945name: cap
9946version: 0.0.1
9947capabilities:
9948 network:
9949 allow:
9950 - https://api.example.com
9951"#,
9952 )
9953 .unwrap();
9954 assert_eq!(manifest.capabilities.unwrap().containment, None);
9955 }
9956
9957 #[test]
9958 fn manifest_rejects_an_unknown_containment_class() {
9959 let err = RuntimeManifest::from_yaml_str(
9960 r#"
9961name: cap
9962version: 0.0.1
9963capabilities:
9964 containment: paranoid
9965"#,
9966 )
9967 .expect_err("an unknown containment class is rejected at parse time");
9968
9969 match err {
9970 RuntimeManifestError::InvalidCapabilities { field, message } => {
9971 assert_eq!(field, "capabilities.containment");
9972 assert_eq!(
9973 message,
9974 "must be one of: advisory, scoped, sealed; got 'paranoid'"
9975 );
9976 }
9977 other => panic!("expected InvalidCapabilities, got {other:?}"),
9978 }
9979 }
9980
9981 fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
9984 RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
9985 }
9986
9987 fn export_error(block: &str) -> (String, String) {
9988 match exports_manifest(block).expect_err("this exports block must not parse") {
9989 RuntimeManifestError::InvalidExports { field, message } => (field, message),
9990 other => panic!("expected InvalidExports, got {other:?}"),
9991 }
9992 }
9993
9994 fn peer_fetch_error(block: &str) -> (String, String) {
9995 match exports_manifest(block).expect_err("this capabilities block must not parse") {
9996 RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
9997 other => panic!("expected InvalidCapabilities, got {other:?}"),
9998 }
9999 }
10000
10001 #[test]
10004 fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
10005 assert_eq!(parse_duration_secs("90").unwrap(), 90);
10006 assert_eq!(parse_duration_secs("30s").unwrap(), 30);
10007 assert_eq!(parse_duration_secs("15m").unwrap(), 900);
10008 assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
10009 assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
10010 assert_eq!(parse_duration_secs(" 2h ").unwrap(), 7200);
10011 assert_eq!(parse_duration_secs("0").unwrap(), 0);
10012 }
10013
10014 #[test]
10017 fn parse_duration_secs_rejects_every_other_spelling() {
10018 for input in [
10019 "",
10020 " ",
10021 "5 minutes",
10022 "30S",
10023 "1H",
10024 "5min",
10025 "1.5h",
10026 "-1",
10027 "m",
10028 "h",
10029 "1h30m",
10030 "0x10",
10031 "30 s",
10032 ] {
10033 let error = parse_duration_secs(input)
10034 .expect_err(&format!("'{input}' must not parse as a duration"));
10035 assert!(
10036 error.contains(DURATION_ACCEPTED_FORM),
10037 "'{input}' must be refused with the accepted form; got: {error}"
10038 );
10039 }
10040 }
10041
10042 #[test]
10043 fn parse_duration_secs_refuses_to_overflow() {
10044 assert!(parse_duration_secs("18446744073709551615h")
10045 .unwrap_err()
10046 .contains("overflows"));
10047 }
10048
10049 #[test]
10050 fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
10051 let manifest = exports_manifest("exports:\n peer_files:\n root: out/\n")
10052 .expect("a minimal peer_files block parses");
10053 let peer = manifest.exports.unwrap().peer_files.unwrap();
10054 assert_eq!(peer.root, "out/");
10055 assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
10056 assert_eq!(peer.max_bytes, 10_485_760);
10057 assert_eq!(peer.max_ttl_secs, None);
10060 assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
10061 assert_eq!(peer.effective_max_ttl_secs(), 3600);
10062 }
10063
10064 #[test]
10065 fn peer_files_accepts_every_duration_spelling() {
10066 for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
10067 let manifest = exports_manifest(&format!(
10068 "exports:\n peer_files:\n root: out/\n max_ttl: {declared}\n"
10069 ))
10070 .expect("a declared max_ttl parses");
10071 let peer = manifest.exports.unwrap().peer_files.unwrap();
10072 assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
10073 assert_eq!(peer.effective_max_ttl_secs(), expected);
10074 }
10075 }
10076
10077 #[test]
10080 fn the_two_export_blocks_are_independent() {
10081 let both = exports_manifest(
10082 "exports:\n files:\n root: out/\n mode: read-only\n \
10083 peer_files:\n root: out/handoff/\n",
10084 )
10085 .expect("both blocks parse together")
10086 .exports
10087 .unwrap();
10088 assert_eq!(both.files.unwrap().root, "out/");
10089 assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
10090
10091 let only_files =
10092 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
10093 .unwrap()
10094 .exports
10095 .unwrap();
10096 assert!(only_files.peer_files.is_none());
10097
10098 let only_peer = exports_manifest("exports:\n peer_files:\n root: out/\n")
10099 .unwrap()
10100 .exports
10101 .unwrap();
10102 assert!(only_peer.files.is_none());
10103
10104 let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
10105 assert!(neither.files.is_none() && neither.peer_files.is_none());
10106 }
10107
10108 #[test]
10109 fn peer_files_root_must_be_relative_and_inside_the_workdir() {
10110 for root in ["../out", "/etc", "out/../../etc"] {
10111 let (field, message) =
10112 export_error(&format!("exports:\n peer_files:\n root: {root}\n"));
10113 assert_eq!(field, "exports.peer_files.root", "root: {root}");
10114 assert!(
10115 message.contains(EXPORT_ROOT_ACCEPTED_FORM),
10116 "root: {root}; got: {message}"
10117 );
10118 }
10119 }
10120
10121 #[test]
10122 fn peer_files_root_is_required() {
10123 let (field, message) = export_error("exports:\n peer_files: {}\n");
10124 assert_eq!(field, "exports.peer_files.root");
10125 assert!(message.contains("is required"));
10126 assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
10127 }
10128
10129 #[test]
10130 fn peer_files_max_ttl_states_its_accepted_form() {
10131 for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
10132 let (field, message) = export_error(&format!(
10133 "exports:\n peer_files:\n root: out/\n max_ttl: \"{declared}\"\n"
10134 ));
10135 assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
10136 assert!(
10137 message.contains(DURATION_ACCEPTED_FORM),
10138 "max_ttl: {declared}; got: {message}"
10139 );
10140 }
10141 }
10142
10143 #[test]
10146 fn peer_files_max_ttl_rejects_zero() {
10147 let (field, message) =
10148 export_error("exports:\n peer_files:\n root: out/\n max_ttl: 0\n");
10149 assert_eq!(field, "exports.peer_files.max_ttl");
10150 assert!(message.contains("greater than zero"));
10151 }
10152
10153 #[test]
10154 fn peer_files_max_bytes_states_its_accepted_form() {
10155 let (field, message) =
10156 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 10MB\n");
10157 assert_eq!(field, "exports.peer_files.max_bytes");
10158 assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
10159 }
10160
10161 #[test]
10162 fn peer_files_max_bytes_rejects_zero() {
10163 let (field, message) =
10164 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 0\n");
10165 assert_eq!(field, "exports.peer_files.max_bytes");
10166 assert!(message.contains("greater than zero"));
10167 }
10168
10169 #[test]
10172 fn peer_fetch_allow_carries_its_declared_destinations() {
10173 let manifest = exports_manifest(
10174 "capabilities:\n peer_fetch:\n allow:\n - localhost:41234\n \
10175 - reporting.internal\n - https://gateway.example.com\n",
10176 )
10177 .expect("a peer_fetch block parses");
10178 assert_eq!(
10179 manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
10180 vec![
10181 "localhost:41234".to_string(),
10182 "reporting.internal".to_string(),
10183 "https://gateway.example.com".to_string(),
10184 ]
10185 );
10186 }
10187
10188 #[test]
10191 fn peer_fetch_allow_must_not_be_empty() {
10192 let (field, message) = peer_fetch_error("capabilities:\n peer_fetch:\n allow: []\n");
10193 assert_eq!(field, "capabilities.peer_fetch.allow");
10194 assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
10195 }
10196
10197 #[test]
10198 fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
10199 for entry in [
10200 "http://[not a host",
10201 "\"\"",
10202 "localhost:notaport",
10203 "http://example.com/path",
10204 "ftp://example.com",
10205 ] {
10206 let (field, message) = peer_fetch_error(&format!(
10207 "capabilities:\n peer_fetch:\n allow:\n - {entry}\n"
10208 ));
10209 assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
10210 assert!(
10211 message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
10212 "entry: {entry}; got: {message}"
10213 );
10214 }
10215 }
10216
10217 #[test]
10220 fn peer_fetch_does_not_widen_network_allow() {
10221 let capabilities = exports_manifest(
10222 "capabilities:\n network:\n allow:\n - api.example.com\n \
10223 peer_fetch:\n allow:\n - localhost:41234\n",
10224 )
10225 .unwrap()
10226 .capabilities
10227 .unwrap();
10228 assert_eq!(
10229 capabilities.network.unwrap().allow,
10230 vec!["api.example.com".to_string()]
10231 );
10232 assert_eq!(
10233 capabilities.peer_fetch.unwrap().allow,
10234 vec!["localhost:41234".to_string()]
10235 );
10236 }
10237
10238 #[test]
10239 fn an_absent_peer_fetch_block_is_absent() {
10240 assert!(exports_manifest("capabilities: {}\n")
10241 .unwrap()
10242 .capabilities
10243 .unwrap()
10244 .peer_fetch
10245 .is_none());
10246 }
10247
10248 #[test]
10249 fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
10250 assert_eq!(parse_byte_size("0").unwrap(), 0);
10251 assert_eq!(parse_byte_size("4096").unwrap(), 4096);
10252 assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
10253 assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
10254 assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
10255 assert_eq!(parse_byte_size(" 10Mi ").unwrap(), 10 * 1024 * 1024);
10256 }
10257
10258 #[test]
10261 fn parse_byte_size_rejects_every_other_spelling() {
10262 for input in [
10263 "", " ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
10264 "1Ki1",
10265 ] {
10266 let error = parse_byte_size(input)
10267 .expect_err(&format!("'{input}' must not parse as a byte size"));
10268 assert!(
10269 error.contains(BYTE_SIZE_ACCEPTED_FORM),
10270 "'{input}' must be refused with the accepted form; got: {error}"
10271 );
10272 }
10273 }
10274
10275 #[test]
10276 fn parse_byte_size_refuses_to_overflow() {
10277 assert!(parse_byte_size("18446744073709551615Gi")
10278 .unwrap_err()
10279 .contains("overflows"));
10280 }
10281
10282 #[test]
10283 fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
10284 let manifest =
10285 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
10286 .expect("a minimal exports block parses");
10287 let files = manifest.exports.unwrap().files.unwrap();
10288 assert_eq!(files.root, "out/");
10289 assert_eq!(files.mode, ExportMode::ReadOnly);
10290 assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
10291 assert_eq!(files.max_bytes, 10_485_760);
10292 }
10293
10294 #[test]
10295 fn exports_files_accepts_both_byte_spellings() {
10296 for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
10297 let manifest = exports_manifest(&format!(
10298 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: {declared}\n"
10299 ))
10300 .expect("a declared max_bytes parses");
10301 assert_eq!(
10302 manifest.exports.unwrap().files.unwrap().max_bytes,
10303 expected,
10304 "max_bytes: {declared}"
10305 );
10306 }
10307 }
10308
10309 #[test]
10312 fn an_exports_block_without_files_declares_no_plane() {
10313 let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
10314 assert!(manifest.exports.unwrap().files.is_none());
10315 }
10316
10317 #[test]
10318 fn an_absent_exports_block_is_absent() {
10319 let manifest = exports_manifest("").expect("a manifest without exports parses");
10320 assert!(manifest.exports.is_none());
10321 }
10322
10323 #[test]
10324 fn exports_files_root_must_be_relative_and_inside_the_workdir() {
10325 for root in ["../out", "/etc", "out/../../etc", "''"] {
10326 let (field, message) = export_error(&format!(
10327 "exports:\n files:\n root: {root}\n mode: read-only\n"
10328 ));
10329 assert_eq!(field, "exports.files.root", "root: {root}");
10330 assert!(
10331 message.contains("must be a relative path inside the workdir"),
10332 "root: {root}; got: {message}"
10333 );
10334 }
10335 }
10336
10337 #[test]
10338 fn exports_files_root_is_required() {
10339 let (field, message) = export_error("exports:\n files:\n mode: read-only\n");
10340 assert_eq!(field, "exports.files.root");
10341 assert_eq!(
10342 message,
10343 "is required and must be a relative path inside the workdir"
10344 );
10345 }
10346
10347 #[test]
10348 fn exports_files_mode_is_required_and_read_only() {
10349 let (field, message) = export_error("exports:\n files:\n root: out/\n");
10350 assert_eq!(field, "exports.files.mode");
10351 assert_eq!(message, "is required and must be 'read-only'");
10352
10353 let (field, message) =
10354 export_error("exports:\n files:\n root: out/\n mode: read-write\n");
10355 assert_eq!(field, "exports.files.mode");
10356 assert_eq!(message, "'read-write' must be 'read-only'");
10357 }
10358
10359 #[test]
10360 fn exports_files_max_bytes_states_its_accepted_form() {
10361 let (field, message) = export_error(
10362 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 10MB\n",
10363 );
10364 assert_eq!(field, "exports.files.max_bytes");
10365 assert_eq!(
10366 message,
10367 "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
10368 );
10369 }
10370
10371 #[test]
10374 fn exports_files_max_bytes_rejects_zero() {
10375 let (field, message) = export_error(
10376 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 0\n",
10377 );
10378 assert_eq!(field, "exports.files.max_bytes");
10379 assert!(message.contains("must be greater than zero"), "{message}");
10380 }
10381
10382 #[test]
10384 fn exports_files_root_accepts_a_current_dir_prefix() {
10385 let manifest =
10386 exports_manifest("exports:\n files:\n root: ./out\n mode: read-only\n")
10387 .expect("./out is a legal root");
10388 assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
10389 }
10390
10391 fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
10394 RuntimeManifest::from_yaml_str(yaml)
10395 .expect("fixture must parse")
10396 .unknown_keys
10397 .into_iter()
10398 .map(|key| (key.key, key.block_path, key.nearest_known))
10399 .collect()
10400 }
10401
10402 #[test]
10405 fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
10406 assert_eq!(
10407 unknown_keys_of(
10408 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n read-only:\n - tests\n"
10409 ),
10410 vec![(
10411 "read-only".to_string(),
10412 "capabilities.filesystem".to_string(),
10413 Some("read_only".to_string())
10414 )]
10415 );
10416 }
10417
10418 #[test]
10422 fn a_manifest_of_recognized_keys_reports_nothing() {
10423 assert!(unknown_keys_of(
10424 r#"
10425name: cap
10426version: 0.1.0
10427mur_version: "0.2.0"
10428artifacts:
10429 - name: notes-tool
10430 version: 0.1.0
10431 runtime: tool
10432 capabilities:
10433 filesystem:
10434 scope: notes
10435 network:
10436 allow:
10437 - example.com:443
10438capabilities:
10439 filesystem:
10440 read_only:
10441 - tests
10442 - bench/fixtures
10443 workdir_exec: false
10444 shell:
10445 allow:
10446 - git
10447 - python3
10448 network:
10449 allow:
10450 - example.com:443
10451 unix_sockets: false
10452"#
10453 )
10454 .is_empty());
10455 }
10456
10457 #[test]
10460 fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
10461 assert_eq!(
10462 unknown_keys_of(
10463 "name: cap\nversion: 0.1.0\nartifacts:\n - name: t\n version: 0.1.0\n \
10464 runtime: tool\n capabilities:\n shell:\n allow:\n - git\n \
10465 allwo:\n - git\n"
10466 ),
10467 vec![(
10468 "allwo".to_string(),
10469 "artifacts[0].capabilities.shell".to_string(),
10470 Some("allow".to_string())
10471 )]
10472 );
10473 }
10474
10475 #[test]
10478 fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
10479 assert_eq!(
10480 unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
10481 vec![("quantum_teleport".to_string(), String::new(), None)]
10482 );
10483 }
10484
10485 #[test]
10490 fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
10491 assert!(unknown_keys_of(
10492 "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
10493 )
10494 .is_empty());
10495 }
10496
10497 #[test]
10499 fn every_build_side_key_is_recognized() {
10500 assert!(unknown_keys_of(
10501 "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
10502 wasm\nrequires_files:\n - tool.wasm\n - assets/logo.png\n"
10503 )
10504 .is_empty());
10505 }
10506
10507 #[test]
10510 fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
10511 assert_eq!(
10512 unknown_keys_of(
10513 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
10514 static\nrequires_files:\n - capsule.wasm\nquantum_teleport: true\n"
10515 ),
10516 vec![("quantum_teleport".to_string(), String::new(), None)]
10517 );
10518 }
10519
10520 #[test]
10524 fn the_build_side_keys_change_nothing_the_run_path_reads() {
10525 let body = "artifacts:\n - name: notes-tool\n version: 0.1.0\n runtime: tool\n\
10526 \x20 - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: \
10527 driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\n\
10528 capabilities:\n filesystem:\n read_only:\n - tests\n\
10529 inference:\n transport: http\n model: test-model\n driver:\n \
10530 artifact: murmur-driver-anthropic\n";
10531 let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
10532 .expect("fixture must parse");
10533 let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
10534 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n \
10535 any: shape\nrequires_files: not-a-list\n{body}"
10536 ))
10537 .expect("a build-side key of any shape never refuses a manifest");
10538
10539 assert!(with_build_keys.unknown_keys.is_empty());
10540 assert!(
10541 !plain.artifacts.is_empty()
10542 && plain.capabilities.is_some()
10543 && plain.inference.is_some(),
10544 "the fixture must populate the blocks the comparison below rests on, or it \
10545 compares absence against absence"
10546 );
10547 assert_eq!(with_build_keys.name, plain.name);
10548 assert_eq!(with_build_keys.version, plain.version);
10549 assert_eq!(with_build_keys.artifacts, plain.artifacts);
10550 assert_eq!(with_build_keys.capabilities, plain.capabilities);
10551 assert_eq!(with_build_keys.inference, plain.inference);
10552 }
10553
10554 #[test]
10557 fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
10558 let manifest = RuntimeManifest::from_yaml_str(
10559 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n scope: notes\n \
10560 read-only:\n - tests\n",
10561 )
10562 .expect("an unrecognized key never refuses a manifest");
10563 assert_eq!(
10564 manifest
10565 .capabilities
10566 .as_ref()
10567 .and_then(|caps| caps.filesystem.as_ref())
10568 .and_then(|fs| fs.scope.as_deref()),
10569 Some("notes")
10570 );
10571 assert_eq!(manifest.unknown_keys.len(), 1);
10572 }
10573
10574 #[test]
10576 fn nested_blocks_outside_capabilities_are_walked_too() {
10577 let keys = unknown_keys_of(
10578 "name: cap\nversion: 0.1.0\ncontext:\n max_tokns: 100\nexports:\n files:\n \
10579 root: out\n mode: read-only\n max_byts: 10\n",
10580 );
10581 assert_eq!(
10582 keys,
10583 vec![
10584 (
10585 "max_tokns".to_string(),
10586 "context".to_string(),
10587 Some("max_tokens".to_string())
10588 ),
10589 (
10590 "max_byts".to_string(),
10591 "exports.files".to_string(),
10592 Some("max_bytes".to_string())
10593 ),
10594 ]
10595 );
10596 }
10597
10598 #[test]
10605 fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
10606 let source = include_str!("runtime_manifest.rs");
10607 let lines: Vec<&str> = source.lines().collect();
10608
10609 let mut checked = 0usize;
10610 for (start, line) in lines.iter().enumerate() {
10611 let Some(name) = line
10612 .strip_prefix("struct Raw")
10613 .and_then(|rest| rest.strip_suffix(" {"))
10614 else {
10615 continue;
10616 };
10617 let name = format!("Raw{name}");
10618 let end = (start + 1..lines.len())
10619 .find(|index| lines[*index] == "}")
10620 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
10621 let body = &lines[start + 1..end];
10622
10623 assert!(
10624 body.contains(&" #[serde(flatten)]")
10625 && body.contains(&" unknown: UnknownKeys,"),
10626 "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
10627 so a key it does not recognize would be dropped instead of reported"
10628 );
10629
10630 let declared = serde_field_names(body);
10631 let known = known_keys_of(source, &name);
10632 assert_eq!(
10633 declared, known,
10634 "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
10635 suggester would propose the wrong set for that block"
10636 );
10637 checked += 1;
10638 }
10639 assert!(
10640 checked >= 30,
10641 "expected every Raw* struct to be scanned, found only {checked}"
10642 );
10643 }
10644
10645 #[test]
10651 fn every_raw_struct_descends_into_the_blocks_it_owns() {
10652 let source = include_str!("runtime_manifest.rs");
10653 let lines: Vec<&str> = source.lines().collect();
10654
10655 let mut structs = 0usize;
10656 let mut children = 0usize;
10657 for (start, line) in lines.iter().enumerate() {
10658 let Some(name) = line
10659 .strip_prefix("struct Raw")
10660 .and_then(|rest| rest.strip_suffix(" {"))
10661 else {
10662 continue;
10663 };
10664 let name = format!("Raw{name}");
10665 let end = (start + 1..lines.len())
10666 .find(|index| lines[*index] == "}")
10667 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
10668 let owned = block_typed_fields(&lines[start + 1..end]);
10669 structs += 1;
10670
10671 if owned.is_empty() {
10672 continue;
10673 }
10674 let block = raw_block_impl_of(source, &name);
10675 assert!(
10676 block.contains("fn walk_children"),
10677 "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
10678 `walk_children`, so every key written inside {} would go unreported",
10679 if owned.len() == 1 { "" } else { "s" },
10680 owned.join(", "),
10681 if owned.len() == 1 { "it" } else { "them" }
10682 );
10683 for field in &owned {
10684 assert!(
10685 mentions_field(&block, field),
10686 "{name}'s `walk_children` never touches `self.{field}`, so every key written \
10687 inside that block would go unreported — descend into it with \
10688 `collect_block(.., &child_path(path, \"{field}\"), out)`"
10689 );
10690 children += 1;
10691 }
10692 }
10693 assert!(
10694 structs >= 30,
10695 "expected every Raw* struct to be scanned, found only {structs}"
10696 );
10697 assert!(
10698 children >= 31,
10699 "expected every nested block field to be scanned, found only {children}"
10700 );
10701 }
10702
10703 fn mentions_field(impl_body: &str, field: &str) -> bool {
10707 let needle = format!("self.{field}");
10708 impl_body.match_indices(&needle).any(|(at, _)| {
10709 impl_body[at + needle.len()..]
10710 .chars()
10711 .next()
10712 .is_none_or(|next| !next.is_alphanumeric() && next != '_')
10713 })
10714 }
10715
10716 fn block_typed_fields(body: &[&str]) -> Vec<String> {
10720 let mut fields = Vec::new();
10721 for line in body {
10722 let trimmed = line.trim();
10723 if trimmed.starts_with("//") || trimmed.starts_with("#[") {
10724 continue;
10725 }
10726 let Some((field, declared)) = line
10727 .strip_prefix(" ")
10728 .and_then(|rest| rest.split_once(':'))
10729 else {
10730 continue;
10731 };
10732 if field.is_empty()
10733 || !field
10734 .chars()
10735 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
10736 {
10737 continue;
10738 }
10739 if declared.contains("Raw") {
10740 fields.push(field.to_string());
10741 }
10742 }
10743 fields
10744 }
10745
10746 fn raw_block_impl_of(source: &str, name: &str) -> String {
10749 let header = format!("impl RawBlock for {name} {{");
10750 let body = source
10751 .split_once(&header)
10752 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
10753 .1;
10754 body.split_once("\n}\n")
10755 .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
10756 .0
10757 .to_string()
10758 }
10759
10760 fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
10767 let raw: RawRuntimeManifest = serde_yaml::from_str(yaml).expect("fixture must parse");
10768 collect_unknown_keys(&raw)
10769 .into_iter()
10770 .map(|key| (key.key, key.block_path))
10771 .collect()
10772 }
10773
10774 #[test]
10778 fn every_block_reports_its_own_unknown_key_at_its_own_path() {
10779 let keys = raw_unknown_keys(
10780 r#"
10781name: every-block
10782version: 0.1.0
10783probe_root: 1
10784artifacts:
10785 - name: t
10786 version: 0.1.0
10787 runtime: tool
10788 probe_artifact: 1
10789 capabilities:
10790 probe_artifact_capabilities: 1
10791 shell:
10792 probe_artifact_shell: 1
10793capabilities:
10794 probe_capabilities: 1
10795 network:
10796 probe_network: 1
10797 peer_fetch:
10798 probe_peer_fetch: 1
10799 filesystem:
10800 probe_filesystem: 1
10801 shell:
10802 probe_shell: 1
10803 interpreter_runtime:
10804 - binary: python3
10805 probe_interpreter_runtime: 1
10806 dirs:
10807 - path: /usr/lib/python3
10808 probe_interpreter_runtime_dir: 1
10809 staged_runtime:
10810 - binary: rg
10811 probe_staged_runtime: 1
10812 spawn:
10813 probe_spawn: 1
10814 env:
10815 probe_env: 1
10816 limits:
10817 probe_limits: 1
10818 resources:
10819 probe_resources: 1
10820 state:
10821 probe_state: 1
10822 task_io:
10823 probe_task_io: 1
10824 conversation:
10825 probe_conversation: 1
10826inference:
10827 probe_inference: 1
10828 driver:
10829 probe_driver: 1
10830 provider:
10831 probe_provider: 1
10832 compaction:
10833 probe_compaction: 1
10834context:
10835 probe_context: 1
10836observability:
10837 probe_observability: 1
10838 eval:
10839 probe_eval: 1
10840 scorers:
10841 - type: exact_match
10842 probe_scorer: 1
10843trace:
10844 probe_trace: 1
10845network:
10846 probe_network_config: 1
10847lifecycle:
10848 probe_lifecycle: 1
10849exports:
10850 probe_exports: 1
10851 files:
10852 probe_files: 1
10853 peer_files:
10854 probe_peer_files: 1
10855"#,
10856 );
10857
10858 let expected: Vec<(String, String)> = [
10859 ("probe_root", ""),
10860 ("probe_artifact", "artifacts[0]"),
10861 ("probe_artifact_capabilities", "artifacts[0].capabilities"),
10862 ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
10863 ("probe_capabilities", "capabilities"),
10864 ("probe_network", "capabilities.network"),
10865 ("probe_peer_fetch", "capabilities.peer_fetch"),
10866 ("probe_filesystem", "capabilities.filesystem"),
10867 ("probe_shell", "capabilities.shell"),
10868 (
10869 "probe_interpreter_runtime",
10870 "capabilities.shell.interpreter_runtime[0]",
10871 ),
10872 (
10873 "probe_interpreter_runtime_dir",
10874 "capabilities.shell.interpreter_runtime[0].dirs[0]",
10875 ),
10876 (
10877 "probe_staged_runtime",
10878 "capabilities.shell.staged_runtime[0]",
10879 ),
10880 ("probe_spawn", "capabilities.spawn"),
10881 ("probe_env", "capabilities.env"),
10882 ("probe_limits", "capabilities.limits"),
10883 ("probe_resources", "capabilities.resources"),
10884 ("probe_state", "capabilities.state"),
10885 ("probe_task_io", "capabilities.task_io"),
10886 ("probe_conversation", "capabilities.conversation"),
10887 ("probe_inference", "inference"),
10888 ("probe_driver", "inference.driver"),
10889 ("probe_provider", "inference.provider"),
10890 ("probe_compaction", "inference.compaction"),
10891 ("probe_context", "context"),
10892 ("probe_observability", "observability"),
10893 ("probe_eval", "observability.eval"),
10894 ("probe_scorer", "observability.eval.scorers[0]"),
10895 ("probe_trace", "trace"),
10896 ("probe_network_config", "network"),
10897 ("probe_lifecycle", "lifecycle"),
10898 ("probe_exports", "exports"),
10899 ("probe_files", "exports.files"),
10900 ("probe_peer_files", "exports.peer_files"),
10901 ]
10902 .into_iter()
10903 .map(|(key, path)| (key.to_string(), path.to_string()))
10904 .collect();
10905
10906 assert_eq!(keys, expected);
10907 }
10908
10909 fn serde_field_names(body: &[&str]) -> Vec<String> {
10913 let mut names = Vec::new();
10914 let mut rename: Option<String> = None;
10915 for line in body {
10916 let trimmed = line.trim();
10917 if trimmed.starts_with("#[") {
10918 if let Some(rest) = trimmed.split("rename = \"").nth(1) {
10919 if !trimmed.contains("rename_all") {
10920 rename = rest.split('"').next().map(str::to_string);
10921 }
10922 }
10923 continue;
10924 }
10925 if trimmed.starts_with("//") {
10926 continue;
10927 }
10928 let Some(field) = line
10929 .strip_prefix(" ")
10930 .and_then(|rest| rest.split(':').next())
10931 else {
10932 continue;
10933 };
10934 if field.is_empty()
10935 || !field
10936 .chars()
10937 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
10938 {
10939 continue;
10940 }
10941 if field == "unknown" {
10942 rename = None;
10943 continue;
10944 }
10945 names.push(rename.take().unwrap_or_else(|| field.to_string()));
10946 }
10947 names
10948 }
10949
10950 fn known_keys_of(source: &str, name: &str) -> Vec<String> {
10954 let header = format!("impl RawBlock for {name} {{");
10955 let body = source
10956 .split_once(&header)
10957 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
10958 .1;
10959 let array = body
10960 .split_once("KNOWN_KEYS: &'static [&'static str] = &[")
10961 .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
10962 .1
10963 .split_once("];")
10964 .expect("KNOWN_KEYS array is unterminated")
10965 .0;
10966 array
10967 .split('"')
10968 .skip(1)
10969 .step_by(2)
10970 .map(str::to_string)
10971 .collect()
10972 }
10973}