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 pub fn tasks_held_at_once(&self) -> usize {
100 match self.task_acceptance {
101 TaskAcceptance::None => 0,
102 TaskAcceptance::Single => 1,
103 TaskAcceptance::Queue if self.queue_depth == 0 => 0,
104 TaskAcceptance::Queue => self.queue_depth + 1,
105 }
106 }
107}
108
109impl Default for LifecycleConfig {
110 fn default() -> Self {
111 Self {
112 task_acceptance: TaskAcceptance::Single,
113 after_task: AfterTask::Exit,
114 queue_depth: 1,
115 input_timeout_secs: None,
116 conversation_mode: ConversationMode::Stateless,
117 max_task_reopens: 1,
118 shell_grace_secs: 10,
119 delegation_deadline_secs: 600,
120 }
121 }
122}
123
124#[derive(Debug, Clone, Default)]
127pub struct LifecycleOverride {
128 pub task_acceptance: Option<TaskAcceptance>,
129 pub after_task: Option<AfterTask>,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
150#[serde(rename_all = "lowercase")]
151pub enum ArtifactRuntime {
152 Tool,
153 Driver,
154 Hook,
155 Skill,
156}
157
158impl ArtifactRuntime {
159 pub fn is_llm_visible(&self) -> bool {
165 match self {
166 ArtifactRuntime::Tool => true,
167 ArtifactRuntime::Skill => true,
168 ArtifactRuntime::Driver => false,
169 ArtifactRuntime::Hook => false,
170 }
171 }
172
173 pub fn as_str(&self) -> &'static str {
174 match self {
175 ArtifactRuntime::Tool => "tool",
176 ArtifactRuntime::Driver => "driver",
177 ArtifactRuntime::Hook => "hook",
178 ArtifactRuntime::Skill => "skill",
179 }
180 }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum ArtifactImplementation {
189 Wasm,
190 Native,
191}
192
193pub fn read_tool_implementation(
196 artifact_manifest_path: &Path,
197) -> Result<ArtifactImplementation, RuntimeManifestError> {
198 let content =
199 fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
200 path: artifact_manifest_path.display().to_string(),
201 source,
202 })?;
203 Ok(parse_tool_implementation_from_yaml(&content))
204}
205
206pub fn parse_tool_implementation_from_yaml(yaml: &str) -> ArtifactImplementation {
209 serde_yaml::from_str::<serde_yaml::Value>(yaml)
210 .map(|v| {
211 v.as_mapping()
212 .and_then(|m| m.get(serde_yaml::Value::String("implementation".to_string())))
213 .and_then(serde_yaml::Value::as_str)
214 .map(|s| match s {
215 "native" => ArtifactImplementation::Native,
216 _ => ArtifactImplementation::Wasm,
217 })
218 .unwrap_or(ArtifactImplementation::Wasm)
219 })
220 .unwrap_or(ArtifactImplementation::Wasm)
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
230pub enum HookBinding {
231 OnStage,
233 OnSessionStart,
235 OnTaskStart,
237 OnInference,
239 OnToolCall,
241 OnShell,
243 OnCompaction,
245 OnTaskEnd,
247 OnSessionEnd,
249 All,
251}
252
253impl HookBinding {
254 #[must_use]
259 pub fn as_str(&self) -> &'static str {
260 match self {
261 Self::OnStage => "on-stage",
262 Self::OnSessionStart => "on-session-start",
263 Self::OnTaskStart => "on-task-start",
264 Self::OnInference => "on-inference",
265 Self::OnToolCall => "on-tool-call",
266 Self::OnShell => "on-shell",
267 Self::OnCompaction => "on-compaction",
268 Self::OnTaskEnd => "on-task-end",
269 Self::OnSessionEnd => "on-session-end",
270 Self::All => "all",
271 }
272 }
273}
274
275#[derive(Debug, Clone, PartialEq, Eq)]
280pub enum HookExecutionMode {
281 Blocking,
283 Async,
285}
286
287#[derive(Debug, Clone, PartialEq, Eq)]
292pub enum HookCommitPolicy {
293 None,
295 ReplaceContext,
297 WriteManifests,
299 ReopenTask,
302 SeedContext,
306 Deny,
314}
315
316impl HookCommitPolicy {
317 #[must_use]
319 pub fn as_str(&self) -> &'static str {
320 match self {
321 Self::None => "none",
322 Self::ReplaceContext => "replace-context",
323 Self::WriteManifests => "write-manifests",
324 Self::ReopenTask => "reopen-task",
325 Self::SeedContext => "seed-context",
326 Self::Deny => "deny",
327 }
328 }
329}
330
331#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
341#[serde(rename_all = "snake_case")]
342pub enum HookOverflowPolicy {
343 #[default]
347 Drop,
348 Block,
351}
352
353impl HookOverflowPolicy {
354 #[must_use]
356 pub fn as_str(&self) -> &'static str {
357 match self {
358 Self::Drop => "drop",
359 Self::Block => "block",
360 }
361 }
362}
363
364#[derive(Debug, Clone, PartialEq, Eq)]
366pub struct HookConfig {
367 pub binding: HookBinding,
368 pub execution_mode: HookExecutionMode,
369 pub commit_policy: HookCommitPolicy,
370}
371
372impl Default for HookConfig {
373 fn default() -> Self {
374 Self {
375 binding: HookBinding::All,
376 execution_mode: HookExecutionMode::Blocking,
377 commit_policy: HookCommitPolicy::None,
378 }
379 }
380}
381
382#[must_use]
406pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
407 match binding {
408 HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
409 HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
410 HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
411 HookBinding::OnTaskStart => Some(HookCommitPolicy::SeedContext),
412 HookBinding::OnToolCall | HookBinding::OnShell => Some(HookCommitPolicy::Deny),
413 HookBinding::OnSessionStart
414 | HookBinding::OnInference
415 | HookBinding::OnSessionEnd
416 | HookBinding::All => None,
417 }
418}
419
420pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
422 let content =
423 fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
424 path: artifact_manifest_path.display().to_string(),
425 source,
426 })?;
427 parse_hook_config_from_yaml(&content).map_err(|msg| {
428 RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
429 })
430}
431
432pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
442 let v: serde_yaml::Value =
443 serde_yaml::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
444
445 let binding = v
446 .as_mapping()
447 .and_then(|m| m.get(serde_yaml::Value::String("binding".to_string())))
448 .and_then(serde_yaml::Value::as_str)
449 .map(|s| match s {
450 "on-stage" => Ok(HookBinding::OnStage),
451 "on-session-start" => Ok(HookBinding::OnSessionStart),
452 "on-task-start" => Ok(HookBinding::OnTaskStart),
453 "on-inference" => Ok(HookBinding::OnInference),
454 "on-tool-call" => Ok(HookBinding::OnToolCall),
455 "on-shell" => Ok(HookBinding::OnShell),
456 "on-compaction" => Ok(HookBinding::OnCompaction),
457 "on-task-end" => Ok(HookBinding::OnTaskEnd),
458 "on-session-end" => Ok(HookBinding::OnSessionEnd),
459 other => Err(format!(
460 "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
461 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
462 )),
463 })
464 .transpose()?
465 .unwrap_or(HookBinding::All);
466
467 let execution_mode = v
468 .as_mapping()
469 .and_then(|m| m.get(serde_yaml::Value::String("execution_mode".to_string())))
470 .and_then(serde_yaml::Value::as_str)
471 .map(|s| match s {
472 "blocking" => Ok(HookExecutionMode::Blocking),
473 "async" => Ok(HookExecutionMode::Async),
474 other => Err(format!(
475 "unknown execution_mode '{other}'; expected: blocking, async"
476 )),
477 })
478 .transpose()?
479 .unwrap_or(HookExecutionMode::Blocking);
480
481 let commit_policy = v
482 .as_mapping()
483 .and_then(|m| m.get(serde_yaml::Value::String("commit_policy".to_string())))
484 .and_then(serde_yaml::Value::as_str)
485 .map(|s| match s {
486 "none" => Ok(HookCommitPolicy::None),
487 "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
488 "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
489 "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
490 "seed-context" => Ok(HookCommitPolicy::SeedContext),
491 "deny" => Ok(HookCommitPolicy::Deny),
492 other => Err(format!(
493 "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
494 reopen-task, seed-context, deny"
495 )),
496 })
497 .transpose()?
498 .unwrap_or(HookCommitPolicy::None);
499
500 if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
502 return Err(format!(
503 "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
504 (got '{}')",
505 commit_policy.as_str()
506 ));
507 }
508 if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
509 return Err(
510 "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
511 'on-stage'"
512 .to_string(),
513 );
514 }
515 if binding == HookBinding::All && commit_policy == HookCommitPolicy::Deny {
519 return Err(
520 "commit_policy 'deny' requires an explicit binding: 'on-shell' or 'on-tool-call'; \
521 a hook with no binding: is dispatched at every event, including decision points \
522 it was not written to decide"
523 .to_string(),
524 );
525 }
526 if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
531 let honored = commit_policy_for_binding(&binding);
532 if honored.as_ref() != Some(&commit_policy) {
533 let note = if binding == HookBinding::OnInference {
534 " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
535 } else {
536 ""
537 };
538 return Err(format!(
539 "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
540 commit_policy '{}'{note}",
541 commit_policy.as_str(),
542 binding.as_str(),
543 binding.as_str(),
544 honored.as_ref().map_or("none", HookCommitPolicy::as_str)
545 ));
546 }
547 }
548
549 Ok(HookConfig {
550 binding,
551 execution_mode,
552 commit_policy,
553 })
554}
555
556#[derive(Debug, Clone, PartialEq, Eq)]
557pub struct RuntimeManifest {
558 pub name: String,
559 pub version: String,
560 pub artifacts: Vec<RuntimeArtifact>,
561 pub capabilities: Option<Capabilities>,
562 pub inference: Option<InferenceConfig>,
563 pub context: Option<ContextConfig>,
564 pub observability: Option<ObservabilityConfig>,
565 pub trace: Option<TraceConfig>,
566 pub network: Option<NetworkConfig>,
567 pub lifecycle: Option<LifecycleConfig>,
568 pub exports: Option<Exports>,
571 pub control: Option<ControlConfig>,
575 pub mur_version: Option<String>,
580 pub unknown_keys: Vec<UnknownManifestKey>,
590}
591
592impl RuntimeManifest {
593 pub fn effective_lifecycle(&self) -> LifecycleConfig {
594 self.lifecycle.clone().unwrap_or_default()
595 }
596
597 pub fn accepts_peer_tasks(&self) -> bool {
604 self.exports
605 .as_ref()
606 .is_some_and(Exports::accepts_peer_tasks)
607 }
608}
609
610#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
617pub enum ControllableSetting {
618 InferenceMaxTokens,
621 InferenceDriver,
625}
626
627impl ControllableSetting {
628 pub const ALL: &'static [ControllableSetting] =
630 &[Self::InferenceMaxTokens, Self::InferenceDriver];
631
632 pub const AGENT_SETTABLE: &'static [ControllableSetting] = &[Self::InferenceDriver];
635
636 pub fn wire_name(self) -> &'static str {
638 match self {
639 Self::InferenceMaxTokens => "inference.max_tokens",
640 Self::InferenceDriver => "inference.driver",
641 }
642 }
643
644 pub fn from_wire_name(name: &str) -> Option<Self> {
646 Self::ALL
647 .iter()
648 .copied()
649 .find(|setting| setting.wire_name() == name)
650 }
651}
652
653#[derive(Debug, Clone, PartialEq, Eq, Default)]
658pub struct ControlConfig {
659 pub settings: Vec<ControllableSetting>,
661 pub secrets: Vec<String>,
665 pub agent_settings: Vec<ControllableSetting>,
669}
670
671impl ControlConfig {
672 pub fn declares_secret(&self, name: &str) -> bool {
674 self.secrets.iter().any(|secret| secret == name)
675 }
676
677 pub fn controller_may_set(&self, setting: ControllableSetting) -> bool {
679 self.settings.contains(&setting)
680 }
681
682 pub fn agent_may_set(&self, setting: ControllableSetting) -> bool {
684 self.agent_settings.contains(&setting)
685 }
686}
687
688#[derive(Debug, Clone, PartialEq, Eq)]
691pub struct NetworkConfig {
692 pub internal_port: Option<u16>,
694 pub authentication: Option<NetworkAuthentication>,
697}
698
699pub const DOOR_SCOPES: &[&str] = &[
703 "message/send",
704 "message/stream",
705 "stream/watch",
706 "tasks/get",
707 "tasks/cancel",
708 "session/stop",
709 "resources/files",
710];
711
712pub const OPERATOR_CREDENTIAL: &str = "operator";
715
716const MAX_CREDENTIAL_NAME_LEN: usize = 32;
718
719#[derive(Debug, Clone, Copy, PartialEq, Eq)]
723pub enum AuthenticationScheme {
724 Bearer,
726}
727
728impl AuthenticationScheme {
729 pub const ALL: &'static [AuthenticationScheme] = &[Self::Bearer];
731
732 pub fn wire_name(self) -> &'static str {
734 match self {
735 Self::Bearer => "bearer",
736 }
737 }
738}
739
740#[derive(Debug, Clone, PartialEq, Eq)]
743pub struct DoorCredential {
744 pub name: String,
746 pub scopes: Vec<String>,
748}
749
750#[derive(Debug, Clone, PartialEq, Eq)]
753pub struct NetworkAuthentication {
754 pub scheme: AuthenticationScheme,
755 pub credentials: Vec<DoorCredential>,
757}
758
759pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
763
764#[derive(Debug, Clone, PartialEq, Eq)]
772pub struct Exports {
773 pub files: Option<FileExport>,
776 pub peer_files: Option<PeerFilesExport>,
781 pub peer_tasks: Option<PeerTasksExport>,
785}
786
787impl Exports {
788 pub fn accepts_peer_tasks(&self) -> bool {
794 self.peer_tasks.is_some_and(|peer_tasks| peer_tasks.accept)
795 }
796}
797
798#[derive(Debug, Clone, Copy, PartialEq, Eq)]
809pub struct PeerTasksExport {
810 pub accept: bool,
812}
813
814#[derive(Debug, Clone, PartialEq, Eq)]
816pub struct FileExport {
817 pub root: String,
824 pub mode: ExportMode,
828 pub max_bytes: u64,
832}
833
834pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
837
838pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
847
848pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
850
851#[derive(Debug, Clone, PartialEq, Eq)]
858pub struct PeerFilesExport {
859 pub root: String,
863 pub max_ttl_secs: Option<u64>,
870 pub max_bytes: u64,
874}
875
876impl PeerFilesExport {
877 pub fn effective_max_ttl_secs(&self) -> u64 {
883 self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
884 }
885}
886
887#[derive(Debug, Clone, PartialEq, Eq)]
895pub struct PeerFetchCapabilities {
896 pub allow: Vec<String>,
900}
901
902pub const DURATION_ACCEPTED_FORM: &str =
905 "must be a duration: an integer, optionally suffixed s/m/h/d";
906
907pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
913 let trimmed = input.trim();
914 let (digits, multiplier) = match trimmed.strip_suffix('s') {
915 Some(digits) => (digits, 1u64),
916 None => match trimmed.strip_suffix('m') {
917 Some(digits) => (digits, 60),
918 None => match trimmed.strip_suffix('h') {
919 Some(digits) => (digits, 3600),
920 None => match trimmed.strip_suffix('d') {
921 Some(digits) => (digits, 86_400),
922 None => (trimmed, 1),
923 },
924 },
925 },
926 };
927 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
930 return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
931 }
932 digits
933 .parse::<u64>()
934 .ok()
935 .and_then(|value| value.checked_mul(multiplier))
936 .ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
937}
938
939#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
940pub enum ExportMode {
941 #[serde(rename = "read-only")]
942 ReadOnly,
943}
944
945impl ExportMode {
946 pub fn as_str(self) -> &'static str {
947 match self {
948 ExportMode::ReadOnly => "read-only",
949 }
950 }
951}
952
953impl std::fmt::Display for ExportMode {
954 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
955 f.write_str(self.as_str())
956 }
957}
958
959pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
962
963pub fn parse_byte_size(input: &str) -> Result<u64, String> {
969 let trimmed = input.trim();
970 let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
971 Some(digits) => (digits, 1024u64),
972 None => match trimmed.strip_suffix("Mi") {
973 Some(digits) => (digits, 1024 * 1024),
974 None => match trimmed.strip_suffix("Gi") {
975 Some(digits) => (digits, 1024 * 1024 * 1024),
976 None => (trimmed, 1),
977 },
978 },
979 };
980 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
983 return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
984 }
985 digits
986 .parse::<u64>()
987 .ok()
988 .and_then(|value| value.checked_mul(multiplier))
989 .ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
990}
991
992#[derive(Debug, Clone, PartialEq, Eq)]
993pub struct RuntimeArtifact {
994 pub name: String,
995 pub version: String,
996 pub runtime: ArtifactRuntime,
997 pub source: Option<String>,
1003 pub local_source: bool,
1008 pub prompt_payload: bool,
1013 pub capabilities: Option<Capabilities>,
1032 pub on_overflow: HookOverflowPolicy,
1037 pub config: Option<serde_yaml::Value>,
1054 pub gateway: Option<ArtifactGateway>,
1065}
1066
1067#[derive(Debug, Clone, PartialEq, Eq)]
1071pub struct ArtifactGateway {
1072 pub endpoint: String,
1075 pub api_key: Option<ApiKeyReference>,
1078 pub keyless: bool,
1082}
1083
1084#[derive(Debug, Clone, PartialEq, Eq)]
1085pub struct InferenceDriver {
1086 pub artifact: String,
1087 pub config: Option<String>,
1088}
1089
1090pub const PRIMARY_DRIVER_CHOICE: &str = "primary";
1093
1094#[derive(Debug, Clone, PartialEq, Eq)]
1100pub struct InferenceAlternate {
1101 pub name: String,
1103 pub model: String,
1105 pub driver: String,
1108}
1109
1110#[derive(Clone, PartialEq, Eq)]
1113pub enum ApiKeyReference {
1114 Environment(String),
1117 Literal(String),
1119}
1120
1121impl std::fmt::Debug for ApiKeyReference {
1122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1123 match self {
1124 Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
1125 Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
1126 }
1127 }
1128}
1129
1130#[derive(Debug, Clone, PartialEq, Eq)]
1131pub struct InferenceConfig {
1132 pub transport: String,
1133 pub model: String,
1134 pub driver: Option<InferenceDriver>,
1137 pub command: Option<String>,
1141 pub compaction: Option<CompactionConfig>,
1142 pub system_prompt: Option<String>,
1143 pub system_prompt_file: Option<String>,
1144 pub system_prompt_artifact: Option<String>,
1148 pub max_turns: u32,
1151 pub max_tokens: Option<u32>,
1158 pub max_session_tokens: Option<u64>,
1166 pub tool_refresh: ToolRefresh,
1170 pub alternates: Vec<InferenceAlternate>,
1174}
1175
1176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1183pub enum ToolRefresh {
1184 #[default]
1187 Compaction,
1188 Immediate,
1190}
1191
1192impl ToolRefresh {
1193 pub const ALL: [ToolRefresh; 2] = [Self::Compaction, Self::Immediate];
1195
1196 #[must_use]
1198 pub fn wire_name(self) -> &'static str {
1199 match self {
1200 Self::Compaction => "compaction",
1201 Self::Immediate => "immediate",
1202 }
1203 }
1204}
1205
1206#[derive(Debug, Clone, PartialEq)]
1207pub struct CompactionConfig {
1208 pub threshold: Option<f32>,
1210 pub model: Option<String>,
1212 pub system_prompt: Option<String>,
1215 pub system_prompt_file: Option<String>,
1219 pub dump_summaries: Option<bool>,
1223}
1224
1225impl Eq for CompactionConfig {}
1227
1228pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
1232
1233pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
1237
1238#[derive(Debug, Clone, PartialEq)]
1239pub struct ContextConfig {
1240 pub max_tokens: Option<u32>,
1242 pub record: bool,
1246 pub record_store: Option<String>,
1250 pub seed_budget: f32,
1255 pub seed_overflow_margin: f32,
1260 pub retain: Option<ContextRetainConfig>,
1263}
1264
1265impl Eq for ContextConfig {}
1267
1268#[derive(Debug, Clone, PartialEq, Eq)]
1269pub struct ObservabilityConfig {
1270 pub otel_endpoint: Option<String>,
1271 pub eval: Option<EvalConfig>,
1272}
1273
1274#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1275pub struct TraceConfig {
1276 pub capture: TraceCapture,
1279 pub retain: Option<TraceRetainConfig>,
1284}
1285
1286#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1292pub struct TraceRetainConfig {
1293 pub max_sessions: Option<u32>,
1297 pub max_age_secs: Option<u64>,
1300}
1301
1302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1308pub struct ContextRetainConfig {
1309 pub max_messages: Option<u32>,
1312 pub max_age_secs: Option<u64>,
1315}
1316
1317#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1318pub struct EvalConfig {
1319 #[serde(skip_serializing_if = "Option::is_none")]
1320 pub dataset_id: Option<String>,
1321 pub scorers: Vec<ScorerConfig>,
1322}
1323
1324#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1325#[serde(tag = "type", rename_all = "snake_case")]
1326pub enum ScorerConfig {
1327 ExitOk { name: String },
1328 MaxTurns { name: String, max: u32 },
1329 MaxTokens { name: String, max: u64 },
1330 ToolSequence { name: String, expected: Vec<String> },
1331 LlmJudge { name: String },
1332}
1333
1334#[derive(Debug, Clone, PartialEq, Eq)]
1335pub struct NetworkCapabilities {
1336 pub allow: Vec<String>,
1337 pub unix_sockets: bool,
1345}
1346
1347#[derive(Debug, Clone, PartialEq, Eq)]
1348pub struct FilesystemCapabilities {
1349 pub scope: Option<String>,
1350 pub workdir_exec: bool,
1365 pub read_only: Vec<String>,
1372}
1373
1374#[derive(Debug, Clone, PartialEq, Eq)]
1379pub struct TaskIoCapabilities {
1380 pub read: bool,
1384}
1385
1386#[derive(Debug, Clone, PartialEq, Eq)]
1391pub struct ConversationCapabilities {
1392 pub read: bool,
1395}
1396
1397#[derive(Debug, Clone, PartialEq, Eq)]
1401pub struct PlanCapabilities {
1402 pub submit: bool,
1405}
1406
1407#[derive(Debug, Clone, PartialEq, Eq)]
1414pub struct InstallCapabilities {
1415 pub skill: Vec<String>,
1417 pub tool: Vec<String>,
1419}
1420
1421#[derive(Debug, Clone, PartialEq, Eq)]
1422pub struct ShellCapabilities {
1423 pub allow: Vec<String>,
1424 pub strip_env: Option<Vec<String>>,
1425 pub baseline_env: Option<Vec<String>>,
1426 pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1433 pub staged_runtime: Vec<StagedRuntimeGrant>,
1439}
1440
1441#[derive(Debug, Clone, PartialEq, Eq)]
1446pub struct InterpreterRuntimeGrant {
1447 pub binary: String,
1451 pub dirs: Vec<InterpreterRuntimeDir>,
1454}
1455
1456#[derive(Debug, Clone, PartialEq, Eq)]
1458pub struct InterpreterRuntimeDir {
1459 pub path: String,
1461 pub list_dir: bool,
1466}
1467
1468#[derive(Debug, Clone, PartialEq, Eq)]
1485pub struct StagedRuntimeGrant {
1486 pub binary: String,
1490 pub source_path: String,
1494 pub pin: String,
1499}
1500
1501#[derive(Debug, Clone, PartialEq, Eq)]
1502pub struct SpawnCapabilities {
1503 pub allow: Vec<String>,
1504}
1505
1506#[derive(Debug, Clone, PartialEq, Eq, Default)]
1511pub struct EnvCapabilities {
1512 pub allow: Vec<String>,
1513}
1514
1515#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1523pub struct ResourceLimits {
1524 pub memory_bytes: Option<usize>,
1526 pub table_elements: Option<usize>,
1528 pub instances: Option<usize>,
1530 pub deadline_seconds: Option<u64>,
1532}
1533
1534#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1548pub struct ResourceCapabilities {
1549 pub max_processes: Option<u64>,
1554 pub max_open_files: Option<u64>,
1556 pub max_file_size_bytes: Option<u64>,
1558 pub cpu_seconds: Option<u64>,
1560 pub memory_bytes: Option<u64>,
1563 pub cgroup_memory_bytes: Option<u64>,
1565 pub cgroup_pids_max: Option<u64>,
1567 pub cgroup_cpu_percent: Option<u32>,
1569 pub cgroup_io_bytes_per_sec: Option<u64>,
1572 pub workdir_max_bytes: Option<u64>,
1574}
1575
1576#[derive(Debug, Clone, PartialEq, Eq, Default)]
1583pub struct StateCapabilities {
1584 pub store: Option<String>,
1592}
1593
1594#[derive(
1607 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1608)]
1609#[serde(rename_all = "snake_case")]
1610pub enum ContainmentClass {
1611 #[default]
1615 Advisory,
1616 Scoped,
1620 Sealed,
1628}
1629
1630impl ContainmentClass {
1631 pub fn as_str(self) -> &'static str {
1634 match self {
1635 Self::Advisory => "advisory",
1636 Self::Scoped => "scoped",
1637 Self::Sealed => "sealed",
1638 }
1639 }
1640
1641 pub const ALL: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1643}
1644
1645impl std::fmt::Display for ContainmentClass {
1646 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1647 f.write_str(self.as_str())
1648 }
1649}
1650
1651#[derive(Debug, Clone, PartialEq, Eq)]
1653pub struct ParseContainmentClassError {
1654 pub value: String,
1655}
1656
1657impl std::fmt::Display for ParseContainmentClassError {
1658 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1659 write!(
1660 f,
1661 "must be one of: advisory, scoped, sealed; got '{}'",
1662 self.value
1663 )
1664 }
1665}
1666
1667impl std::error::Error for ParseContainmentClassError {}
1668
1669impl std::str::FromStr for ContainmentClass {
1670 type Err = ParseContainmentClassError;
1671
1672 fn from_str(value: &str) -> Result<Self, Self::Err> {
1673 match value.trim() {
1674 "advisory" => Ok(Self::Advisory),
1675 "scoped" => Ok(Self::Scoped),
1676 "sealed" => Ok(Self::Sealed),
1677 other => Err(ParseContainmentClassError {
1678 value: other.to_string(),
1679 }),
1680 }
1681 }
1682}
1683
1684pub fn effective_containment_floor(
1690 workspace_default: Option<ContainmentClass>,
1691 manifest_declared: Option<ContainmentClass>,
1692 cli_flag: Option<ContainmentClass>,
1693) -> ContainmentClass {
1694 [workspace_default, manifest_declared, cli_flag]
1695 .into_iter()
1696 .flatten()
1697 .max()
1698 .unwrap_or_default()
1699}
1700
1701#[derive(Debug, Clone, PartialEq, Eq)]
1702pub struct Capabilities {
1703 pub network: Option<NetworkCapabilities>,
1704 pub peer_fetch: Option<PeerFetchCapabilities>,
1707 pub filesystem: Option<FilesystemCapabilities>,
1708 pub shell: Option<ShellCapabilities>,
1709 pub spawn: Option<SpawnCapabilities>,
1710 pub env: Option<EnvCapabilities>,
1711 pub limits: Option<ResourceLimits>,
1712 pub resources: Option<ResourceCapabilities>,
1713 pub state: Option<StateCapabilities>,
1716 pub task_io: Option<TaskIoCapabilities>,
1719 pub conversation: Option<ConversationCapabilities>,
1722 pub plan: Option<PlanCapabilities>,
1725 pub install: Option<InstallCapabilities>,
1728 pub containment: Option<ContainmentClass>,
1732}
1733
1734#[derive(Debug, Error)]
1735pub enum RuntimeManifestError {
1736 #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
1737 NotFound(String),
1738 #[error("{0}")]
1739 YamlSyntax(String),
1740 #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
1741 MissingField { field: String },
1742 #[error(
1743 "{}: invalid artifact declaration at index {index}: {message}",
1744 MANIFEST_FILENAME
1745 )]
1746 InvalidArtifact { index: usize, message: String },
1747 #[error(
1748 "{}: invalid inference config for '{field}': {message}",
1749 MANIFEST_FILENAME
1750 )]
1751 InvalidInferenceConfig { field: String, message: String },
1752 #[error(
1753 "{}: invalid capability config for '{field}': {message}",
1754 MANIFEST_FILENAME
1755 )]
1756 InvalidCapabilities { field: String, message: String },
1757 #[error(
1758 "{}: invalid exports config for '{field}': {message}",
1759 MANIFEST_FILENAME
1760 )]
1761 InvalidExports { field: String, message: String },
1762 #[error(
1763 "{}: invalid control config for '{field}': {message}",
1764 MANIFEST_FILENAME
1765 )]
1766 InvalidControl { field: String, message: String },
1767 #[error(
1768 "{}: invalid network config for '{field}': {message}",
1769 MANIFEST_FILENAME
1770 )]
1771 InvalidNetworkConfig { field: String, message: String },
1772 #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
1773 InvalidTraceConfig { field: String, message: String },
1774 #[error(
1778 "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
1779 gateway.endpoint '{endpoint}' but binds no credential",
1780 MANIFEST_FILENAME
1781 )]
1782 GatewayWithoutCredential {
1783 index: usize,
1784 name: String,
1785 endpoint: String,
1786 },
1787 #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
1788 Io {
1789 path: String,
1790 #[source]
1791 source: std::io::Error,
1792 },
1793}
1794
1795#[derive(Debug, Deserialize)]
1796struct RawRuntimeManifest {
1797 name: Option<String>,
1798 version: Option<String>,
1799 #[serde(default)]
1800 artifacts: Vec<RawArtifact>,
1801 #[serde(default)]
1802 capabilities: Option<RawCapabilities>,
1803 #[serde(default)]
1804 inference: Option<RawInferenceConfig>,
1805 #[serde(default)]
1806 context: Option<RawContextConfig>,
1807 #[serde(default)]
1808 observability: Option<RawObservabilityConfig>,
1809 #[serde(default)]
1810 trace: Option<RawTraceConfig>,
1811 #[serde(default)]
1812 network: Option<RawNetworkConfig>,
1813 #[serde(default)]
1814 lifecycle: Option<RawLifecycleConfig>,
1815 #[serde(default)]
1816 exports: Option<RawExports>,
1817 #[serde(default)]
1818 control: Option<RawControl>,
1819 #[serde(default)]
1820 mur_version: Option<String>,
1821 #[serde(default, deserialize_with = "deserialize_present")]
1825 config: Option<serde_yaml::Value>,
1826 #[serde(default)]
1831 #[allow(dead_code)]
1832 runtime: Option<serde_yaml::Value>,
1833 #[serde(default)]
1837 #[allow(dead_code)]
1838 implementation: Option<serde_yaml::Value>,
1839 #[serde(default)]
1843 #[allow(dead_code)]
1844 execution: Option<serde_yaml::Value>,
1845 #[serde(default)]
1848 #[allow(dead_code)]
1849 requires_files: Option<serde_yaml::Value>,
1850 #[serde(flatten)]
1851 unknown: UnknownKeys,
1852}
1853
1854#[derive(Debug, Deserialize)]
1855struct RawControl {
1856 #[serde(default)]
1860 settings: Option<serde_yaml::Value>,
1861 #[serde(default)]
1862 secrets: Option<serde_yaml::Value>,
1863 #[serde(default)]
1864 agent_settings: Option<serde_yaml::Value>,
1865 #[serde(flatten)]
1866 unknown: UnknownKeys,
1867}
1868
1869#[derive(Debug, Deserialize)]
1870struct RawExports {
1871 #[serde(default)]
1872 files: Option<RawFileExport>,
1873 #[serde(default)]
1874 peer_files: Option<RawPeerFilesExport>,
1875 #[serde(default)]
1876 peer_tasks: Option<RawPeerTasksExport>,
1877 #[serde(flatten)]
1878 unknown: UnknownKeys,
1879}
1880
1881#[derive(Debug, Deserialize)]
1882struct RawPeerTasksExport {
1883 #[serde(default)]
1886 accept: Option<bool>,
1887 #[serde(flatten)]
1888 unknown: UnknownKeys,
1889}
1890
1891#[derive(Debug, Deserialize)]
1892struct RawPeerFilesExport {
1893 #[serde(default)]
1894 root: Option<String>,
1895 #[serde(default)]
1899 max_ttl: Option<serde_yaml::Value>,
1900 #[serde(default)]
1901 max_bytes: Option<serde_yaml::Value>,
1902 #[serde(flatten)]
1903 unknown: UnknownKeys,
1904}
1905
1906#[derive(Debug, Deserialize)]
1907struct RawFileExport {
1908 #[serde(default)]
1909 root: Option<String>,
1910 #[serde(default)]
1911 mode: Option<String>,
1912 #[serde(default)]
1917 max_bytes: Option<serde_yaml::Value>,
1918 #[serde(flatten)]
1919 unknown: UnknownKeys,
1920}
1921
1922#[derive(Debug, Deserialize)]
1923struct RawLifecycleConfig {
1924 #[serde(default)]
1925 task_acceptance: Option<TaskAcceptance>,
1926 #[serde(default)]
1927 after_task: Option<AfterTask>,
1928 #[serde(default)]
1929 queue_depth: Option<usize>,
1930 #[serde(default)]
1931 input_timeout_secs: Option<u64>,
1932 #[serde(default)]
1933 conversation: Option<ConversationMode>,
1934 #[serde(default)]
1935 max_task_reopens: Option<u32>,
1936 #[serde(default)]
1937 shell_grace_secs: Option<u64>,
1938 #[serde(default)]
1939 delegation_deadline_secs: Option<u64>,
1940 #[serde(flatten)]
1941 unknown: UnknownKeys,
1942}
1943
1944#[derive(Debug, Deserialize)]
1945struct RawNetworkConfig {
1946 internal_port: Option<u16>,
1947 #[serde(default)]
1948 authentication: Option<RawNetworkAuthentication>,
1949 #[serde(flatten)]
1950 unknown: UnknownKeys,
1951}
1952
1953#[derive(Debug, Deserialize)]
1954struct RawNetworkAuthentication {
1955 #[serde(default)]
1956 scheme: Option<String>,
1957 #[serde(default)]
1960 credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
1961 #[serde(flatten)]
1962 unknown: UnknownKeys,
1963}
1964
1965#[derive(Debug, Deserialize)]
1966struct RawDoorCredential {
1967 #[serde(default)]
1971 scopes: Option<serde_yaml::Value>,
1972 #[serde(flatten)]
1973 unknown: UnknownKeys,
1974}
1975
1976#[derive(Debug, Deserialize)]
1977struct RawContextConfig {
1978 max_tokens: Option<u32>,
1979 #[serde(default)]
1982 record: Option<String>,
1983 #[serde(default)]
1984 record_store: Option<String>,
1985 #[serde(default)]
1986 seed_budget: Option<f32>,
1987 #[serde(default)]
1988 seed_overflow_margin: Option<f32>,
1989 #[serde(default, deserialize_with = "present_yaml_value")]
1992 retain: Option<serde_yaml::Value>,
1993 #[serde(flatten)]
1994 unknown: UnknownKeys,
1995}
1996
1997#[derive(Debug, Deserialize)]
1998struct RawObservabilityConfig {
1999 #[serde(default)]
2000 otel_endpoint: Option<String>,
2001 #[serde(default)]
2002 eval: Option<RawEvalConfig>,
2003 #[serde(flatten)]
2004 unknown: UnknownKeys,
2005}
2006
2007#[derive(Debug, Deserialize)]
2008struct RawTraceConfig {
2009 #[serde(default)]
2013 capture: Option<String>,
2014 #[serde(default, deserialize_with = "present_yaml_value")]
2016 retain: Option<serde_yaml::Value>,
2017 #[serde(flatten)]
2018 unknown: UnknownKeys,
2019}
2020
2021#[derive(Debug, Deserialize)]
2022struct RawEvalConfig {
2023 #[serde(default)]
2024 dataset_id: Option<String>,
2025 #[serde(default)]
2026 scorers: Vec<RawScorerConfig>,
2027 #[serde(flatten)]
2028 unknown: UnknownKeys,
2029}
2030
2031#[derive(Debug, Deserialize)]
2032struct RawScorerConfig {
2033 #[serde(rename = "type")]
2034 scorer_type: String,
2035 name: Option<String>,
2036 max: Option<u64>,
2037 expected: Option<Vec<String>>,
2038 #[serde(flatten)]
2039 unknown: UnknownKeys,
2040}
2041
2042#[derive(Debug, Deserialize)]
2043struct RawArtifact {
2044 name: Option<String>,
2045 version: Option<String>,
2046 runtime: Option<String>,
2047 #[serde(default)]
2048 source: Option<String>,
2049 #[serde(default)]
2050 local_source: Option<bool>,
2051 #[serde(default)]
2052 prompt_payload: Option<bool>,
2053 #[serde(default)]
2054 capabilities: Option<RawCapabilities>,
2055 #[serde(default)]
2056 on_overflow: Option<String>,
2057 #[serde(default, deserialize_with = "deserialize_present")]
2062 config: Option<serde_yaml::Value>,
2063 #[serde(default, deserialize_with = "deserialize_present")]
2066 gateway: Option<Option<RawArtifactGateway>>,
2067 #[serde(flatten)]
2068 unknown: UnknownKeys,
2069}
2070
2071#[derive(Debug, Deserialize)]
2072struct RawArtifactGateway {
2073 #[serde(default)]
2074 endpoint: Option<String>,
2075 #[serde(default, deserialize_with = "deserialize_present")]
2078 api_key: Option<Option<String>>,
2079 #[serde(default)]
2081 keyless: Option<serde_yaml::Value>,
2082 #[serde(flatten)]
2083 unknown: UnknownKeys,
2084}
2085
2086fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
2092where
2093 T: Deserialize<'de>,
2094 D: serde::Deserializer<'de>,
2095{
2096 T::deserialize(deserializer).map(Some)
2097}
2098
2099#[derive(Debug, Deserialize)]
2100struct RawCapabilities {
2101 #[serde(default)]
2102 network: Option<RawNetworkCapabilities>,
2103 #[serde(default)]
2104 peer_fetch: Option<RawPeerFetchCapabilities>,
2105 #[serde(default)]
2106 filesystem: Option<RawFilesystemCapabilities>,
2107 #[serde(default)]
2108 shell: Option<RawShellCapabilities>,
2109 #[serde(default)]
2110 spawn: Option<RawSpawnCapabilities>,
2111 #[serde(default)]
2112 env: Option<RawEnvCapabilities>,
2113 #[serde(default)]
2114 limits: Option<RawResourceLimits>,
2115 #[serde(default)]
2116 resources: Option<RawResourceCapabilities>,
2117 #[serde(default)]
2118 state: Option<RawStateCapabilities>,
2119 #[serde(default)]
2120 task_io: Option<RawTaskIoCapabilities>,
2121 #[serde(default)]
2122 conversation: Option<RawConversationCapabilities>,
2123 #[serde(default)]
2124 plan: Option<RawPlanCapabilities>,
2125 #[serde(default)]
2126 install: Option<RawInstallCapabilities>,
2127 #[serde(default)]
2131 containment: Option<String>,
2132 #[serde(flatten)]
2133 unknown: UnknownKeys,
2134}
2135
2136#[derive(Debug, Deserialize)]
2137struct RawTaskIoCapabilities {
2138 #[serde(default)]
2141 read: Option<bool>,
2142 #[serde(flatten)]
2143 unknown: UnknownKeys,
2144}
2145
2146#[derive(Debug, Deserialize)]
2147struct RawConversationCapabilities {
2148 #[serde(default)]
2151 read: Option<bool>,
2152 #[serde(flatten)]
2153 unknown: UnknownKeys,
2154}
2155
2156#[derive(Debug, Deserialize)]
2157struct RawPlanCapabilities {
2158 #[serde(default)]
2161 submit: Option<bool>,
2162 #[serde(flatten)]
2163 unknown: UnknownKeys,
2164}
2165
2166#[derive(Debug, Deserialize)]
2167struct RawInstallCapabilities {
2168 #[serde(default)]
2169 skill: Vec<String>,
2170 #[serde(default)]
2171 tool: Vec<String>,
2172 #[serde(flatten)]
2173 unknown: UnknownKeys,
2174}
2175
2176#[derive(Debug, Deserialize)]
2177struct RawSpawnCapabilities {
2178 #[serde(default)]
2179 allow: Vec<String>,
2180 #[serde(flatten)]
2181 unknown: UnknownKeys,
2182}
2183
2184#[derive(Debug, Deserialize)]
2185struct RawEnvCapabilities {
2186 #[serde(default)]
2187 allow: Vec<String>,
2188 #[serde(flatten)]
2189 unknown: UnknownKeys,
2190}
2191
2192#[derive(Debug, Deserialize)]
2193struct RawResourceLimits {
2194 #[serde(default)]
2195 memory_bytes: Option<usize>,
2196 #[serde(default)]
2197 table_elements: Option<usize>,
2198 #[serde(default)]
2199 instances: Option<usize>,
2200 #[serde(default)]
2201 deadline_seconds: Option<u64>,
2202 #[serde(flatten)]
2203 unknown: UnknownKeys,
2204}
2205
2206#[derive(Debug, Deserialize)]
2207struct RawStateCapabilities {
2208 #[serde(default)]
2209 store: Option<String>,
2210 #[serde(flatten)]
2211 unknown: UnknownKeys,
2212}
2213
2214#[derive(Debug, Deserialize)]
2215struct RawResourceCapabilities {
2216 #[serde(default)]
2217 max_processes: Option<u64>,
2218 #[serde(default)]
2219 max_open_files: Option<u64>,
2220 #[serde(default)]
2221 max_file_size_bytes: Option<u64>,
2222 #[serde(default)]
2223 cpu_seconds: Option<u64>,
2224 #[serde(default)]
2225 memory_bytes: Option<u64>,
2226 #[serde(default)]
2227 cgroup_memory_bytes: Option<u64>,
2228 #[serde(default)]
2229 cgroup_pids_max: Option<u64>,
2230 #[serde(default)]
2231 cgroup_cpu_percent: Option<u32>,
2232 #[serde(default)]
2233 cgroup_io_bytes_per_sec: Option<u64>,
2234 #[serde(default)]
2235 workdir_max_bytes: Option<u64>,
2236 #[serde(flatten)]
2237 unknown: UnknownKeys,
2238}
2239
2240#[derive(Debug, Deserialize)]
2241struct RawNetworkCapabilities {
2242 #[serde(default)]
2243 allow: Vec<String>,
2244 #[serde(default)]
2245 unix_sockets: bool,
2246 #[serde(flatten)]
2247 unknown: UnknownKeys,
2248}
2249
2250#[derive(Debug, Deserialize)]
2251struct RawPeerFetchCapabilities {
2252 #[serde(default)]
2253 allow: Vec<String>,
2254 #[serde(flatten)]
2255 unknown: UnknownKeys,
2256}
2257
2258#[derive(Debug, Deserialize)]
2259struct RawFilesystemCapabilities {
2260 #[serde(default)]
2261 scope: Option<String>,
2262 #[serde(default)]
2266 workdir_exec: bool,
2267 #[serde(default)]
2270 read_only: Vec<String>,
2271 #[serde(flatten)]
2272 unknown: UnknownKeys,
2273}
2274
2275#[derive(Debug, Deserialize)]
2276struct RawShellCapabilities {
2277 #[serde(default)]
2278 allow: Vec<String>,
2279 #[serde(default)]
2280 strip_env: Option<Vec<String>>,
2281 #[serde(default)]
2282 baseline_env: Option<Vec<String>>,
2283 #[serde(default)]
2284 interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
2285 #[serde(default)]
2286 staged_runtime: Vec<RawStagedRuntimeGrant>,
2287 #[serde(flatten)]
2288 unknown: UnknownKeys,
2289}
2290
2291#[derive(Debug, Deserialize)]
2292struct RawInterpreterRuntimeGrant {
2293 #[serde(default)]
2294 binary: Option<String>,
2295 #[serde(default)]
2296 dirs: Vec<RawInterpreterRuntimeDir>,
2297 #[serde(flatten)]
2298 unknown: UnknownKeys,
2299}
2300
2301#[derive(Debug, Deserialize)]
2302struct RawInterpreterRuntimeDir {
2303 #[serde(default)]
2304 path: Option<String>,
2305 #[serde(default)]
2309 list_dir: Option<bool>,
2310 #[serde(flatten)]
2311 unknown: UnknownKeys,
2312}
2313
2314#[derive(Debug, Deserialize)]
2315struct RawStagedRuntimeGrant {
2316 #[serde(default)]
2317 binary: Option<String>,
2318 #[serde(default)]
2319 source_path: Option<String>,
2320 #[serde(default)]
2324 pin: Option<String>,
2325 #[serde(flatten)]
2326 unknown: UnknownKeys,
2327}
2328
2329#[derive(Debug, Deserialize)]
2330struct RawInferenceConfig {
2331 transport: Option<String>,
2332 #[serde(default, deserialize_with = "deserialize_present")]
2335 endpoint: Option<serde_yaml::Value>,
2336 model: Option<String>,
2337 #[serde(default, deserialize_with = "deserialize_present")]
2340 api_key: Option<serde_yaml::Value>,
2341 #[serde(default)]
2342 command: Option<String>,
2343 #[serde(default)]
2344 system_prompt: Option<String>,
2345 #[serde(default)]
2346 system_prompt_file: Option<String>,
2347 #[serde(default)]
2348 system_prompt_artifact: Option<String>,
2349 #[serde(default)]
2350 driver: Option<RawInferenceDriver>,
2351 #[serde(default)]
2352 compaction: Option<RawCompactionConfig>,
2353 #[serde(default)]
2354 max_turns: Option<u32>,
2355 #[serde(default)]
2358 max_task_reopens: Option<u32>,
2359 #[serde(default)]
2360 max_tokens: Option<u32>,
2361 #[serde(default)]
2362 max_session_tokens: Option<u64>,
2363 #[serde(default)]
2364 tool_refresh: Option<String>,
2365 #[serde(default)]
2366 alternates: Option<Vec<RawInferenceAlternate>>,
2367 #[serde(flatten)]
2368 unknown: UnknownKeys,
2369}
2370
2371#[derive(Debug, Deserialize)]
2372struct RawInferenceAlternate {
2373 #[serde(default)]
2374 name: Option<String>,
2375 #[serde(default)]
2376 model: Option<String>,
2377 #[serde(default)]
2378 driver: Option<RawInferenceDriver>,
2379 #[serde(flatten)]
2380 unknown: UnknownKeys,
2381}
2382
2383#[derive(Debug, Deserialize)]
2384struct RawCompactionConfig {
2385 threshold: Option<f32>,
2386 model: Option<String>,
2387 system_prompt: Option<String>,
2388 system_prompt_file: Option<String>,
2389 dump_summaries: Option<bool>,
2390 #[serde(flatten)]
2391 unknown: UnknownKeys,
2392}
2393
2394#[derive(Debug, Deserialize)]
2395struct RawInferenceDriver {
2396 artifact: Option<String>,
2397 #[serde(default)]
2398 config: Option<serde_yaml::Value>,
2399 #[serde(flatten)]
2400 unknown: UnknownKeys,
2401}
2402
2403type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2410
2411trait RawBlock {
2418 const KNOWN_KEYS: &'static [&'static str];
2422
2423 fn unknown_keys(&self) -> &UnknownKeys;
2424
2425 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2433 let _ = (path, out);
2434 }
2435}
2436
2437impl RawBlock for RawRuntimeManifest {
2438 const KNOWN_KEYS: &'static [&'static str] = &[
2439 "name",
2440 "version",
2441 "artifacts",
2442 "capabilities",
2443 "inference",
2444 "context",
2445 "observability",
2446 "trace",
2447 "network",
2448 "lifecycle",
2449 "exports",
2450 "control",
2451 "mur_version",
2452 "config",
2453 "runtime",
2454 "implementation",
2455 "execution",
2456 "requires_files",
2457 ];
2458 fn unknown_keys(&self) -> &UnknownKeys {
2459 &self.unknown
2460 }
2461
2462 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2463 let artifacts = child_path(path, "artifacts");
2464 for (index, artifact) in self.artifacts.iter().enumerate() {
2465 collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2466 }
2467 if let Some(capabilities) = &self.capabilities {
2468 collect_block(capabilities, &child_path(path, "capabilities"), out);
2469 }
2470 if let Some(inference) = &self.inference {
2471 collect_block(inference, &child_path(path, "inference"), out);
2472 }
2473 if let Some(context) = &self.context {
2474 collect_block(context, &child_path(path, "context"), out);
2475 }
2476 if let Some(observability) = &self.observability {
2477 collect_block(observability, &child_path(path, "observability"), out);
2478 }
2479 if let Some(trace) = &self.trace {
2480 collect_block(trace, &child_path(path, "trace"), out);
2481 }
2482 if let Some(network) = &self.network {
2483 collect_block(network, &child_path(path, "network"), out);
2484 }
2485 if let Some(lifecycle) = &self.lifecycle {
2486 collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2487 }
2488 if let Some(exports) = &self.exports {
2489 collect_block(exports, &child_path(path, "exports"), out);
2490 }
2491 if let Some(control) = &self.control {
2492 collect_block(control, &child_path(path, "control"), out);
2493 }
2494 }
2495}
2496
2497impl RawBlock for RawControl {
2498 const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
2499 fn unknown_keys(&self) -> &UnknownKeys {
2500 &self.unknown
2501 }
2502}
2503
2504impl RawBlock for RawExports {
2505 const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files", "peer_tasks"];
2506 fn unknown_keys(&self) -> &UnknownKeys {
2507 &self.unknown
2508 }
2509
2510 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2511 if let Some(files) = &self.files {
2512 collect_block(files, &child_path(path, "files"), out);
2513 }
2514 if let Some(peer_files) = &self.peer_files {
2515 collect_block(peer_files, &child_path(path, "peer_files"), out);
2516 }
2517 if let Some(peer_tasks) = &self.peer_tasks {
2518 collect_block(peer_tasks, &child_path(path, "peer_tasks"), out);
2519 }
2520 }
2521}
2522
2523impl RawBlock for RawPeerTasksExport {
2524 const KNOWN_KEYS: &'static [&'static str] = &["accept"];
2525 fn unknown_keys(&self) -> &UnknownKeys {
2526 &self.unknown
2527 }
2528}
2529
2530impl RawBlock for RawPeerFilesExport {
2531 const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2532 fn unknown_keys(&self) -> &UnknownKeys {
2533 &self.unknown
2534 }
2535}
2536
2537impl RawBlock for RawFileExport {
2538 const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2539 fn unknown_keys(&self) -> &UnknownKeys {
2540 &self.unknown
2541 }
2542}
2543
2544impl RawBlock for RawLifecycleConfig {
2545 const KNOWN_KEYS: &'static [&'static str] = &[
2546 "task_acceptance",
2547 "after_task",
2548 "queue_depth",
2549 "input_timeout_secs",
2550 "conversation",
2551 "max_task_reopens",
2552 "shell_grace_secs",
2553 "delegation_deadline_secs",
2554 ];
2555 fn unknown_keys(&self) -> &UnknownKeys {
2556 &self.unknown
2557 }
2558}
2559
2560impl RawBlock for RawNetworkConfig {
2561 const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
2562 fn unknown_keys(&self) -> &UnknownKeys {
2563 &self.unknown
2564 }
2565
2566 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2567 if let Some(authentication) = &self.authentication {
2568 collect_block(authentication, &child_path(path, "authentication"), out);
2569 }
2570 }
2571}
2572
2573impl RawBlock for RawNetworkAuthentication {
2574 const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
2575 fn unknown_keys(&self) -> &UnknownKeys {
2576 &self.unknown
2577 }
2578
2579 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2580 let credentials_path = child_path(path, "credentials");
2581 for (name, credential) in self.credentials.iter().flatten() {
2582 collect_block(credential, &child_path(&credentials_path, name), out);
2583 }
2584 }
2585}
2586
2587impl RawBlock for RawDoorCredential {
2588 const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
2589 fn unknown_keys(&self) -> &UnknownKeys {
2590 &self.unknown
2591 }
2592}
2593
2594impl RawBlock for RawContextConfig {
2595 const KNOWN_KEYS: &'static [&'static str] = &[
2596 "max_tokens",
2597 "record",
2598 "record_store",
2599 "seed_budget",
2600 "seed_overflow_margin",
2601 "retain",
2602 ];
2603 fn unknown_keys(&self) -> &UnknownKeys {
2604 &self.unknown
2605 }
2606}
2607
2608impl RawBlock for RawObservabilityConfig {
2609 const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
2610 fn unknown_keys(&self) -> &UnknownKeys {
2611 &self.unknown
2612 }
2613
2614 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2615 if let Some(eval) = &self.eval {
2616 collect_block(eval, &child_path(path, "eval"), out);
2617 }
2618 }
2619}
2620
2621impl RawBlock for RawTraceConfig {
2622 const KNOWN_KEYS: &'static [&'static str] = &["capture", "retain"];
2623 fn unknown_keys(&self) -> &UnknownKeys {
2624 &self.unknown
2625 }
2626}
2627
2628impl RawBlock for RawEvalConfig {
2629 const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
2630 fn unknown_keys(&self) -> &UnknownKeys {
2631 &self.unknown
2632 }
2633
2634 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2635 let scorers = child_path(path, "scorers");
2636 for (index, scorer) in self.scorers.iter().enumerate() {
2637 collect_block(scorer, &format!("{scorers}[{index}]"), out);
2638 }
2639 }
2640}
2641
2642impl RawBlock for RawScorerConfig {
2643 const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
2644 fn unknown_keys(&self) -> &UnknownKeys {
2645 &self.unknown
2646 }
2647}
2648
2649impl RawBlock for RawArtifact {
2650 const KNOWN_KEYS: &'static [&'static str] = &[
2651 "name",
2652 "version",
2653 "runtime",
2654 "source",
2655 "local_source",
2656 "prompt_payload",
2657 "capabilities",
2658 "on_overflow",
2659 "config",
2660 "gateway",
2661 ];
2662 fn unknown_keys(&self) -> &UnknownKeys {
2663 &self.unknown
2664 }
2665
2666 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2667 if let Some(capabilities) = &self.capabilities {
2668 collect_block(capabilities, &child_path(path, "capabilities"), out);
2669 }
2670 if let Some(Some(gateway)) = &self.gateway {
2671 collect_block(gateway, &child_path(path, "gateway"), out);
2672 }
2673 }
2674}
2675
2676impl RawBlock for RawArtifactGateway {
2677 const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
2678 fn unknown_keys(&self) -> &UnknownKeys {
2679 &self.unknown
2680 }
2681}
2682
2683impl RawBlock for RawCapabilities {
2684 const KNOWN_KEYS: &'static [&'static str] = &[
2685 "network",
2686 "peer_fetch",
2687 "filesystem",
2688 "shell",
2689 "spawn",
2690 "env",
2691 "limits",
2692 "resources",
2693 "state",
2694 "task_io",
2695 "conversation",
2696 "plan",
2697 "install",
2698 "containment",
2699 ];
2700 fn unknown_keys(&self) -> &UnknownKeys {
2701 &self.unknown
2702 }
2703
2704 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2708 if let Some(network) = &self.network {
2709 collect_block(network, &child_path(path, "network"), out);
2710 }
2711 if let Some(peer_fetch) = &self.peer_fetch {
2712 collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
2713 }
2714 if let Some(filesystem) = &self.filesystem {
2715 collect_block(filesystem, &child_path(path, "filesystem"), out);
2716 }
2717 if let Some(shell) = &self.shell {
2718 collect_block(shell, &child_path(path, "shell"), out);
2719 }
2720 if let Some(spawn) = &self.spawn {
2721 collect_block(spawn, &child_path(path, "spawn"), out);
2722 }
2723 if let Some(env) = &self.env {
2724 collect_block(env, &child_path(path, "env"), out);
2725 }
2726 if let Some(limits) = &self.limits {
2727 collect_block(limits, &child_path(path, "limits"), out);
2728 }
2729 if let Some(resources) = &self.resources {
2730 collect_block(resources, &child_path(path, "resources"), out);
2731 }
2732 if let Some(state) = &self.state {
2733 collect_block(state, &child_path(path, "state"), out);
2734 }
2735 if let Some(task_io) = &self.task_io {
2736 collect_block(task_io, &child_path(path, "task_io"), out);
2737 }
2738 if let Some(conversation) = &self.conversation {
2739 collect_block(conversation, &child_path(path, "conversation"), out);
2740 }
2741 if let Some(plan) = &self.plan {
2742 collect_block(plan, &child_path(path, "plan"), out);
2743 }
2744 if let Some(install) = &self.install {
2745 collect_block(install, &child_path(path, "install"), out);
2746 }
2747 }
2748}
2749
2750impl RawBlock for RawTaskIoCapabilities {
2751 const KNOWN_KEYS: &'static [&'static str] = &["read"];
2752 fn unknown_keys(&self) -> &UnknownKeys {
2753 &self.unknown
2754 }
2755}
2756
2757impl RawBlock for RawConversationCapabilities {
2758 const KNOWN_KEYS: &'static [&'static str] = &["read"];
2759 fn unknown_keys(&self) -> &UnknownKeys {
2760 &self.unknown
2761 }
2762}
2763
2764impl RawBlock for RawPlanCapabilities {
2765 const KNOWN_KEYS: &'static [&'static str] = &["submit"];
2766 fn unknown_keys(&self) -> &UnknownKeys {
2767 &self.unknown
2768 }
2769}
2770
2771impl RawBlock for RawInstallCapabilities {
2772 const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool"];
2773 fn unknown_keys(&self) -> &UnknownKeys {
2774 &self.unknown
2775 }
2776}
2777
2778impl RawBlock for RawSpawnCapabilities {
2779 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2780 fn unknown_keys(&self) -> &UnknownKeys {
2781 &self.unknown
2782 }
2783}
2784
2785impl RawBlock for RawEnvCapabilities {
2786 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2787 fn unknown_keys(&self) -> &UnknownKeys {
2788 &self.unknown
2789 }
2790}
2791
2792impl RawBlock for RawResourceLimits {
2793 const KNOWN_KEYS: &'static [&'static str] = &[
2794 "memory_bytes",
2795 "table_elements",
2796 "instances",
2797 "deadline_seconds",
2798 ];
2799 fn unknown_keys(&self) -> &UnknownKeys {
2800 &self.unknown
2801 }
2802}
2803
2804impl RawBlock for RawStateCapabilities {
2805 const KNOWN_KEYS: &'static [&'static str] = &["store"];
2806 fn unknown_keys(&self) -> &UnknownKeys {
2807 &self.unknown
2808 }
2809}
2810
2811impl RawBlock for RawResourceCapabilities {
2812 const KNOWN_KEYS: &'static [&'static str] = &[
2813 "max_processes",
2814 "max_open_files",
2815 "max_file_size_bytes",
2816 "cpu_seconds",
2817 "memory_bytes",
2818 "cgroup_memory_bytes",
2819 "cgroup_pids_max",
2820 "cgroup_cpu_percent",
2821 "cgroup_io_bytes_per_sec",
2822 "workdir_max_bytes",
2823 ];
2824 fn unknown_keys(&self) -> &UnknownKeys {
2825 &self.unknown
2826 }
2827}
2828
2829impl RawBlock for RawNetworkCapabilities {
2830 const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
2831 fn unknown_keys(&self) -> &UnknownKeys {
2832 &self.unknown
2833 }
2834}
2835
2836impl RawBlock for RawPeerFetchCapabilities {
2837 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
2838 fn unknown_keys(&self) -> &UnknownKeys {
2839 &self.unknown
2840 }
2841}
2842
2843impl RawBlock for RawFilesystemCapabilities {
2844 const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only"];
2845 fn unknown_keys(&self) -> &UnknownKeys {
2846 &self.unknown
2847 }
2848}
2849
2850impl RawBlock for RawShellCapabilities {
2851 const KNOWN_KEYS: &'static [&'static str] = &[
2852 "allow",
2853 "strip_env",
2854 "baseline_env",
2855 "interpreter_runtime",
2856 "staged_runtime",
2857 ];
2858 fn unknown_keys(&self) -> &UnknownKeys {
2859 &self.unknown
2860 }
2861
2862 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2863 let interpreter_runtime = child_path(path, "interpreter_runtime");
2864 for (index, grant) in self.interpreter_runtime.iter().enumerate() {
2865 collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
2866 }
2867 let staged_runtime = child_path(path, "staged_runtime");
2868 for (index, grant) in self.staged_runtime.iter().enumerate() {
2869 collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
2870 }
2871 }
2872}
2873
2874impl RawBlock for RawInterpreterRuntimeGrant {
2875 const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
2876 fn unknown_keys(&self) -> &UnknownKeys {
2877 &self.unknown
2878 }
2879
2880 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2881 let dirs = child_path(path, "dirs");
2882 for (index, dir) in self.dirs.iter().enumerate() {
2883 collect_block(dir, &format!("{dirs}[{index}]"), out);
2884 }
2885 }
2886}
2887
2888impl RawBlock for RawInterpreterRuntimeDir {
2889 const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
2890 fn unknown_keys(&self) -> &UnknownKeys {
2891 &self.unknown
2892 }
2893}
2894
2895impl RawBlock for RawStagedRuntimeGrant {
2896 const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
2897 fn unknown_keys(&self) -> &UnknownKeys {
2898 &self.unknown
2899 }
2900}
2901
2902impl RawBlock for RawInferenceConfig {
2903 const KNOWN_KEYS: &'static [&'static str] = &[
2904 "transport",
2905 "endpoint",
2906 "model",
2907 "api_key",
2908 "command",
2909 "system_prompt",
2910 "system_prompt_file",
2911 "system_prompt_artifact",
2912 "driver",
2913 "compaction",
2914 "max_turns",
2915 "max_task_reopens",
2916 "max_tokens",
2917 "max_session_tokens",
2918 "tool_refresh",
2919 "alternates",
2920 ];
2921 fn unknown_keys(&self) -> &UnknownKeys {
2922 &self.unknown
2923 }
2924
2925 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2926 if let Some(driver) = &self.driver {
2927 collect_block(driver, &child_path(path, "driver"), out);
2928 }
2929 if let Some(compaction) = &self.compaction {
2930 collect_block(compaction, &child_path(path, "compaction"), out);
2931 }
2932 if let Some(alternates) = &self.alternates {
2933 let alternates_path = child_path(path, "alternates");
2934 for (index, alternate) in alternates.iter().enumerate() {
2935 collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
2936 }
2937 }
2938 }
2939}
2940
2941impl RawBlock for RawInferenceAlternate {
2942 const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
2943 fn unknown_keys(&self) -> &UnknownKeys {
2944 &self.unknown
2945 }
2946
2947 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2948 if let Some(driver) = &self.driver {
2949 collect_block(driver, &child_path(path, "driver"), out);
2950 }
2951 }
2952}
2953
2954impl RawBlock for RawCompactionConfig {
2955 const KNOWN_KEYS: &'static [&'static str] = &[
2956 "threshold",
2957 "model",
2958 "system_prompt",
2959 "system_prompt_file",
2960 "dump_summaries",
2961 ];
2962 fn unknown_keys(&self) -> &UnknownKeys {
2963 &self.unknown
2964 }
2965}
2966
2967impl RawBlock for RawInferenceDriver {
2968 const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
2969 fn unknown_keys(&self) -> &UnknownKeys {
2970 &self.unknown
2971 }
2972}
2973
2974fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
2981 let mut out = Vec::new();
2982 collect_block(raw, "", &mut out);
2983 out
2984}
2985
2986fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
2992 for key in block.unknown_keys().keys() {
2993 out.push(UnknownManifestKey {
2994 key: key.clone(),
2995 block_path: path.to_string(),
2996 nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
2997 });
2998 }
2999 block.walk_children(path, out);
3000}
3001
3002fn child_path(parent: &str, field: &str) -> String {
3006 if parent.is_empty() {
3007 return field.to_string();
3008 }
3009 format!("{parent}.{field}")
3010}
3011
3012pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
3019 fs::read_to_string(path).map_err(|source| {
3020 if source.kind() == std::io::ErrorKind::NotFound {
3021 return RuntimeManifestError::NotFound(path.display().to_string());
3022 }
3023
3024 RuntimeManifestError::Io {
3025 path: path.display().to_string(),
3026 source,
3027 }
3028 })
3029}
3030
3031#[must_use = "validated runtime manifest is required before run"]
3032pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
3033 let content = read_runtime_manifest_text(path)?;
3034
3035 RuntimeManifest::from_yaml_str(&content)
3036}
3037
3038#[derive(Debug, Clone, PartialEq, Eq)]
3040pub struct ReferencedEnvVariable {
3041 pub field: String,
3043 pub variable: String,
3045}
3046
3047impl ReferencedEnvVariable {
3048 pub fn is_gateway_credential(&self) -> bool {
3051 self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
3052 }
3053}
3054
3055#[derive(Deserialize)]
3063struct ReferencedManifestFields {
3064 #[serde(default)]
3065 artifacts: Vec<ReferencedArtifactFields>,
3066}
3067
3068#[derive(Deserialize)]
3069struct ReferencedArtifactFields {
3070 #[serde(default)]
3071 name: Option<String>,
3072 #[serde(default)]
3073 gateway: Option<ReferencedGatewayFields>,
3074}
3075
3076#[derive(Deserialize)]
3077struct ReferencedGatewayFields {
3078 #[serde(default)]
3079 api_key: Option<String>,
3080}
3081
3082pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
3090 let Ok(raw) = serde_yaml::from_str::<ReferencedManifestFields>(manifest_yaml) else {
3091 return Vec::new();
3092 };
3093
3094 let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
3095 for artifact in raw.artifacts {
3096 let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
3097 continue;
3098 };
3099 if let Some(variable) = parse_env_reference(api_key.trim()) {
3100 let name = artifact.name.unwrap_or_default();
3101 referenced.push(ReferencedEnvVariable {
3102 field: format!("artifacts.{}.gateway.api_key", name.trim()),
3103 variable: variable.to_string(),
3104 });
3105 }
3106 }
3107
3108 referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
3109 referenced.dedup();
3110 referenced
3111}
3112
3113#[derive(Clone, Copy, PartialEq, Eq)]
3119enum SecretResolution {
3120 Resolve,
3122 Skip,
3125}
3126
3127impl RuntimeManifest {
3128 pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
3129 Self::parse(input, SecretResolution::Resolve)
3130 }
3131
3132 pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
3142 Self::parse(input, SecretResolution::Skip)
3143 }
3144
3145 fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
3146 let raw: RawRuntimeManifest = serde_yaml::from_str(input).map_err(|err| {
3147 if let Some(location) = err.location() {
3148 RuntimeManifestError::YamlSyntax(format!(
3149 "{}: YAML syntax error at line {}, column {}: {}",
3150 MANIFEST_FILENAME,
3151 location.line(),
3152 location.column(),
3153 err
3154 ))
3155 } else {
3156 RuntimeManifestError::YamlSyntax(format!(
3157 "{MANIFEST_FILENAME}: YAML syntax error: {err}"
3158 ))
3159 }
3160 })?;
3161
3162 let unknown_keys = collect_unknown_keys(&raw);
3165
3166 let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
3167 RuntimeManifestError::MissingField {
3168 field: "name".to_string(),
3169 }
3170 })?;
3171
3172 let version = raw
3173 .version
3174 .filter(|s| !s.trim().is_empty())
3175 .ok_or_else(|| RuntimeManifestError::MissingField {
3176 field: "version".to_string(),
3177 })?;
3178
3179 let gateway_references = raw_gateway_references(&raw.artifacts);
3182
3183 let artifacts = raw
3184 .artifacts
3185 .into_iter()
3186 .enumerate()
3187 .map(|(index, artifact)| {
3188 let name = artifact
3189 .name
3190 .filter(|s| !s.trim().is_empty())
3191 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3192 index,
3193 message: "missing required field 'name'".to_string(),
3194 })?;
3195
3196 let source = artifact.source.filter(|s| !s.trim().is_empty());
3197
3198 let runtime = match artifact.runtime.as_deref() {
3199 None | Some("tool") => ArtifactRuntime::Tool,
3200 Some("wasm") | Some("native") => {
3201 return Err(RuntimeManifestError::InvalidArtifact {
3202 index,
3203 message: format!(
3204 "use 'runtime: tool'; implementation is declared in the \
3205 artifact's own manifest (got '{}')",
3206 artifact.runtime.as_deref().unwrap_or("")
3207 ),
3208 });
3209 }
3210 Some("driver") => ArtifactRuntime::Driver,
3211 Some("hook") => ArtifactRuntime::Hook,
3212 Some("skill") => ArtifactRuntime::Skill,
3213 Some(other) => {
3214 return Err(RuntimeManifestError::InvalidArtifact {
3215 index,
3216 message: format!(
3217 "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
3218 ),
3219 });
3220 }
3221 };
3222
3223 let local_source = artifact
3227 .local_source
3228 .unwrap_or(runtime == ArtifactRuntime::Skill);
3229 let prompt_payload = artifact
3230 .prompt_payload
3231 .unwrap_or(runtime == ArtifactRuntime::Skill);
3232
3233 if source.is_some() && !local_source {
3235 return Err(RuntimeManifestError::InvalidArtifact {
3236 index,
3237 message: format!(
3238 "artifact '{name}' declares 'source:' but does not declare \
3239 'local_source: true' (runtime: {})",
3240 runtime.as_str()
3241 ),
3242 });
3243 }
3244
3245 let version = if source.is_some() {
3248 if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
3249 crate::warn_line(format_args!(
3250 "warning: artifact '{name}' declares both 'source:' and \
3251 'version: {explicit}'; version is ignored for local-source skills \
3252 (using 'local')"
3253 ));
3254 }
3255 "local".to_string()
3256 } else {
3257 artifact
3258 .version
3259 .filter(|s| !s.trim().is_empty())
3260 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3261 index,
3262 message: "missing required field 'version'".to_string(),
3263 })?
3264 };
3265
3266 let capabilities = match artifact.capabilities {
3272 None => None,
3273 Some(raw_caps) => {
3274 if raw_caps.install.is_some() {
3277 return Err(RuntimeManifestError::InvalidArtifact {
3278 index,
3279 message: format!(
3280 "artifact '{name}' declares 'capabilities.install:'; the \
3281 grant is capsule-wide and is only recognized in the \
3282 top-level capabilities block"
3283 ),
3284 });
3285 }
3286 if runtime == ArtifactRuntime::Skill {
3287 return Err(RuntimeManifestError::InvalidArtifact {
3288 index,
3289 message: format!(
3290 "artifact '{name}' declares per-artifact 'capabilities:' but \
3291 has 'runtime: {}'; per-artifact capabilities are only \
3292 recognized on 'runtime: hook', 'runtime: tool', and \
3293 'runtime: driver' entries — use the capsule-wide top-level \
3294 'capabilities:' block instead",
3295 runtime.as_str()
3296 ),
3297 });
3298 }
3299 if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
3305 return Err(RuntimeManifestError::InvalidArtifact {
3306 index,
3307 message: format!(
3308 "artifact '{name}' declares 'capabilities.task_io:' but has \
3309 'runtime: {}'; the key grants the murmur:task-io/read host \
3310 import and is only recognized on 'runtime: hook' entries",
3311 runtime.as_str()
3312 ),
3313 });
3314 }
3315 if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
3319 return Err(RuntimeManifestError::InvalidArtifact {
3320 index,
3321 message: format!(
3322 "artifact '{name}' declares 'capabilities.conversation:' but \
3323 has 'runtime: {}'; the key grants the \
3324 murmur:conversation/read host import and is only recognized \
3325 on 'runtime: hook' entries",
3326 runtime.as_str()
3327 ),
3328 });
3329 }
3330 parse_capabilities(Some(raw_caps))?
3331 }
3332 };
3333
3334 let on_overflow = match artifact.on_overflow.as_deref() {
3340 None => HookOverflowPolicy::default(),
3341 Some(raw) => {
3342 if runtime != ArtifactRuntime::Hook {
3343 return Err(RuntimeManifestError::InvalidArtifact {
3344 index,
3345 message: format!(
3346 "artifact '{name}' declares 'on_overflow:' but has \
3347 'runtime: {}'; the key governs an async hook's job queue \
3348 and is only recognized on 'runtime: hook' entries",
3349 runtime.as_str()
3350 ),
3351 });
3352 }
3353 match raw {
3354 "drop" => HookOverflowPolicy::Drop,
3355 "block" => HookOverflowPolicy::Block,
3356 other => {
3357 return Err(RuntimeManifestError::InvalidArtifact {
3358 index,
3359 message: format!(
3360 "artifact '{name}' declares unknown on_overflow \
3361 '{other}'; expected: drop, block"
3362 ),
3363 });
3364 }
3365 }
3366 }
3367 };
3368
3369 if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
3376 return Err(RuntimeManifestError::InvalidArtifact {
3377 index,
3378 message: format!(
3379 "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
3380 is recognized only on 'runtime: hook', 'runtime: tool', and \
3381 'runtime: driver' entries",
3382 runtime.as_str()
3383 ),
3384 });
3385 }
3386
3387 let gateway = match artifact.gateway {
3388 None => None,
3389 Some(raw_gateway) => {
3390 if runtime == ArtifactRuntime::Skill {
3391 return Err(RuntimeManifestError::InvalidArtifact {
3392 index,
3393 message: format!(
3394 "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
3395 the key is recognized only on 'runtime: hook', \
3396 'runtime: tool', and 'runtime: driver' entries",
3397 runtime.as_str()
3398 ),
3399 });
3400 }
3401 Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
3402 }
3403 };
3404
3405 Ok(RuntimeArtifact {
3406 name,
3407 version,
3408 runtime,
3409 source,
3410 local_source,
3411 prompt_payload,
3412 capabilities,
3413 on_overflow,
3414 config: artifact.config,
3415 gateway,
3416 })
3417 })
3418 .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
3419
3420 if raw
3424 .capabilities
3425 .as_ref()
3426 .is_some_and(|caps| caps.task_io.is_some())
3427 {
3428 return Err(RuntimeManifestError::InvalidCapabilities {
3429 field: "capabilities.task_io".to_string(),
3430 message: "is recognized only on 'runtime: hook' artifact entries, not in the \
3431 capsule-wide capabilities block — the grant is per-hook"
3432 .to_string(),
3433 });
3434 }
3435 if raw.config.is_some() {
3440 return Err(RuntimeManifestError::InvalidCapabilities {
3441 field: "config".to_string(),
3442 message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
3443 'runtime: driver' entry that reads it — there is no capsule-wide \
3444 config block"
3445 .to_string(),
3446 });
3447 }
3448 let capabilities = parse_capabilities(raw.capabilities)?;
3449 let inference = parse_inference(raw.inference)?;
3450 validate_driver_gateways(&artifacts, inference.as_ref())?;
3451 validate_driver_choices(&artifacts, inference.as_ref())?;
3452 validate_process_driver_entry(&artifacts, inference.as_ref())?;
3453
3454 if let Some(ref sp_art) = inference
3457 .as_ref()
3458 .and_then(|i| i.system_prompt_artifact.clone())
3459 {
3460 let matching = artifacts.iter().find(|a| &a.name == sp_art);
3461 match matching {
3462 None => {
3463 return Err(RuntimeManifestError::InvalidInferenceConfig {
3464 field: "inference.system_prompt_artifact".to_string(),
3465 message: format!("artifact '{sp_art}' is not declared in artifacts:"),
3466 });
3467 }
3468 Some(a) if !a.prompt_payload => {
3469 return Err(RuntimeManifestError::InvalidInferenceConfig {
3470 field: "inference.system_prompt_artifact".to_string(),
3471 message: format!(
3472 "artifact '{sp_art}' (runtime: {}) does not declare \
3473 'prompt_payload: true', which system_prompt_artifact requires",
3474 a.runtime.as_str()
3475 ),
3476 });
3477 }
3478 Some(_) => {} }
3480 }
3481 let context = parse_context(raw.context)?;
3482 let observability = parse_observability(raw.observability);
3483 let trace = raw
3484 .trace
3485 .map(|raw_trace| {
3486 let capture = resolve_trace_capture(raw_trace.capture.as_deref())?;
3487 let retain = parse_trace_retain(raw_trace.retain)?;
3488 Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
3489 })
3490 .transpose()?;
3491 let network = raw
3492 .network
3493 .map(|n| {
3494 Ok::<_, RuntimeManifestError>(NetworkConfig {
3495 internal_port: n.internal_port,
3496 authentication: parse_network_authentication(n.authentication)?,
3497 })
3498 })
3499 .transpose()?;
3500 refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
3501 let lifecycle = raw.lifecycle.map(|raw_lc| {
3502 let defaults = LifecycleConfig::default();
3503 LifecycleConfig {
3504 task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
3505 after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
3506 queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
3507 input_timeout_secs: raw_lc.input_timeout_secs,
3508 conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
3509 max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
3510 shell_grace_secs: raw_lc.shell_grace_secs.unwrap_or(defaults.shell_grace_secs),
3511 delegation_deadline_secs: raw_lc
3512 .delegation_deadline_secs
3513 .unwrap_or(defaults.delegation_deadline_secs),
3514 }
3515 });
3516
3517 let exports = parse_exports(raw.exports)?;
3518 let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
3519 validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
3520
3521 Ok(Self {
3522 name,
3523 version,
3524 artifacts,
3525 capabilities,
3526 inference,
3527 context,
3528 observability,
3529 trace,
3530 network,
3531 lifecycle,
3532 exports,
3533 control,
3534 mur_version: raw.mur_version.filter(|s| !s.trim().is_empty()),
3535 unknown_keys,
3536 })
3537 }
3538}
3539
3540fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
3542 artifacts
3543 .iter()
3544 .filter_map(|artifact| {
3545 let name = artifact.name.as_deref()?.trim();
3546 let written = artifact
3547 .gateway
3548 .as_ref()?
3549 .as_ref()?
3550 .api_key
3551 .as_ref()?
3552 .as_ref()?;
3553 let variable = parse_env_reference(written.trim())?;
3554 Some((name.to_string(), variable.to_string()))
3555 })
3556 .collect()
3557}
3558
3559fn parse_control(
3566 raw: Option<RawControl>,
3567 gateway_references: &[(String, String)],
3568 inference: Option<&InferenceConfig>,
3569) -> Result<Option<ControlConfig>, RuntimeManifestError> {
3570 let Some(raw) = raw else {
3571 return Ok(None);
3572 };
3573 let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
3574 field: field.to_string(),
3575 message,
3576 };
3577 let settings_written = control_string_list("control.settings", raw.settings)?;
3578 let secrets_written = control_string_list("control.secrets", raw.secrets)?;
3579 let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
3580 if settings_written.is_empty()
3581 && secrets_written.is_empty()
3582 && agent_settings_written.is_empty()
3583 {
3584 return Ok(None);
3585 }
3586 let Some(inference) = inference else {
3587 return Err(refuse(
3588 "control",
3589 "is declared on a capsule with no inference: block; the control surface is served on \
3590 an agent capsule's A2A listener, and a script capsule opens none"
3591 .to_string(),
3592 ));
3593 };
3594
3595 let mut settings = Vec::new();
3596 for entry in settings_written {
3597 let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
3598 let controllable: Vec<&str> = ControllableSetting::ALL
3599 .iter()
3600 .map(|setting| setting.wire_name())
3601 .collect();
3602 return Err(refuse(
3603 "control.settings",
3604 format!(
3605 "'{entry}' is not a controllable setting; the controllable settings are: {}",
3606 controllable.join(", ")
3607 ),
3608 ));
3609 };
3610 if settings.contains(&setting) {
3611 return Err(refuse(
3612 "control.settings",
3613 format!("'{entry}' is listed more than once"),
3614 ));
3615 }
3616 match setting {
3617 ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
3618 return Err(refuse(
3619 "control.settings",
3620 format!(
3621 "'{entry}' is controllable only under inference.transport: http; a \
3622 transport: {} harness sets its own output cap",
3623 inference.transport
3624 ),
3625 ));
3626 }
3627 ControllableSetting::InferenceMaxTokens => {}
3628 ControllableSetting::InferenceDriver => {
3629 check_driver_setting(inference).map_err(|message| {
3630 refuse("control.settings", format!("'{entry}' {message}"))
3631 })?;
3632 }
3633 }
3634 settings.push(setting);
3635 }
3636
3637 let mut agent_settings = Vec::new();
3638 for entry in agent_settings_written {
3639 let Some(setting) = ControllableSetting::from_wire_name(&entry)
3640 .filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
3641 else {
3642 let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
3643 .iter()
3644 .map(|setting| setting.wire_name())
3645 .collect();
3646 return Err(refuse(
3647 "control.agent_settings",
3648 format!(
3649 "'{entry}' is not a setting the agent may change; control.agent_settings \
3650 accepts only: {}",
3651 settable.join(", ")
3652 ),
3653 ));
3654 };
3655 if agent_settings.contains(&setting) {
3656 return Err(refuse(
3657 "control.agent_settings",
3658 format!("'{entry}' is listed more than once"),
3659 ));
3660 }
3661 check_driver_setting(inference)
3662 .map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
3663 agent_settings.push(setting);
3664 }
3665
3666 let inference_driver = (inference.transport == "http")
3667 .then(|| {
3668 inference
3669 .driver
3670 .as_ref()
3671 .map(|driver| driver.artifact.as_str())
3672 })
3673 .flatten();
3674 let mut secrets: Vec<String> = Vec::new();
3675 for entry in secrets_written {
3676 if !is_valid_env_variable(&entry) {
3677 return Err(refuse(
3678 "control.secrets",
3679 format!(
3680 "'{entry}' is not a credential name; a name is uppercase letters, digits and \
3681 underscores, and does not start with a digit"
3682 ),
3683 ));
3684 }
3685 if secrets.contains(&entry) {
3686 return Err(refuse(
3687 "control.secrets",
3688 format!("'{entry}' is listed more than once"),
3689 ));
3690 }
3691 let backing: Vec<&str> = gateway_references
3692 .iter()
3693 .filter(|(_, variable)| *variable == entry)
3694 .map(|(artifact, _)| artifact.as_str())
3695 .collect();
3696 if backing.is_empty() {
3697 return Err(refuse(
3698 "control.secrets",
3699 format!(
3700 "'{entry}' is not referenced by any artifact's gateway.api_key as \
3701 ${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
3702 ),
3703 ));
3704 }
3705 if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
3706 return Err(refuse(
3707 "control.secrets",
3708 format!(
3709 "'{entry}' keys the configured inference driver's gateway \
3710 (artifacts.{driver}.gateway.api_key); the inference credential is resolved \
3711 at launch and cannot be supplied by a controller"
3712 ),
3713 ));
3714 }
3715 secrets.push(entry);
3716 }
3717
3718 Ok(Some(ControlConfig {
3719 settings,
3720 secrets,
3721 agent_settings,
3722 }))
3723}
3724
3725fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
3728 if inference.transport != "http" {
3729 return Err(format!(
3730 "is controllable only under inference.transport: http; a transport: {} harness owns \
3731 the model it runs",
3732 inference.transport
3733 ));
3734 }
3735 if inference.alternates.is_empty() {
3736 return Err(
3737 "selects among inference.alternates, and this capsule declares none; declare an \
3738 alternate to switch to"
3739 .to_string(),
3740 );
3741 }
3742 Ok(())
3743}
3744
3745fn control_string_list(
3747 field: &str,
3748 value: Option<serde_yaml::Value>,
3749) -> Result<Vec<String>, RuntimeManifestError> {
3750 let refuse = || RuntimeManifestError::InvalidControl {
3751 field: field.to_string(),
3752 message: "must be a list of names".to_string(),
3753 };
3754 let items = match value {
3755 None | Some(serde_yaml::Value::Null) => return Ok(Vec::new()),
3756 Some(serde_yaml::Value::Sequence(items)) => items,
3757 Some(_) => return Err(refuse()),
3758 };
3759 items
3760 .into_iter()
3761 .map(|item| match item {
3762 serde_yaml::Value::String(name) => Ok(name.trim().to_string()),
3763 _ => Err(refuse()),
3764 })
3765 .collect()
3766}
3767
3768fn parse_network_authentication(
3770 raw: Option<RawNetworkAuthentication>,
3771) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
3772 let Some(raw) = raw else {
3773 return Ok(None);
3774 };
3775 let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
3776 field: field.to_string(),
3777 message,
3778 };
3779 let schemes: Vec<&str> = AuthenticationScheme::ALL
3780 .iter()
3781 .map(|scheme| scheme.wire_name())
3782 .collect();
3783 let scheme = match raw.scheme.as_deref().map(str::trim) {
3784 None | Some("") => {
3785 return Err(refuse(
3786 "network.authentication.scheme",
3787 format!("is required; the schemes are: {}", schemes.join(", ")),
3788 ))
3789 }
3790 Some(written) => AuthenticationScheme::ALL
3791 .iter()
3792 .copied()
3793 .find(|scheme| scheme.wire_name() == written)
3794 .ok_or_else(|| {
3795 refuse(
3796 "network.authentication.scheme",
3797 format!(
3798 "'{written}' is not a door authentication scheme; the schemes are: {}",
3799 schemes.join(", ")
3800 ),
3801 )
3802 })?,
3803 };
3804
3805 let mut credentials = Vec::new();
3806 for (name, credential) in raw.credentials.unwrap_or_default() {
3807 if name == OPERATOR_CREDENTIAL {
3808 return Err(refuse(
3809 "network.authentication.credentials",
3810 format!(
3811 "'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
3812 token, which holds every scope"
3813 ),
3814 ));
3815 }
3816 if !is_valid_credential_name(&name) {
3817 return Err(refuse(
3818 "network.authentication.credentials",
3819 format!(
3820 "'{name}' is not a credential name; a name is a lowercase letter followed by \
3821 at most {} lowercase letters, digits, '_' or '-'",
3822 MAX_CREDENTIAL_NAME_LEN - 1
3823 ),
3824 ));
3825 }
3826 let field = format!("network.authentication.credentials.{name}.scopes");
3827 let scopes = door_scope_list(&field, credential.scopes)?;
3828 credentials.push(DoorCredential { name, scopes });
3829 }
3830
3831 Ok(Some(NetworkAuthentication {
3832 scheme,
3833 credentials,
3834 }))
3835}
3836
3837fn is_valid_credential_name(name: &str) -> bool {
3839 let mut chars = name.chars();
3840 chars.next().is_some_and(|first| first.is_ascii_lowercase())
3841 && name.len() <= MAX_CREDENTIAL_NAME_LEN
3842 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
3843}
3844
3845fn door_scope_list(
3847 field: &str,
3848 value: Option<serde_yaml::Value>,
3849) -> Result<Vec<String>, RuntimeManifestError> {
3850 let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
3851 field: field.to_string(),
3852 message,
3853 };
3854 let items = match value {
3855 None | Some(serde_yaml::Value::Null) => {
3856 return Err(refuse(
3857 "is required: a credential reaches only the scopes it lists".to_string(),
3858 ))
3859 }
3860 Some(serde_yaml::Value::Sequence(items)) => items,
3861 Some(_) => return Err(refuse("must be a list of scopes".to_string())),
3862 };
3863 if items.is_empty() {
3864 return Err(refuse(
3865 "lists no scope; a credential must reach at least one".to_string(),
3866 ));
3867 }
3868 let mut scopes: Vec<String> = Vec::new();
3869 for item in items {
3870 let serde_yaml::Value::String(scope) = item else {
3871 return Err(refuse("must be a list of scopes".to_string()));
3872 };
3873 let scope = scope.trim().to_string();
3874 if !DOOR_SCOPES.contains(&scope.as_str()) {
3875 return Err(refuse(format!(
3876 "'{scope}' is not a door scope; the scopes are: {}",
3877 DOOR_SCOPES.join(", ")
3878 )));
3879 }
3880 if scopes.contains(&scope) {
3881 return Err(refuse(format!("'{scope}' is listed more than once")));
3882 }
3883 scopes.push(scope);
3884 }
3885 Ok(scopes)
3886}
3887
3888fn refuse_authenticated_delegating_parent(
3893 network: Option<&NetworkConfig>,
3894 capabilities: Option<&Capabilities>,
3895) -> Result<(), RuntimeManifestError> {
3896 let authenticated = network.is_some_and(|network| network.authentication.is_some());
3897 let delegates = capabilities
3898 .and_then(|capabilities| capabilities.spawn.as_ref())
3899 .is_some_and(|spawn| !spawn.allow.is_empty());
3900 if authenticated && delegates {
3901 return Err(RuntimeManifestError::InvalidNetworkConfig {
3902 field: "network.authentication".to_string(),
3903 message: "cannot be declared together with capabilities.spawn.allow: a delegated \
3904 child posts its outcome to this door, and a delegated child cannot yet be \
3905 issued a token for this door"
3906 .to_string(),
3907 });
3908 }
3909 Ok(())
3910}
3911
3912fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
3919 let Some(raw_exports) = raw else {
3920 return Ok(None);
3921 };
3922 let files = raw_exports.files.map(parse_file_export).transpose()?;
3923 let peer_files = raw_exports
3924 .peer_files
3925 .map(parse_peer_files_export)
3926 .transpose()?;
3927 let peer_tasks = raw_exports
3928 .peer_tasks
3929 .map(|raw| {
3930 raw.accept
3931 .map(|accept| PeerTasksExport { accept })
3932 .ok_or_else(|| RuntimeManifestError::InvalidExports {
3933 field: "exports.peer_tasks.accept".to_string(),
3934 message: "must be set explicitly to true or false — consent to peer tasks is \
3935 never inferred"
3936 .to_string(),
3937 })
3938 })
3939 .transpose()?;
3940 Ok(Some(Exports {
3941 files,
3942 peer_files,
3943 peer_tasks,
3944 }))
3945}
3946
3947fn parse_peer_files_export(
3955 raw: RawPeerFilesExport,
3956) -> Result<PeerFilesExport, RuntimeManifestError> {
3957 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
3958 field: field.to_string(),
3959 message,
3960 };
3961
3962 let root = raw
3963 .root
3964 .map(|root| root.trim().to_string())
3965 .filter(|root| !root.is_empty())
3966 .ok_or_else(|| {
3967 invalid(
3968 "exports.peer_files.root",
3969 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
3970 )
3971 })?;
3972 check_export_root_shape("exports.peer_files.root", &root)?;
3973
3974 let max_ttl_secs = match raw.max_ttl {
3975 None => None,
3976 Some(value) => {
3977 let text = scalar_text(&value).ok_or_else(|| {
3978 invalid(
3979 "exports.peer_files.max_ttl",
3980 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
3981 )
3982 })?;
3983 let parsed = parse_duration_secs(&text)
3984 .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
3985 if parsed == 0 {
3986 return Err(invalid(
3987 "exports.peer_files.max_ttl",
3988 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
3989 ));
3990 }
3991 Some(parsed)
3992 }
3993 };
3994
3995 let max_bytes = match raw.max_bytes {
3996 None => DEFAULT_PEER_FILES_MAX_BYTES,
3997 Some(value) => {
3998 let text = scalar_text(&value).ok_or_else(|| {
3999 invalid(
4000 "exports.peer_files.max_bytes",
4001 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4002 )
4003 })?;
4004 let parsed = parse_byte_size(&text)
4005 .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
4006 if parsed == 0 {
4007 return Err(invalid(
4008 "exports.peer_files.max_bytes",
4009 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4010 ));
4011 }
4012 parsed
4013 }
4014 };
4015
4016 Ok(PeerFilesExport {
4017 root,
4018 max_ttl_secs,
4019 max_bytes,
4020 })
4021}
4022
4023fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_yaml::Value>, D::Error>
4031where
4032 D: serde::Deserializer<'de>,
4033{
4034 serde_yaml::Value::deserialize(deserializer).map(Some)
4035}
4036
4037fn scalar_text(value: &serde_yaml::Value) -> Option<String> {
4038 match value {
4039 serde_yaml::Value::String(text) => Some(text.clone()),
4040 serde_yaml::Value::Number(number) => Some(number.to_string()),
4041 _ => None,
4042 }
4043}
4044
4045fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
4049 for component in Path::new(root).components() {
4050 match component {
4051 std::path::Component::Normal(_) | std::path::Component::CurDir => {}
4052 std::path::Component::ParentDir => {
4053 return Err(RuntimeManifestError::InvalidExports {
4054 field: field.to_string(),
4055 message: format!(
4056 "'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}"
4057 ),
4058 });
4059 }
4060 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
4061 return Err(RuntimeManifestError::InvalidExports {
4062 field: field.to_string(),
4063 message: format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}"),
4064 });
4065 }
4066 }
4067 }
4068 Ok(())
4069}
4070
4071fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
4072 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4073 field: field.to_string(),
4074 message,
4075 };
4076
4077 let root = raw
4078 .root
4079 .map(|root| root.trim().to_string())
4080 .filter(|root| !root.is_empty())
4081 .ok_or_else(|| {
4082 invalid(
4083 "exports.files.root",
4084 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4085 )
4086 })?;
4087 check_export_root_shape("exports.files.root", &root)?;
4088
4089 let mode = match raw.mode.as_deref().map(str::trim) {
4090 None | Some("") => {
4091 return Err(invalid(
4092 "exports.files.mode",
4093 "is required and must be 'read-only'".to_string(),
4094 ));
4095 }
4096 Some("read-only") => ExportMode::ReadOnly,
4097 Some(other) => {
4098 return Err(invalid(
4099 "exports.files.mode",
4100 format!("'{other}' must be 'read-only'"),
4101 ));
4102 }
4103 };
4104
4105 let max_bytes = match raw.max_bytes {
4106 None => DEFAULT_EXPORT_MAX_BYTES,
4107 Some(value) => {
4108 let Some(text) = scalar_text(&value) else {
4109 return Err(invalid(
4110 "exports.files.max_bytes",
4111 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4112 ));
4113 };
4114 let parsed = parse_byte_size(&text)
4115 .map_err(|message| invalid("exports.files.max_bytes", message))?;
4116 if parsed == 0 {
4117 return Err(invalid(
4118 "exports.files.max_bytes",
4119 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4120 ));
4121 }
4122 parsed
4123 }
4124 };
4125
4126 Ok(FileExport {
4127 root,
4128 mode,
4129 max_bytes,
4130 })
4131}
4132
4133const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
4136
4137pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
4139
4140fn check_network_destination_shape(entry: &str) -> Result<(), String> {
4147 let trimmed = entry.trim();
4148 if trimmed.is_empty() {
4149 return Err(format!(
4150 "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4151 ));
4152 }
4153
4154 if trimmed.contains("://") {
4155 let url = Url::parse(trimmed).map_err(|error| {
4156 format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
4157 })?;
4158 let scheme = url.scheme().to_ascii_lowercase();
4159 if scheme != "http" && scheme != "https" {
4160 return Err(format!(
4161 "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
4162 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4163 ));
4164 }
4165 if url.host_str().is_none_or(str::is_empty) {
4166 return Err(format!(
4167 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4168 ));
4169 }
4170 if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
4171 return Err(format!(
4172 "'{entry}' must not include a path, query or fragment; \
4173 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4174 ));
4175 }
4176 return Ok(());
4177 }
4178
4179 if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
4180 return Err(format!(
4181 "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4182 ));
4183 }
4184 if let Some((host, port)) = trimmed.rsplit_once(':') {
4186 if !host.ends_with(']') {
4187 if host.is_empty() {
4188 return Err(format!(
4189 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4190 ));
4191 }
4192 if port.parse::<u16>().is_err() {
4193 return Err(format!(
4194 "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4195 ));
4196 }
4197 }
4198 }
4199 Ok(())
4200}
4201
4202fn parse_capabilities(
4203 raw: Option<RawCapabilities>,
4204) -> Result<Option<Capabilities>, RuntimeManifestError> {
4205 let Some(raw_caps) = raw else {
4206 return Ok(None);
4207 };
4208
4209 let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
4210 allow: raw_network.allow,
4211 unix_sockets: raw_network.unix_sockets,
4212 });
4213
4214 let peer_fetch = raw_caps
4215 .peer_fetch
4216 .map(|raw_peer_fetch| {
4217 if raw_peer_fetch.allow.is_empty() {
4218 return Err(RuntimeManifestError::InvalidCapabilities {
4219 field: "capabilities.peer_fetch.allow".to_string(),
4220 message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
4221 });
4222 }
4223 for entry in &raw_peer_fetch.allow {
4224 check_network_destination_shape(entry).map_err(|message| {
4225 RuntimeManifestError::InvalidCapabilities {
4226 field: "capabilities.peer_fetch.allow".to_string(),
4227 message,
4228 }
4229 })?;
4230 }
4231 Ok(PeerFetchCapabilities {
4232 allow: raw_peer_fetch.allow,
4233 })
4234 })
4235 .transpose()?;
4236
4237 let filesystem = raw_caps
4238 .filesystem
4239 .map(|raw_filesystem| FilesystemCapabilities {
4240 scope: raw_filesystem.scope.and_then(|scope| {
4241 let trimmed = scope.trim();
4242 (!trimmed.is_empty()).then(|| trimmed.to_string())
4243 }),
4244 workdir_exec: raw_filesystem.workdir_exec,
4249 read_only: raw_filesystem
4253 .read_only
4254 .into_iter()
4255 .filter_map(|entry| {
4256 let trimmed = entry.trim();
4257 (!trimmed.is_empty()).then(|| trimmed.to_string())
4258 })
4259 .collect(),
4260 });
4261
4262 let shell = raw_caps
4263 .shell
4264 .map(|raw_shell| {
4265 if raw_shell.allow.is_empty() {
4266 return Err(RuntimeManifestError::InvalidCapabilities {
4267 field: "capabilities.shell.allow".to_string(),
4268 message: "must contain at least one binary".to_string(),
4269 });
4270 }
4271
4272 let interpreter_runtime =
4273 parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
4274 let staged_runtime = parse_staged_runtime(
4277 &raw_shell.allow,
4278 &interpreter_runtime,
4279 raw_shell.staged_runtime,
4280 )?;
4281
4282 Ok(ShellCapabilities {
4283 allow: raw_shell.allow,
4284 strip_env: raw_shell.strip_env,
4285 baseline_env: raw_shell.baseline_env,
4286 interpreter_runtime,
4287 staged_runtime,
4288 })
4289 })
4290 .transpose()?;
4291
4292 let spawn = raw_caps.spawn.map(|raw_spawn| SpawnCapabilities {
4293 allow: raw_spawn.allow,
4294 });
4295
4296 let env = raw_caps.env.map(|raw_env| EnvCapabilities {
4297 allow: raw_env.allow,
4298 });
4299
4300 let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
4301
4302 let resources = raw_caps
4303 .resources
4304 .map(parse_resource_capabilities)
4305 .transpose()?;
4306
4307 let state = raw_caps.state.map(parse_state_capabilities);
4308
4309 let task_io = raw_caps
4310 .task_io
4311 .map(|raw_task_io| {
4312 raw_task_io
4313 .read
4314 .map(|read| TaskIoCapabilities { read })
4315 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4316 field: "capabilities.task_io.read".to_string(),
4317 message: "must be set explicitly to true or false — a capability is never \
4318 inferred"
4319 .to_string(),
4320 })
4321 })
4322 .transpose()?;
4323
4324 let conversation = raw_caps
4325 .conversation
4326 .map(|raw_conversation| {
4327 raw_conversation
4328 .read
4329 .map(|read| ConversationCapabilities { read })
4330 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4331 field: "capabilities.conversation.read".to_string(),
4332 message: "must be set explicitly to true or false — a capability is never \
4333 inferred"
4334 .to_string(),
4335 })
4336 })
4337 .transpose()?;
4338
4339 let plan = raw_caps
4340 .plan
4341 .map(|raw_plan| {
4342 raw_plan
4343 .submit
4344 .map(|submit| PlanCapabilities { submit })
4345 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4346 field: "capabilities.plan.submit".to_string(),
4347 message: "must be set explicitly to true or false — a capability is never \
4348 inferred"
4349 .to_string(),
4350 })
4351 })
4352 .transpose()?;
4353
4354 let install = raw_caps.install.map(parse_install).transpose()?;
4355
4356 let containment = raw_caps
4357 .containment
4358 .as_deref()
4359 .map(str::trim)
4360 .filter(|value| !value.is_empty())
4361 .map(|value| {
4362 value.parse::<ContainmentClass>().map_err(|error| {
4363 RuntimeManifestError::InvalidCapabilities {
4364 field: "capabilities.containment".to_string(),
4365 message: error.to_string(),
4366 }
4367 })
4368 })
4369 .transpose()?;
4370
4371 Ok(Some(Capabilities {
4372 network,
4373 peer_fetch,
4374 filesystem,
4375 shell,
4376 spawn,
4377 env,
4378 limits,
4379 resources,
4380 state,
4381 task_io,
4382 conversation,
4383 plan,
4384 install,
4385 containment,
4386 }))
4387}
4388
4389fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
4392 if raw.skill.is_empty() && raw.tool.is_empty() {
4393 return Err(RuntimeManifestError::InvalidCapabilities {
4394 field: "capabilities.install".to_string(),
4395 message: "must name at least one 'skill' or 'tool' entry — a capability is never \
4396 inferred; omit the block to grant no install at all"
4397 .to_string(),
4398 });
4399 }
4400 for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
4401 for entry in entries {
4402 crate::install_pattern::validate(entry).map_err(|reason| {
4403 RuntimeManifestError::InvalidCapabilities {
4404 field: format!("capabilities.install.{key}"),
4405 message: format!(
4406 "entry {entry:?} is invalid ({reason}); expected {}",
4407 crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
4408 ),
4409 }
4410 })?;
4411 }
4412 }
4413 Ok(InstallCapabilities {
4414 skill: raw.skill,
4415 tool: raw.tool,
4416 })
4417}
4418
4419fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
4429 StateCapabilities {
4430 store: raw.store.map(|store| store.trim().to_string()),
4431 }
4432}
4433
4434fn parse_interpreter_runtime(
4440 allow: &[String],
4441 raw_grants: Vec<RawInterpreterRuntimeGrant>,
4442) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
4443 let mut grants = Vec::with_capacity(raw_grants.len());
4444 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
4445 let base = format!("capabilities.shell.interpreter_runtime[{index}]");
4446
4447 let binary = raw_grant
4448 .binary
4449 .filter(|b| !b.trim().is_empty())
4450 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4451 field: format!("{base}.binary"),
4452 message: "must name a binary".to_string(),
4453 })?;
4454
4455 if !allow.contains(&binary) {
4459 return Err(RuntimeManifestError::InvalidCapabilities {
4460 field: format!("{base}.binary"),
4461 message: format!(
4462 "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
4463 only narrow filesystem access for an already-allowlisted binary, never \
4464 grant exec"
4465 ),
4466 });
4467 }
4468
4469 if raw_grant.dirs.is_empty() {
4470 return Err(RuntimeManifestError::InvalidCapabilities {
4471 field: format!("{base}.dirs"),
4472 message: "must name at least one directory".to_string(),
4473 });
4474 }
4475
4476 let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
4477 for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
4478 let dir_base = format!("{base}.dirs[{dir_index}]");
4479
4480 let path = raw_dir
4481 .path
4482 .filter(|p| !p.trim().is_empty())
4483 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4484 field: format!("{dir_base}.path"),
4485 message: "must name a host directory".to_string(),
4486 })?;
4487 if !path.starts_with('/') {
4488 return Err(RuntimeManifestError::InvalidCapabilities {
4489 field: format!("{dir_base}.path"),
4490 message: format!(
4491 "'{path}' must be an absolute host path (start with '/') — these are \
4492 filesystem paths outside the workdir"
4493 ),
4494 });
4495 }
4496
4497 let list_dir =
4498 raw_dir
4499 .list_dir
4500 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4501 field: format!("{dir_base}.list_dir"),
4502 message: format!(
4503 "'{path}' must set list_dir explicitly to true or false — \
4504 enumerability is never inferred"
4505 ),
4506 })?;
4507
4508 dirs.push(InterpreterRuntimeDir { path, list_dir });
4509 }
4510
4511 grants.push(InterpreterRuntimeGrant { binary, dirs });
4512 }
4513
4514 Ok(grants)
4515}
4516
4517fn parse_staged_runtime(
4538 allow: &[String],
4539 interpreter_runtime: &[InterpreterRuntimeGrant],
4540 raw_grants: Vec<RawStagedRuntimeGrant>,
4541) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
4542 let mut grants = Vec::with_capacity(raw_grants.len());
4543 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
4544 let base = format!("capabilities.shell.staged_runtime[{index}]");
4545
4546 let binary = raw_grant
4547 .binary
4548 .filter(|b| !b.trim().is_empty())
4549 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4550 field: format!("{base}.binary"),
4551 message: "must name a binary".to_string(),
4552 })?;
4553
4554 if !allow.contains(&binary) {
4555 return Err(RuntimeManifestError::InvalidCapabilities {
4556 field: format!("{base}.binary"),
4557 message: format!(
4558 "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
4559 stage the runtime tree behind an already-allowlisted binary, never grant exec"
4560 ),
4561 });
4562 }
4563
4564 if interpreter_runtime
4565 .iter()
4566 .any(|grant| grant.binary == binary)
4567 {
4568 return Err(RuntimeManifestError::InvalidCapabilities {
4569 field: format!("{base}.binary"),
4570 message: format!(
4571 "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
4572 are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
4573 tree into the capsule's own root, which is what makes widening the capsule's \
4574 host filesystem scope unnecessary rather than something to add to it. Remove \
4575 the interpreter_runtime grant for '{binary}'"
4576 ),
4577 });
4578 }
4579
4580 let source_path = raw_grant
4581 .source_path
4582 .filter(|p| !p.trim().is_empty())
4583 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4584 field: format!("{base}.source_path"),
4585 message: "must name the host directory holding the pinned runtime tree".to_string(),
4586 })?;
4587 if !source_path.starts_with('/') {
4588 return Err(RuntimeManifestError::InvalidCapabilities {
4589 field: format!("{base}.source_path"),
4590 message: format!(
4591 "'{source_path}' must be an absolute host path (start with '/') — \
4592 staged-runtime sources are host filesystem paths outside the workdir"
4593 ),
4594 });
4595 }
4596
4597 let pin = raw_grant
4598 .pin
4599 .map(|p| p.trim().to_string())
4600 .filter(|p| !p.is_empty())
4601 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
4602 field: format!("{base}.pin"),
4603 message: format!(
4604 "'{source_path}' must declare a non-empty pin identifying which build this \
4605 tree is — it is never inferred, because it is the value a human compares \
4606 across two hosts to confirm the same runtime shipped to both"
4607 ),
4608 })?;
4609
4610 grants.push(StagedRuntimeGrant {
4611 binary,
4612 source_path,
4613 pin,
4614 });
4615 }
4616
4617 Ok(grants)
4618}
4619
4620fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
4625 let reject_zero_usize =
4626 |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
4627 match value {
4628 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
4629 field: format!("capabilities.limits.{field}"),
4630 message: "must be greater than zero".to_string(),
4631 }),
4632 _ => Ok(()),
4633 }
4634 };
4635
4636 reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
4637 reject_zero_usize(raw.table_elements, "table_elements")?;
4638 reject_zero_usize(raw.instances, "instances")?;
4639 if raw.deadline_seconds == Some(0) {
4640 return Err(RuntimeManifestError::InvalidCapabilities {
4641 field: "capabilities.limits.deadline_seconds".to_string(),
4642 message: "must be greater than zero".to_string(),
4643 });
4644 }
4645
4646 Ok(ResourceLimits {
4647 memory_bytes: raw.memory_bytes,
4648 table_elements: raw.table_elements,
4649 instances: raw.instances,
4650 deadline_seconds: raw.deadline_seconds,
4651 })
4652}
4653
4654fn parse_resource_capabilities(
4660 raw: RawResourceCapabilities,
4661) -> Result<ResourceCapabilities, RuntimeManifestError> {
4662 let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
4663 match value {
4664 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
4665 field: format!("capabilities.resources.{field}"),
4666 message: "must be greater than zero".to_string(),
4667 }),
4668 _ => Ok(()),
4669 }
4670 };
4671
4672 reject_zero(raw.max_processes, "max_processes")?;
4673 reject_zero(raw.max_open_files, "max_open_files")?;
4674 reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
4675 reject_zero(raw.cpu_seconds, "cpu_seconds")?;
4676 reject_zero(raw.memory_bytes, "memory_bytes")?;
4677 reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
4678 reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
4679 reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
4680 reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
4681 reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
4682
4683 Ok(ResourceCapabilities {
4684 max_processes: raw.max_processes,
4685 max_open_files: raw.max_open_files,
4686 max_file_size_bytes: raw.max_file_size_bytes,
4687 cpu_seconds: raw.cpu_seconds,
4688 memory_bytes: raw.memory_bytes,
4689 cgroup_memory_bytes: raw.cgroup_memory_bytes,
4690 cgroup_pids_max: raw.cgroup_pids_max,
4691 cgroup_cpu_percent: raw.cgroup_cpu_percent,
4692 cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
4693 workdir_max_bytes: raw.workdir_max_bytes,
4694 })
4695}
4696
4697const PROCESS_DRIVER_REQUIRED: &str =
4701 "missing required field; a transport: process capsule must name the process driver that \
4702 knows how to drive its harness. inference.command no longer selects a harness — it only \
4703 overrides the binary the driver names. Add inference.driver.artifact naming a process \
4704 driver, for example murmur-driver-claude-code";
4705
4706fn parse_inference(
4707 raw: Option<RawInferenceConfig>,
4708) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
4709 let Some(raw) = raw else {
4710 return Ok(None);
4711 };
4712
4713 let transport = raw
4714 .transport
4715 .map(|s| s.trim().to_string())
4716 .filter(|s| !s.is_empty())
4717 .unwrap_or_else(|| "http".to_string());
4718
4719 let compaction = parse_compaction(raw.compaction)?;
4720 let system_prompt = optional_trimmed_string(raw.system_prompt);
4721 let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
4722 let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
4723
4724 let prompt_sources_set = [
4725 system_prompt.is_some(),
4726 system_prompt_file.is_some(),
4727 system_prompt_artifact.is_some(),
4728 ]
4729 .iter()
4730 .filter(|&&b| b)
4731 .count();
4732 if prompt_sources_set > 1 {
4733 return Err(RuntimeManifestError::InvalidInferenceConfig {
4734 field: "inference.system_prompt".to_string(),
4735 message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
4736 may be set"
4737 .to_string(),
4738 });
4739 }
4740
4741 let max_turns = match raw.max_turns {
4742 None => 10,
4743 Some(0) => {
4744 return Err(RuntimeManifestError::InvalidInferenceConfig {
4745 field: "inference.max_turns".to_string(),
4746 message: "must be greater than 0".to_string(),
4747 });
4748 }
4749 Some(n) => n,
4750 };
4751
4752 if raw.max_task_reopens.is_some() {
4756 return Err(RuntimeManifestError::InvalidInferenceConfig {
4757 field: "inference.max_task_reopens".to_string(),
4758 message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
4759 .to_string(),
4760 });
4761 }
4762
4763 for (present, removed, moved_to) in [
4767 (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
4768 (raw.api_key.is_some(), "api_key", "gateway.api_key"),
4769 ] {
4770 if !present {
4771 continue;
4772 }
4773 let entry = raw
4774 .driver
4775 .as_ref()
4776 .and_then(|driver| driver.artifact.as_deref())
4777 .map(str::trim)
4778 .filter(|name| !name.is_empty())
4779 .map(|name| format!("on the artifacts: entry '{name}'"))
4780 .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
4781 return Err(RuntimeManifestError::InvalidInferenceConfig {
4782 field: format!("inference.{removed}"),
4783 message: format!("was removed; set {moved_to} {entry} instead"),
4784 });
4785 }
4786
4787 match transport.as_str() {
4788 "http" => {
4789 let model = required_inference_field(raw.model, "model")?;
4790
4791 let driver_raw =
4792 raw.driver
4793 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
4794 field: "inference.driver".to_string(),
4795 message: "missing required field".to_string(),
4796 })?;
4797 let driver = parse_inference_driver(driver_raw)?;
4798
4799 if raw.max_tokens == Some(0) {
4803 return Err(RuntimeManifestError::InvalidInferenceConfig {
4804 field: "inference.max_tokens".to_string(),
4805 message: "must be greater than 0".to_string(),
4806 });
4807 }
4808 if raw.max_session_tokens == Some(0) {
4809 return Err(RuntimeManifestError::InvalidInferenceConfig {
4810 field: "inference.max_session_tokens".to_string(),
4811 message: "must be greater than 0".to_string(),
4812 });
4813 }
4814 let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
4815 let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
4816
4817 Ok(Some(InferenceConfig {
4818 transport,
4819 model,
4820 driver: Some(driver),
4821 command: None,
4822 compaction,
4823 system_prompt,
4824 system_prompt_file,
4825 system_prompt_artifact,
4826 max_turns,
4827 max_tokens: raw.max_tokens,
4828 max_session_tokens: raw.max_session_tokens,
4829 tool_refresh,
4830 alternates,
4831 }))
4832 }
4833 "process" => {
4834 if raw.max_tokens.is_some() {
4837 return Err(RuntimeManifestError::InvalidInferenceConfig {
4838 field: "inference.max_tokens".to_string(),
4839 message: "is not valid with transport: process".to_string(),
4840 });
4841 }
4842 if raw.tool_refresh.is_some() {
4845 return Err(RuntimeManifestError::InvalidInferenceConfig {
4846 field: "inference.tool_refresh".to_string(),
4847 message: "is not valid with transport: process".to_string(),
4848 });
4849 }
4850 if raw.alternates.is_some() {
4853 return Err(RuntimeManifestError::InvalidInferenceConfig {
4854 field: "inference.alternates".to_string(),
4855 message: "is not valid with transport: process; the harness owns the \
4856 conversation and the model it runs"
4857 .to_string(),
4858 });
4859 }
4860
4861 if raw.max_session_tokens == Some(0) {
4865 return Err(RuntimeManifestError::InvalidInferenceConfig {
4866 field: "inference.max_session_tokens".to_string(),
4867 message: "must be greater than 0".to_string(),
4868 });
4869 }
4870
4871 let driver = raw.driver.map(parse_inference_driver).transpose()?;
4872 let command = optional_trimmed_string(raw.command);
4873 if driver.is_none() {
4877 return Err(RuntimeManifestError::InvalidInferenceConfig {
4878 field: "inference.driver.artifact".to_string(),
4879 message: PROCESS_DRIVER_REQUIRED.to_string(),
4880 });
4881 }
4882 let model = optional_trimmed_string(raw.model).unwrap_or_default();
4886
4887 Ok(Some(InferenceConfig {
4888 transport,
4889 model,
4890 driver,
4891 command,
4892 compaction,
4893 system_prompt,
4894 system_prompt_file,
4895 system_prompt_artifact,
4896 max_turns,
4897 max_tokens: None,
4898 max_session_tokens: raw.max_session_tokens,
4899 tool_refresh: ToolRefresh::default(),
4900 alternates: Vec::new(),
4901 }))
4902 }
4903 other => Err(RuntimeManifestError::InvalidInferenceConfig {
4904 field: "inference.transport".to_string(),
4905 message: format!("unknown value '{other}'"),
4906 }),
4907 }
4908}
4909
4910fn is_valid_driver_choice_name(name: &str) -> bool {
4913 let mut chars = name.chars();
4914 chars.next().is_some_and(|first| first.is_ascii_lowercase())
4915 && name.len() <= 63
4916 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
4917}
4918
4919fn parse_alternates(
4925 raw: Option<Vec<RawInferenceAlternate>>,
4926 primary_driver: &str,
4927 primary_model: &str,
4928) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
4929 let mut alternates: Vec<InferenceAlternate> = Vec::new();
4930 for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
4931 let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
4932 field: if key.is_empty() {
4933 format!("inference.alternates[{index}]")
4934 } else {
4935 format!("inference.alternates[{index}].{key}")
4936 },
4937 message,
4938 };
4939 let name = optional_trimmed_string(entry.name)
4940 .ok_or_else(|| refuse("name", "missing required field".to_string()))?;
4941 if name == PRIMARY_DRIVER_CHOICE {
4942 return Err(refuse(
4943 "name",
4944 format!(
4945 "'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
4946 inference.model; name the alternate something else"
4947 ),
4948 ));
4949 }
4950 if !is_valid_driver_choice_name(&name) {
4951 return Err(refuse(
4952 "name",
4953 format!(
4954 "'{name}' is not a driver choice name; a name is a lowercase letter followed \
4955 by up to 62 lowercase letters, digits and hyphens"
4956 ),
4957 ));
4958 }
4959 if alternates.iter().any(|alternate| alternate.name == name) {
4960 return Err(refuse(
4961 "name",
4962 format!("'{name}' names more than one alternate"),
4963 ));
4964 }
4965 let model = optional_trimmed_string(entry.model)
4966 .ok_or_else(|| refuse("model", "missing required field".to_string()))?;
4967 let Some(driver) = entry.driver else {
4968 return Err(refuse(
4969 "driver.artifact",
4970 "missing required field".to_string(),
4971 ));
4972 };
4973 if driver
4974 .config
4975 .as_ref()
4976 .is_some_and(|config| !matches!(config, serde_yaml::Value::Null))
4977 {
4978 return Err(refuse(
4979 "driver.config",
4980 "is not valid on an alternate; inference.driver.config applies to every \
4981 declared driver, and per-driver settings belong in that driver's \
4982 artifacts[].config"
4983 .to_string(),
4984 ));
4985 }
4986 let driver = optional_trimmed_string(driver.artifact)
4987 .ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
4988 if driver == primary_driver && model == primary_model {
4989 return Err(refuse(
4990 "",
4991 format!(
4992 "'{name}' is driver '{driver}' with model '{model}', which is the primary \
4993 choice; an alternate names a different pair"
4994 ),
4995 ));
4996 }
4997 if let Some(twin) = alternates
4998 .iter()
4999 .find(|alternate| alternate.driver == driver && alternate.model == model)
5000 {
5001 return Err(refuse(
5002 "",
5003 format!(
5004 "'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
5005 already is; an alternate names a different pair",
5006 twin.name
5007 ),
5008 ));
5009 }
5010 alternates.push(InferenceAlternate {
5011 name,
5012 model,
5013 driver,
5014 });
5015 }
5016 Ok(alternates)
5017}
5018
5019fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
5021 let Some(value) = raw else {
5022 return Ok(ToolRefresh::default());
5023 };
5024 ToolRefresh::ALL
5025 .into_iter()
5026 .find(|refresh| refresh.wire_name() == value)
5027 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5028 field: "inference.tool_refresh".to_string(),
5029 message: format!(
5030 "must be one of: {}; got '{value}'",
5031 ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
5032 ),
5033 })
5034}
5035
5036fn parse_context(
5037 raw: Option<RawContextConfig>,
5038) -> Result<Option<ContextConfig>, RuntimeManifestError> {
5039 let Some(raw) = raw else {
5040 return Ok(None);
5041 };
5042
5043 if let Some(max_tokens) = raw.max_tokens {
5044 if max_tokens == 0 {
5045 return Err(RuntimeManifestError::InvalidInferenceConfig {
5046 field: "context.max_tokens".to_string(),
5047 message: "must be greater than 0".to_string(),
5048 });
5049 }
5050 }
5051
5052 let fraction = |field: &str, value: Option<f32>, default: f32| match value {
5053 None => Ok(default),
5054 Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
5055 Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
5056 field: field.to_string(),
5057 message: "must be between 0.0 and 1.0".to_string(),
5058 }),
5059 };
5060
5061 let record = match raw.record.as_deref().map(str::trim) {
5062 None => true,
5063 Some("on") => true,
5064 Some("off") => false,
5065 Some(other) => {
5066 return Err(RuntimeManifestError::InvalidInferenceConfig {
5067 field: "context.record".to_string(),
5068 message: format!("unknown value '{other}'; expected: on, off"),
5069 })
5070 }
5071 };
5072
5073 Ok(Some(ContextConfig {
5074 max_tokens: raw.max_tokens,
5075 record,
5076 record_store: raw.record_store.map(|store| store.trim().to_string()),
5077 seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
5078 seed_overflow_margin: fraction(
5079 "context.seed_overflow_margin",
5080 raw.seed_overflow_margin,
5081 DEFAULT_SEED_OVERFLOW_MARGIN,
5082 )?,
5083 retain: parse_context_retain(raw.retain)?,
5084 }))
5085}
5086
5087const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
5089
5090const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
5092
5093const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
5096 keep everything, which is what no policy means";
5097
5098fn parse_retain_block(
5104 block: &str,
5105 count_key: &str,
5106 accepted: &[&str],
5107 raw: serde_yaml::Value,
5108 invalid: &dyn Fn(String, String) -> RuntimeManifestError,
5109) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
5110 let mapping = match raw {
5111 serde_yaml::Value::Mapping(mapping) => mapping,
5112 serde_yaml::Value::Null => {
5114 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
5115 }
5116 other => {
5117 return Err(invalid(
5118 block.to_string(),
5119 format!(
5120 "must be a block of keys ({}), got '{other:?}'",
5121 accepted.join(", ")
5122 ),
5123 ))
5124 }
5125 };
5126 if mapping.is_empty() {
5127 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
5128 }
5129
5130 let mut count: Option<u32> = None;
5131 let mut max_age_secs: Option<u64> = None;
5132 for (key, value) in &mapping {
5133 let key = key.as_str().unwrap_or_default();
5134 let field = format!("{block}.{key}");
5135 if key == count_key {
5136 let parsed = value
5137 .as_u64()
5138 .and_then(|n| u32::try_from(n).ok())
5139 .ok_or_else(|| {
5140 invalid(
5141 field.clone(),
5142 "must be a whole number of at least 1".to_string(),
5143 )
5144 })?;
5145 if parsed == 0 {
5146 return Err(invalid(
5147 field,
5148 "must be at least 1 — a limit of zero would delete what retention exists \
5149 to keep; remove the key to leave it unbounded"
5150 .to_string(),
5151 ));
5152 }
5153 count = Some(parsed);
5154 } else if key == "max_age" {
5155 let text = scalar_text(value).ok_or_else(|| {
5156 invalid(
5157 field.clone(),
5158 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
5159 )
5160 })?;
5161 let parsed =
5162 parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
5163 if parsed == 0 {
5164 return Err(invalid(
5165 field,
5166 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
5167 ));
5168 }
5169 max_age_secs = Some(parsed);
5170 } else {
5171 return Err(invalid(
5172 block.to_string(),
5173 format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
5174 ));
5175 }
5176 }
5177 Ok((count, max_age_secs))
5178}
5179
5180fn parse_trace_retain(
5181 raw: Option<serde_yaml::Value>,
5182) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
5183 let Some(raw) = raw else {
5184 return Ok(None);
5185 };
5186 let (max_sessions, max_age_secs) = parse_retain_block(
5187 "trace.retain",
5188 "max_sessions",
5189 &TRACE_RETAIN_KEYS,
5190 raw,
5191 &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
5192 )?;
5193 Ok(Some(TraceRetainConfig {
5194 max_sessions,
5195 max_age_secs,
5196 }))
5197}
5198
5199fn parse_context_retain(
5200 raw: Option<serde_yaml::Value>,
5201) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
5202 let Some(raw) = raw else {
5203 return Ok(None);
5204 };
5205 let (max_messages, max_age_secs) = parse_retain_block(
5206 "context.retain",
5207 "max_messages",
5208 &CONTEXT_RETAIN_KEYS,
5209 raw,
5210 &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
5211 )?;
5212 Ok(Some(ContextRetainConfig {
5213 max_messages,
5214 max_age_secs,
5215 }))
5216}
5217
5218fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
5219 raw.map(|raw| ObservabilityConfig {
5220 otel_endpoint: raw
5221 .otel_endpoint
5222 .map(|value| value.trim().to_string())
5223 .filter(|value| !value.is_empty()),
5224 eval: raw.eval.map(parse_eval_config),
5225 })
5226}
5227
5228fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
5229 let scorers = raw
5230 .scorers
5231 .into_iter()
5232 .filter_map(|raw| {
5233 let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
5234 match raw.scorer_type.as_str() {
5235 "exit_ok" => Some(ScorerConfig::ExitOk { name }),
5236 "max_turns" => Some(ScorerConfig::MaxTurns {
5237 name,
5238 max: raw.max.unwrap_or(10) as u32,
5239 }),
5240 "max_tokens" => Some(ScorerConfig::MaxTokens {
5241 name,
5242 max: raw.max.unwrap_or(100_000),
5243 }),
5244 "tool_sequence" => Some(ScorerConfig::ToolSequence {
5245 name,
5246 expected: raw.expected.unwrap_or_default(),
5247 }),
5248 "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
5249 other => {
5250 crate::warn_line(format_args!(
5251 "[murmur-artifact] unknown scorer type '{other}' — skipping"
5252 ));
5253 None
5254 }
5255 }
5256 })
5257 .collect();
5258
5259 EvalConfig {
5260 dataset_id: raw.dataset_id,
5261 scorers,
5262 }
5263}
5264
5265fn parse_compaction(
5266 raw: Option<RawCompactionConfig>,
5267) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
5268 let Some(raw) = raw else {
5269 return Ok(None);
5270 };
5271
5272 if let Some(threshold) = raw.threshold {
5273 if threshold <= 0.0 || threshold > 1.0 {
5274 return Err(RuntimeManifestError::InvalidInferenceConfig {
5275 field: "inference.compaction.threshold".to_string(),
5276 message: format!("must be in (0.0, 1.0] but got {threshold}"),
5277 });
5278 }
5279 }
5280
5281 if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
5282 return Err(RuntimeManifestError::InvalidInferenceConfig {
5283 field: "inference.compaction.system_prompt".to_string(),
5284 message: "at most one of inference.compaction.system_prompt, \
5285 inference.compaction.system_prompt_file may be set"
5286 .to_string(),
5287 });
5288 }
5289
5290 Ok(Some(CompactionConfig {
5291 threshold: raw.threshold,
5292 model: raw.model,
5293 system_prompt: raw.system_prompt,
5294 system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
5295 dump_summaries: raw.dump_summaries,
5298 }))
5299}
5300
5301fn optional_trimmed_string(value: Option<String>) -> Option<String> {
5302 value
5303 .map(|s| s.trim().to_string())
5304 .filter(|s| !s.is_empty())
5305}
5306
5307fn required_inference_field(
5308 value: Option<String>,
5309 key: &str,
5310) -> Result<String, RuntimeManifestError> {
5311 value
5312 .map(|value| value.trim().to_string())
5313 .filter(|value| !value.is_empty())
5314 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5315 field: format!("inference.{key}"),
5316 message: "missing required field".to_string(),
5317 })
5318}
5319
5320fn parse_artifact_gateway(
5328 index: usize,
5329 name: &str,
5330 raw: Option<RawArtifactGateway>,
5331 secrets: SecretResolution,
5332) -> Result<ArtifactGateway, RuntimeManifestError> {
5333 let (endpoint, api_key, keyless) = match raw {
5334 Some(raw) => (
5335 optional_trimmed_string(raw.endpoint),
5336 raw.api_key,
5337 raw.keyless,
5338 ),
5339 None => (None, None, None),
5340 };
5341 let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
5342 index,
5343 message: format!(
5344 "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
5345 the upstream URL the runtime sends this artifact's keyed requests to"
5346 ),
5347 })?;
5348 validate_gateway_endpoint(name, &endpoint)
5349 .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
5350
5351 let keyless = match keyless {
5352 None | Some(serde_yaml::Value::Null) | Some(serde_yaml::Value::Bool(false)) => false,
5353 Some(serde_yaml::Value::Bool(true)) => true,
5354 Some(_) => {
5355 return Err(RuntimeManifestError::InvalidArtifact {
5356 index,
5357 message: format!(
5358 "artifact '{name}' declares gateway.keyless with a value that is not a \
5359 boolean; write keyless: true for an upstream that takes no key"
5360 ),
5361 })
5362 }
5363 };
5364 if keyless && api_key.is_some() {
5365 return Err(RuntimeManifestError::InvalidArtifact {
5366 index,
5367 message: format!(
5368 "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
5369 gateway either presents a key or is keyless"
5370 ),
5371 });
5372 }
5373 if keyless {
5374 return Ok(ArtifactGateway {
5375 endpoint,
5376 api_key: None,
5377 keyless: true,
5378 });
5379 }
5380
5381 let written = api_key
5382 .flatten()
5383 .map(|value| value.trim().to_string())
5384 .filter(|value| !value.is_empty());
5385 let Some(written) = written else {
5386 return Err(RuntimeManifestError::GatewayWithoutCredential {
5387 index,
5388 name: name.to_string(),
5389 endpoint,
5390 });
5391 };
5392 let api_key = match secrets {
5393 SecretResolution::Skip => None,
5394 SecretResolution::Resolve => Some(match parse_env_reference(&written) {
5395 Some(variable) => ApiKeyReference::Environment(variable.to_string()),
5396 None => ApiKeyReference::Literal(written),
5397 }),
5398 };
5399
5400 Ok(ArtifactGateway {
5401 endpoint,
5402 api_key,
5403 keyless: false,
5404 })
5405}
5406
5407fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
5434 let shown = mask_endpoint_userinfo(endpoint);
5435 let refuse =
5436 |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
5437 if endpoint.contains("${") {
5438 return Err(refuse(
5439 "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
5440 literally"
5441 .to_string(),
5442 ));
5443 }
5444 let url = Url::parse(endpoint)
5445 .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
5446 let host = match url.scheme() {
5447 "http" | "https" => url
5448 .host_str()
5449 .filter(|host| !host.is_empty())
5450 .ok_or_else(|| refuse("which has no host".to_string()))?,
5451 other => {
5452 return Err(refuse(format!(
5453 "whose scheme '{other}' is unsupported — expected http or https"
5454 )))
5455 }
5456 };
5457 if !url.username().is_empty() || url.password().is_some() {
5458 return Err(refuse(format!(
5459 "which carries userinfo before '@' — the gateway never sends it, and this URL \
5460 addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
5461 the key through gateway.api_key"
5462 )));
5463 }
5464 if url.query().is_some() {
5465 return Err(refuse(
5466 "which carries a query — the gateway keeps each request's own query, so write the \
5467 upstream without one"
5468 .to_string(),
5469 ));
5470 }
5471 if url.fragment().is_some() {
5472 return Err(refuse(
5473 "which carries a fragment — write the upstream without one".to_string(),
5474 ));
5475 }
5476 if url.scheme() == "http" {
5477 let bare = host.trim_start_matches('[').trim_end_matches(']');
5478 let is_loopback = bare == "localhost"
5479 || bare
5480 .parse::<IpAddr>()
5481 .map(|ip| ip.is_loopback())
5482 .unwrap_or(false);
5483 if !is_loopback {
5484 return Err(refuse(format!(
5485 "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
5486 remote upstream, or http://localhost or a loopback IP literal for a local one"
5487 )));
5488 }
5489 }
5490 Ok(())
5491}
5492
5493fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
5497 let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
5498 let rest = &endpoint[authority_start..];
5499 let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
5500 match rest[..authority_len].rfind('@') {
5501 Some(at) => format!(
5502 "{}***{}",
5503 &endpoint[..authority_start],
5504 &endpoint[authority_start + at..]
5505 )
5506 .into(),
5507 None => endpoint.into(),
5508 }
5509}
5510
5511fn validate_driver_gateways(
5518 artifacts: &[RuntimeArtifact],
5519 inference: Option<&InferenceConfig>,
5520) -> Result<(), RuntimeManifestError> {
5521 let configured_driver = inference
5522 .filter(|inference| inference.transport == "http")
5523 .and_then(|inference| inference.driver.as_ref())
5524 .map(|driver| driver.artifact.as_str());
5525 let alternate_drivers: Vec<&str> = inference
5526 .map(|inference| {
5527 inference
5528 .alternates
5529 .iter()
5530 .map(|alternate| alternate.driver.as_str())
5531 .collect()
5532 })
5533 .unwrap_or_default();
5534 let under_process = inference.is_some_and(|inference| inference.transport == "process");
5535
5536 for (index, artifact) in artifacts.iter().enumerate() {
5537 if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
5538 continue;
5539 }
5540 if under_process {
5541 return Err(RuntimeManifestError::InvalidArtifact {
5542 index,
5543 message: format!(
5544 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
5545 valid with transport: process — the CLI subprocess holds its own \
5546 credentials and a process driver is granted nothing",
5547 artifact.name
5548 ),
5549 });
5550 }
5551 if configured_driver != Some(artifact.name.as_str())
5552 && !alternate_drivers.contains(&artifact.name.as_str())
5553 {
5554 return Err(RuntimeManifestError::InvalidArtifact {
5555 index,
5556 message: format!(
5557 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
5558 the configured inference driver; a driver's gateway is accepted only on the \
5559 entry inference.driver.artifact names under transport: http, or on one an \
5560 inference.alternates entry names",
5561 artifact.name
5562 ),
5563 });
5564 }
5565 }
5566
5567 if let Some(driver) = configured_driver {
5568 let Some((index, entry)) = artifacts
5569 .iter()
5570 .enumerate()
5571 .find(|(_, artifact)| artifact.name == driver)
5572 else {
5573 return Err(RuntimeManifestError::InvalidInferenceConfig {
5574 field: "inference.driver.artifact".to_string(),
5575 message: format!("artifact '{driver}' is not declared in artifacts:"),
5576 });
5577 };
5578 if entry.gateway.is_none() {
5579 return Err(RuntimeManifestError::InvalidArtifact {
5580 index,
5581 message: format!(
5582 "artifact '{driver}' is the transport: http inference driver but declares no \
5583 gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
5584 on its entry"
5585 ),
5586 });
5587 }
5588 }
5589 Ok(())
5590}
5591
5592fn validate_driver_choices(
5598 artifacts: &[RuntimeArtifact],
5599 inference: Option<&InferenceConfig>,
5600) -> Result<(), RuntimeManifestError> {
5601 let Some(inference) = inference else {
5602 return Ok(());
5603 };
5604 let primary_driver = inference
5605 .driver
5606 .as_ref()
5607 .map(|driver| driver.artifact.as_str());
5608 for (index, alternate) in inference.alternates.iter().enumerate() {
5609 let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
5610 field: format!("inference.alternates[{index}].driver.artifact"),
5611 message,
5612 };
5613 let driver = alternate.driver.as_str();
5614 let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
5615 return Err(refuse(format!(
5616 "artifact '{driver}' is not declared in artifacts:"
5617 )));
5618 };
5619 if entry.runtime != ArtifactRuntime::Driver {
5620 return Err(refuse(format!(
5621 "artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
5622 runtime: driver entry",
5623 entry.runtime.as_str()
5624 )));
5625 }
5626 if entry.gateway.is_none() && primary_driver != Some(driver) {
5627 return Err(refuse(format!(
5628 "artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
5629 its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
5630 URL>, api_key: ... }}' on its entry"
5631 )));
5632 }
5633 }
5634 Ok(())
5635}
5636
5637fn validate_alternates_selectable(
5640 inference: Option<&InferenceConfig>,
5641 control: Option<&ControlConfig>,
5642) -> Result<(), RuntimeManifestError> {
5643 if inference.is_none_or(|inference| inference.alternates.is_empty()) {
5644 return Ok(());
5645 }
5646 let selectable = control.is_some_and(|control| {
5647 control.controller_may_set(ControllableSetting::InferenceDriver)
5648 || control.agent_may_set(ControllableSetting::InferenceDriver)
5649 });
5650 if selectable {
5651 return Ok(());
5652 }
5653 Err(RuntimeManifestError::InvalidInferenceConfig {
5654 field: "inference.alternates".to_string(),
5655 message: "declares alternates that nothing could ever select; list inference.driver in \
5656 control.settings for a controller to switch, or in control.agent_settings for \
5657 the agent to"
5658 .to_string(),
5659 })
5660}
5661
5662fn validate_process_driver_entry(
5670 artifacts: &[RuntimeArtifact],
5671 inference: Option<&InferenceConfig>,
5672) -> Result<(), RuntimeManifestError> {
5673 let Some(driver) = inference
5674 .filter(|inference| inference.transport == "process")
5675 .and_then(|inference| inference.driver.as_ref())
5676 .map(|driver| driver.artifact.as_str())
5677 else {
5678 return Ok(());
5679 };
5680 let Some((index, entry)) = artifacts
5681 .iter()
5682 .enumerate()
5683 .find(|(_, artifact)| artifact.name == driver)
5684 else {
5685 return Err(RuntimeManifestError::InvalidInferenceConfig {
5686 field: "inference.driver.artifact".to_string(),
5687 message: format!("artifact '{driver}' is not declared in artifacts:"),
5688 });
5689 };
5690 if entry.runtime != ArtifactRuntime::Driver {
5691 return Err(RuntimeManifestError::InvalidArtifact {
5692 index,
5693 message: format!(
5694 "artifact '{driver}' is the transport: process driver and must be declared with \
5695 runtime: driver"
5696 ),
5697 });
5698 }
5699 if entry.capabilities.is_some() || entry.config.is_some() {
5700 return Err(RuntimeManifestError::InvalidArtifact {
5701 index,
5702 message: format!(
5703 "artifact '{driver}' is the transport: process driver, which is granted nothing; \
5704 remove capabilities:/config: from its entry — driver settings go in \
5705 inference.driver.config"
5706 ),
5707 });
5708 }
5709 Ok(())
5710}
5711
5712fn parse_inference_driver(
5713 raw: RawInferenceDriver,
5714) -> Result<InferenceDriver, RuntimeManifestError> {
5715 let artifact = raw
5716 .artifact
5717 .map(|value| value.trim().to_string())
5718 .filter(|value| !value.is_empty())
5719 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5720 field: "inference.driver.artifact".to_string(),
5721 message: "missing required field".to_string(),
5722 })?;
5723
5724 let config = parse_inference_driver_config(raw.config)?;
5725
5726 Ok(InferenceDriver { artifact, config })
5727}
5728
5729fn parse_inference_driver_config(
5730 raw: Option<serde_yaml::Value>,
5731) -> Result<Option<String>, RuntimeManifestError> {
5732 let Some(raw) = raw else {
5733 return Ok(None);
5734 };
5735
5736 if matches!(raw, serde_yaml::Value::Null) {
5737 return Ok(None);
5738 }
5739
5740 if !matches!(raw, serde_yaml::Value::Mapping(_)) {
5741 return Err(RuntimeManifestError::InvalidInferenceConfig {
5742 field: "inference.driver.config".to_string(),
5743 message: "expected mapping/object".to_string(),
5744 });
5745 }
5746
5747 let value =
5748 serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
5749 field: "inference.driver.config".to_string(),
5750 message: format!("failed to convert to JSON: {err}"),
5751 })?;
5752
5753 serde_json::to_string(&value).map(Some).map_err(|err| {
5754 RuntimeManifestError::InvalidInferenceConfig {
5755 field: "inference.driver.config".to_string(),
5756 message: format!("failed to encode JSON: {err}"),
5757 }
5758 })
5759}
5760
5761fn parse_env_reference(value: &str) -> Option<&str> {
5762 if !value.starts_with("${") || !value.ends_with('}') {
5763 return None;
5764 }
5765
5766 let variable = &value[2..value.len() - 1];
5767 is_valid_env_variable(variable).then_some(variable)
5768}
5769
5770fn is_valid_env_variable(value: &str) -> bool {
5771 let mut chars = value.chars();
5772 let Some(first) = chars.next() else {
5773 return false;
5774 };
5775
5776 if !(first == '_' || first.is_ascii_uppercase()) {
5777 return false;
5778 }
5779
5780 chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
5781}
5782
5783#[cfg(test)]
5784mod tests {
5785 use super::*;
5786 use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
5787
5788 fn manifest_with_trace(block: &str) -> String {
5789 format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
5790 }
5791
5792 fn lifecycle(task_acceptance: TaskAcceptance, queue_depth: usize) -> LifecycleConfig {
5793 LifecycleConfig {
5794 task_acceptance,
5795 queue_depth,
5796 ..Default::default()
5797 }
5798 }
5799
5800 #[test]
5801 fn tasks_held_at_once_under_queue_is_one_running_plus_queue_depth_waiting() {
5802 assert_eq!(lifecycle(TaskAcceptance::Queue, 1).tasks_held_at_once(), 2);
5803 assert_eq!(lifecycle(TaskAcceptance::Queue, 9).tasks_held_at_once(), 10);
5804 }
5805
5806 #[test]
5807 fn tasks_held_at_once_under_queue_with_depth_zero_is_zero() {
5808 assert_eq!(lifecycle(TaskAcceptance::Queue, 0).tasks_held_at_once(), 0);
5809 }
5810
5811 #[test]
5812 fn tasks_held_at_once_under_single_is_one_whatever_the_depth() {
5813 assert_eq!(lifecycle(TaskAcceptance::Single, 1).tasks_held_at_once(), 1);
5814 assert_eq!(lifecycle(TaskAcceptance::Single, 5).tasks_held_at_once(), 1);
5815 }
5816
5817 #[test]
5818 fn tasks_held_at_once_under_none_is_zero_whatever_the_depth() {
5819 assert_eq!(lifecycle(TaskAcceptance::None, 1).tasks_held_at_once(), 0);
5820 assert_eq!(lifecycle(TaskAcceptance::None, 5).tasks_held_at_once(), 0);
5821 }
5822
5823 #[test]
5826 fn tasks_held_at_once_of_the_default_lifecycle_and_default_depth() {
5827 assert_eq!(LifecycleConfig::default().tasks_held_at_once(), 1);
5828 let queue = RuntimeManifest::from_yaml_str(
5829 "name: cap\nversion: 0.0.1\nlifecycle:\n task_acceptance: queue\n",
5830 )
5831 .unwrap()
5832 .effective_lifecycle();
5833 assert_eq!(queue.queue_depth, 1);
5834 assert_eq!(queue.tasks_held_at_once(), 2);
5835 }
5836
5837 #[test]
5840 fn absent_trace_block_stays_absent_and_defaults_to_meta() {
5841 let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
5842 assert_eq!(manifest.trace, None);
5843 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
5844 }
5845
5846 #[test]
5847 fn each_capture_mode_parses_from_the_trace_block() {
5848 for (yaml, expected) in [
5849 ("none", TraceCapture::None),
5850 ("meta", TraceCapture::Meta),
5851 ("content", TraceCapture::Content),
5852 ] {
5853 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
5854 " capture: {yaml}\n"
5855 )))
5856 .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
5857 assert_eq!(
5858 manifest.trace,
5859 Some(TraceConfig {
5860 capture: expected,
5861 retain: None
5862 })
5863 );
5864 }
5865 }
5866
5867 #[test]
5873 fn no_retain_block_anywhere_parses_as_no_policy() {
5874 let manifest = RuntimeManifest::from_yaml_str(
5875 "name: cap\nversion: 0.0.1\ntrace:\n capture: content\ncontext:\n record: on\n",
5876 )
5877 .unwrap();
5878 assert_eq!(manifest.trace.unwrap().retain, None);
5879 assert_eq!(manifest.context.unwrap().retain, None);
5880 }
5881
5882 #[test]
5883 fn trace_retain_parses_both_keys_and_the_day_suffix() {
5884 let manifest = RuntimeManifest::from_yaml_str(
5885 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 50\n max_age: 14d\n",
5886 )
5887 .unwrap();
5888 assert_eq!(
5889 manifest.trace.unwrap().retain,
5890 Some(TraceRetainConfig {
5891 max_sessions: Some(50),
5892 max_age_secs: Some(14 * 86_400),
5893 })
5894 );
5895 }
5896
5897 #[test]
5898 fn context_retain_parses_both_keys_and_either_alone() {
5899 let both = RuntimeManifest::from_yaml_str(
5900 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n max_messages: 2000\n max_age: 90d\n",
5901 )
5902 .unwrap();
5903 assert_eq!(
5904 both.context.unwrap().retain,
5905 Some(ContextRetainConfig {
5906 max_messages: Some(2000),
5907 max_age_secs: Some(90 * 86_400),
5908 })
5909 );
5910
5911 let count_only = RuntimeManifest::from_yaml_str(
5912 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 10\n",
5913 )
5914 .unwrap();
5915 assert_eq!(
5916 count_only.context.unwrap().retain,
5917 Some(ContextRetainConfig {
5918 max_messages: Some(10),
5919 max_age_secs: None,
5920 })
5921 );
5922 }
5923
5924 #[test]
5927 fn an_empty_retain_block_is_refused_naming_the_block() {
5928 for (yaml, expect_trace) in [
5929 (
5930 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain: {}\n",
5931 true,
5932 ),
5933 (
5934 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n",
5935 true,
5936 ),
5937 (
5938 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain: {}\n",
5939 false,
5940 ),
5941 (
5942 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n",
5943 false,
5944 ),
5945 ] {
5946 let err = RuntimeManifest::from_yaml_str(yaml)
5947 .expect_err("an empty retain block must be refused");
5948 let (field, message) = match (&err, expect_trace) {
5949 (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
5950 (field, message)
5951 }
5952 (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
5953 (field, message)
5954 }
5955 _ => panic!("wrong variant for {yaml:?}: {err:?}"),
5956 };
5957 assert_eq!(
5958 field,
5959 if expect_trace {
5960 "trace.retain"
5961 } else {
5962 "context.retain"
5963 }
5964 );
5965 assert!(message.contains("at least one key"), "{message}");
5966 }
5967 }
5968
5969 #[test]
5972 fn a_zero_retain_limit_is_refused_naming_the_key() {
5973 let trace = RuntimeManifest::from_yaml_str(
5974 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 0\n",
5975 )
5976 .expect_err("max_sessions: 0 must be refused");
5977 assert!(
5978 matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
5979 if field == "trace.retain.max_sessions"),
5980 "{trace:?}"
5981 );
5982
5983 let context = RuntimeManifest::from_yaml_str(
5984 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 0\n",
5985 )
5986 .expect_err("max_messages: 0 must be refused");
5987 assert!(
5988 matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
5989 if field == "context.retain.max_messages"),
5990 "{context:?}"
5991 );
5992
5993 let age = RuntimeManifest::from_yaml_str(
5994 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_age: 0\n",
5995 )
5996 .expect_err("max_age: 0 must be refused");
5997 assert!(
5998 matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
5999 if field == "trace.retain.max_age"),
6000 "{age:?}"
6001 );
6002 }
6003
6004 #[test]
6005 fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
6006 let err = RuntimeManifest::from_yaml_str(
6007 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_traces: 3\n",
6008 )
6009 .expect_err("an unknown key must be refused");
6010 match err {
6011 RuntimeManifestError::InvalidTraceConfig { field, message } => {
6012 assert_eq!(field, "trace.retain");
6013 assert!(message.contains("max_traces"), "{message}");
6014 assert!(message.contains("max_sessions"), "{message}");
6015 }
6016 other => panic!("expected InvalidTraceConfig, got {other:?}"),
6017 }
6018 }
6019
6020 #[test]
6021 fn an_unparseable_retain_duration_reports_the_accepted_form() {
6022 let err = RuntimeManifest::from_yaml_str(
6023 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_age: 5 weeks\n",
6024 )
6025 .expect_err("'5 weeks' must be refused");
6026 match err {
6027 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
6028 assert_eq!(field, "context.retain.max_age");
6029 assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
6030 }
6031 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
6032 }
6033 }
6034
6035 #[test]
6036 fn unparseable_capture_value_names_the_field_and_accepted_values() {
6037 let err = RuntimeManifest::from_yaml_str(&manifest_with_trace(" capture: verbose\n"))
6038 .expect_err("'verbose' is not a capture mode");
6039 let rendered = err.to_string();
6040 assert!(rendered.contains("trace.capture"), "{rendered}");
6041 for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
6042 assert!(rendered.contains(accepted), "{rendered}");
6043 }
6044 }
6045
6046 #[test]
6047 fn resources_block_is_optional_and_absent_stays_absent() {
6048 let manifest = RuntimeManifest::from_yaml_str(
6049 r#"
6050name: cap
6051version: 0.0.1
6052capabilities:
6053 shell:
6054 allow: [bash]
6055"#,
6056 )
6057 .unwrap();
6058
6059 let caps = manifest.capabilities.expect("shell block must parse");
6063 assert_eq!(caps.resources, None);
6064 }
6065
6066 #[test]
6067 fn resources_fields_parse_and_each_stays_independently_optional() {
6068 let manifest = RuntimeManifest::from_yaml_str(
6069 r#"
6070name: cap
6071version: 0.0.1
6072capabilities:
6073 resources:
6074 max_processes: 32
6075 max_open_files: 16
6076 max_file_size_bytes: 1048576
6077 cpu_seconds: 30
6078 memory_bytes: 268435456
6079 cgroup_memory_bytes: 536870912
6080 cgroup_pids_max: 64
6081 cgroup_cpu_percent: 50
6082 cgroup_io_bytes_per_sec: 1048576
6083 workdir_max_bytes: 2097152
6084"#,
6085 )
6086 .unwrap();
6087
6088 let resources = manifest
6089 .capabilities
6090 .and_then(|caps| caps.resources)
6091 .expect("resources block must parse");
6092 assert_eq!(resources.max_processes, Some(32));
6093 assert_eq!(resources.max_open_files, Some(16));
6094 assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
6095 assert_eq!(resources.cpu_seconds, Some(30));
6096 assert_eq!(resources.memory_bytes, Some(268_435_456));
6097 assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
6098 assert_eq!(resources.cgroup_pids_max, Some(64));
6099 assert_eq!(resources.cgroup_cpu_percent, Some(50));
6100 assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
6101 assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
6102 }
6103
6104 #[test]
6105 fn state_block_is_optional_and_absent_stays_absent() {
6106 let manifest = RuntimeManifest::from_yaml_str(
6107 r#"
6108name: cap
6109version: 0.0.1
6110artifacts:
6111 - name: notes-tool
6112 version: 1.0.0
6113 runtime: tool
6114 capabilities:
6115 filesystem:
6116 scope: cache
6117"#,
6118 )
6119 .unwrap();
6120
6121 let caps = manifest.artifacts[0]
6123 .capabilities
6124 .as_ref()
6125 .expect("filesystem block must parse");
6126 assert_eq!(caps.state, None);
6127 }
6128
6129 #[test]
6133 fn state_block_declared_empty_grants_the_capsule_named_store() {
6134 let manifest = RuntimeManifest::from_yaml_str(
6135 r#"
6136name: cap
6137version: 0.0.1
6138artifacts:
6139 - name: notes-tool
6140 version: 1.0.0
6141 runtime: tool
6142 capabilities:
6143 state: {}
6144"#,
6145 )
6146 .unwrap();
6147
6148 let state = manifest.artifacts[0]
6149 .capabilities
6150 .as_ref()
6151 .and_then(|caps| caps.state.as_ref())
6152 .expect("state block must parse");
6153 assert_eq!(state.store, None);
6154 }
6155
6156 #[test]
6157 fn state_store_name_parses_and_stays_independently_optional() {
6158 let manifest = RuntimeManifest::from_yaml_str(
6159 r#"
6160name: cap
6161version: 0.0.1
6162artifacts:
6163 - name: notes-tool
6164 version: 1.0.0
6165 runtime: tool
6166 capabilities:
6167 state:
6168 store: " shey "
6169 network:
6170 allow: [https://api.example.com]
6171"#,
6172 )
6173 .unwrap();
6174
6175 let caps = manifest.artifacts[0]
6176 .capabilities
6177 .as_ref()
6178 .expect("capabilities must parse");
6179 assert_eq!(
6182 caps.state.as_ref().and_then(|state| state.store.as_deref()),
6183 Some("shey")
6184 );
6185 assert_eq!(
6187 caps.network
6188 .as_ref()
6189 .map(|network| network.allow.as_slice()),
6190 Some(["https://api.example.com".to_string()].as_slice())
6191 );
6192 assert_eq!(caps.filesystem, None);
6193 }
6194
6195 #[test]
6199 fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
6200 let manifest = RuntimeManifest::from_yaml_str(
6201 r#"
6202name: cap
6203version: 0.0.1
6204artifacts:
6205 - name: notes-tool
6206 version: 1.0.0
6207 runtime: tool
6208 capabilities:
6209 state:
6210 store: ""
6211"#,
6212 )
6213 .unwrap();
6214
6215 assert_eq!(
6216 manifest.artifacts[0]
6217 .capabilities
6218 .as_ref()
6219 .and_then(|caps| caps.state.as_ref())
6220 .and_then(|state| state.store.as_deref()),
6221 Some("")
6222 );
6223 }
6224
6225 #[test]
6226 fn resources_partial_block_leaves_undeclared_fields_none() {
6227 let manifest = RuntimeManifest::from_yaml_str(
6228 r#"
6229name: cap
6230version: 0.0.1
6231capabilities:
6232 resources:
6233 max_open_files: 16
6234"#,
6235 )
6236 .unwrap();
6237
6238 let resources = manifest
6239 .capabilities
6240 .and_then(|caps| caps.resources)
6241 .expect("resources block must parse");
6242 assert_eq!(resources.max_open_files, Some(16));
6243 assert_eq!(resources.max_processes, None);
6244 assert_eq!(resources.workdir_max_bytes, None);
6245 }
6246
6247 #[test]
6251 fn resources_zero_is_rejected_on_every_field() {
6252 for field in [
6253 "max_processes",
6254 "max_open_files",
6255 "max_file_size_bytes",
6256 "cpu_seconds",
6257 "memory_bytes",
6258 "cgroup_memory_bytes",
6259 "cgroup_pids_max",
6260 "cgroup_cpu_percent",
6261 "cgroup_io_bytes_per_sec",
6262 "workdir_max_bytes",
6263 ] {
6264 let yaml =
6265 format!("name: cap\nversion: 0.0.1\ncapabilities:\n resources:\n {field}: 0\n");
6266 let error =
6267 RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
6268
6269 match error {
6270 RuntimeManifestError::InvalidCapabilities {
6271 field: reported,
6272 message,
6273 } => {
6274 assert_eq!(reported, format!("capabilities.resources.{field}"));
6275 assert_eq!(message, "must be greater than zero");
6276 }
6277 other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
6278 }
6279 }
6280 }
6281
6282 #[test]
6283 fn artifact_runtime_defaults_to_tool() {
6284 let manifest = RuntimeManifest::from_yaml_str(
6285 r#"
6286name: cap
6287version: 0.0.1
6288artifacts:
6289 - name: echo
6290 version: 1.2.3
6291"#,
6292 )
6293 .unwrap();
6294
6295 assert_eq!(manifest.artifacts.len(), 1);
6296 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
6297 }
6298
6299 #[test]
6300 fn artifact_runtime_tool_explicit() {
6301 let manifest = RuntimeManifest::from_yaml_str(
6302 r#"
6303name: cap
6304version: 0.0.1
6305artifacts:
6306 - name: echo
6307 version: 1.2.3
6308 runtime: tool
6309"#,
6310 )
6311 .unwrap();
6312
6313 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
6314 }
6315
6316 #[test]
6317 fn artifact_runtime_wasm_is_error() {
6318 let err = RuntimeManifest::from_yaml_str(
6319 r#"
6320name: cap
6321version: 0.0.1
6322artifacts:
6323 - name: echo
6324 version: 1.2.3
6325 runtime: wasm
6326"#,
6327 )
6328 .unwrap_err();
6329
6330 let msg = err.to_string();
6331 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
6332 assert!(msg.contains("wasm"), "error was: {msg}");
6333 }
6334
6335 #[test]
6336 fn artifact_runtime_native_is_error() {
6337 let err = RuntimeManifest::from_yaml_str(
6338 r#"
6339name: cap
6340version: 0.0.1
6341artifacts:
6342 - name: echo
6343 version: 1.2.3
6344 runtime: native
6345"#,
6346 )
6347 .unwrap_err();
6348
6349 let msg = err.to_string();
6350 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
6351 assert!(msg.contains("native"), "error was: {msg}");
6352 }
6353
6354 #[test]
6355 fn invalid_artifact_reports_index() {
6356 let err = RuntimeManifest::from_yaml_str(
6357 r#"
6358name: cap
6359version: 0.0.1
6360artifacts:
6361 - name: ""
6362 version: 1.2.3
6363"#,
6364 )
6365 .unwrap_err();
6366
6367 let msg = err.to_string();
6368 assert!(msg.contains("index 0"));
6369 assert!(msg.contains("missing required field 'name'"));
6370 }
6371
6372 #[test]
6373 fn capabilities_parse_when_all_fields_present() {
6374 let manifest = RuntimeManifest::from_yaml_str(
6375 r#"
6376name: cap
6377version: 0.0.1
6378artifacts:
6379 - name: echo
6380 version: 1.2.3
6381capabilities:
6382 network:
6383 allow:
6384 - https://api.anthropic.com
6385 - http://127.0.0.1:8080
6386 filesystem:
6387 scope: ./sandbox
6388"#,
6389 )
6390 .unwrap();
6391
6392 let capabilities = manifest.capabilities.expect("capabilities should exist");
6393 assert_eq!(
6394 capabilities.network.unwrap().allow,
6395 vec![
6396 "https://api.anthropic.com".to_string(),
6397 "http://127.0.0.1:8080".to_string()
6398 ]
6399 );
6400 assert_eq!(
6401 capabilities.filesystem.unwrap().scope,
6402 Some("./sandbox".to_string())
6403 );
6404 }
6405
6406 #[test]
6407 fn env_capabilities_parse_allowlist() {
6408 let manifest = RuntimeManifest::from_yaml_str(
6409 r#"
6410name: cap
6411version: 0.0.1
6412artifacts:
6413 - name: echo
6414 version: 1.2.3
6415capabilities:
6416 env:
6417 allow:
6418 - MY_APP_REGION
6419 - GITHUB_TOKEN
6420"#,
6421 )
6422 .unwrap();
6423
6424 let capabilities = manifest.capabilities.expect("capabilities should exist");
6425 assert_eq!(
6426 capabilities.env.unwrap().allow,
6427 vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
6428 );
6429 }
6430
6431 #[test]
6432 fn env_capabilities_absent_when_block_omitted() {
6433 let manifest = RuntimeManifest::from_yaml_str(
6434 r#"
6435name: cap
6436version: 0.0.1
6437artifacts:
6438 - name: echo
6439 version: 1.2.3
6440capabilities:
6441 network:
6442 allow:
6443 - https://api.anthropic.com
6444"#,
6445 )
6446 .unwrap();
6447
6448 assert!(manifest.capabilities.unwrap().env.is_none());
6449 }
6450
6451 #[test]
6454 fn env_capabilities_allow_empty_list_is_accepted() {
6455 let manifest = RuntimeManifest::from_yaml_str(
6456 r#"
6457name: cap
6458version: 0.0.1
6459artifacts:
6460 - name: echo
6461 version: 1.2.3
6462capabilities:
6463 env: {}
6464"#,
6465 )
6466 .unwrap();
6467
6468 assert_eq!(
6469 manifest.capabilities.unwrap().env,
6470 Some(EnvCapabilities { allow: vec![] })
6471 );
6472 }
6473
6474 #[test]
6475 fn missing_capabilities_block_is_backward_compatible() {
6476 let manifest = RuntimeManifest::from_yaml_str(
6477 r#"
6478name: cap
6479version: 0.0.1
6480artifacts:
6481 - name: echo
6482 version: 1.2.3
6483"#,
6484 )
6485 .unwrap();
6486
6487 assert!(manifest.capabilities.is_none());
6488 }
6489
6490 #[test]
6491 fn network_only_capabilities_parse() {
6492 let manifest = RuntimeManifest::from_yaml_str(
6493 r#"
6494name: cap
6495version: 0.0.1
6496artifacts:
6497 - name: echo
6498 version: 1.2.3
6499capabilities:
6500 network:
6501 allow:
6502 - https://allowed.example.com
6503"#,
6504 )
6505 .unwrap();
6506
6507 let capabilities = manifest.capabilities.unwrap();
6508 assert_eq!(
6509 capabilities.network.unwrap().allow,
6510 vec!["https://allowed.example.com".to_string()]
6511 );
6512 assert!(capabilities.filesystem.is_none());
6513 }
6514
6515 #[test]
6516 fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
6517 let manifest = RuntimeManifest::from_yaml_str(
6518 r#"
6519name: cap
6520version: 0.0.1
6521artifacts:
6522 - name: echo
6523 version: 1.2.3
6524capabilities:
6525 network: {}
6526"#,
6527 )
6528 .unwrap();
6529
6530 let capabilities = manifest.capabilities.unwrap();
6531 assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
6532 }
6533
6534 #[test]
6537 fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
6538 let manifest = RuntimeManifest::from_yaml_str(
6539 r#"
6540name: cap
6541version: 0.0.1
6542artifacts:
6543 - name: echo
6544 version: 1.2.3
6545capabilities:
6546 network:
6547 allow:
6548 - https://api.anthropic.com
6549"#,
6550 )
6551 .unwrap();
6552
6553 let network = manifest.capabilities.unwrap().network.unwrap();
6554 assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
6555 assert!(!network.unix_sockets);
6556 }
6557
6558 #[test]
6559 fn network_unix_sockets_parses_explicit_false() {
6560 let manifest = RuntimeManifest::from_yaml_str(
6561 r#"
6562name: cap
6563version: 0.0.1
6564artifacts:
6565 - name: echo
6566 version: 1.2.3
6567capabilities:
6568 network:
6569 unix_sockets: false
6570"#,
6571 )
6572 .unwrap();
6573
6574 assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
6575 }
6576
6577 #[test]
6578 fn network_unix_sockets_parses_explicit_true() {
6579 let manifest = RuntimeManifest::from_yaml_str(
6580 r#"
6581name: cap
6582version: 0.0.1
6583artifacts:
6584 - name: echo
6585 version: 1.2.3
6586capabilities:
6587 network:
6588 unix_sockets: true
6589"#,
6590 )
6591 .unwrap();
6592
6593 let network = manifest.capabilities.unwrap().network.unwrap();
6594 assert!(network.unix_sockets);
6595 assert_eq!(network.allow, Vec::<String>::new());
6597 }
6598
6599 #[test]
6603 fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
6604 let manifest = RuntimeManifest::from_yaml_str(
6605 r#"
6606name: cap
6607version: 0.0.1
6608capabilities:
6609 filesystem:
6610 read_only:
6611 - tests
6612 - " bench/fixtures "
6613 - ""
6614 - " "
6615"#,
6616 )
6617 .unwrap();
6618
6619 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
6620 assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
6621 }
6622
6623 #[test]
6625 fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
6626 let manifest = RuntimeManifest::from_yaml_str(
6627 r#"
6628name: cap
6629version: 0.0.1
6630capabilities:
6631 filesystem:
6632 scope: workdir
6633"#,
6634 )
6635 .unwrap();
6636
6637 assert!(manifest
6638 .capabilities
6639 .unwrap()
6640 .filesystem
6641 .unwrap()
6642 .read_only
6643 .is_empty());
6644 }
6645
6646 #[test]
6649 fn filesystem_read_only_does_not_judge_path_shape() {
6650 let manifest = RuntimeManifest::from_yaml_str(
6651 r#"
6652name: cap
6653version: 0.0.1
6654capabilities:
6655 filesystem:
6656 read_only:
6657 - /etc
6658 - ../outside
6659"#,
6660 )
6661 .expect("shape is judged by the runtime, not the parser");
6662
6663 assert_eq!(
6664 manifest.capabilities.unwrap().filesystem.unwrap().read_only,
6665 vec!["/etc", "../outside"]
6666 );
6667 }
6668
6669 #[test]
6673 fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
6674 let manifest = RuntimeManifest::from_yaml_str(
6675 r#"
6676name: cap
6677version: 0.0.1
6678artifacts:
6679 - name: echo
6680 version: 1.2.3
6681capabilities:
6682 filesystem:
6683 scope: workdir
6684"#,
6685 )
6686 .unwrap();
6687
6688 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
6689 assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
6690 assert!(!filesystem.workdir_exec);
6691 }
6692
6693 #[test]
6698 fn filesystem_block_stays_absent_when_undeclared() {
6699 let manifest = RuntimeManifest::from_yaml_str(
6700 r#"
6701name: cap
6702version: 0.0.1
6703artifacts:
6704 - name: echo
6705 version: 1.2.3
6706capabilities:
6707 network:
6708 allow:
6709 - https://api.anthropic.com
6710"#,
6711 )
6712 .unwrap();
6713
6714 assert!(manifest.capabilities.unwrap().filesystem.is_none());
6715 }
6716
6717 #[test]
6718 fn filesystem_workdir_exec_parses_explicit_false() {
6719 let manifest = RuntimeManifest::from_yaml_str(
6720 r#"
6721name: cap
6722version: 0.0.1
6723artifacts:
6724 - name: echo
6725 version: 1.2.3
6726capabilities:
6727 filesystem:
6728 workdir_exec: false
6729"#,
6730 )
6731 .unwrap();
6732
6733 assert!(
6734 !manifest
6735 .capabilities
6736 .unwrap()
6737 .filesystem
6738 .unwrap()
6739 .workdir_exec
6740 );
6741 }
6742
6743 #[test]
6744 fn filesystem_workdir_exec_parses_explicit_true() {
6745 let manifest = RuntimeManifest::from_yaml_str(
6746 r#"
6747name: cap
6748version: 0.0.1
6749artifacts:
6750 - name: echo
6751 version: 1.2.3
6752capabilities:
6753 filesystem:
6754 workdir_exec: true
6755"#,
6756 )
6757 .unwrap();
6758
6759 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
6760 assert!(filesystem.workdir_exec);
6761 assert_eq!(filesystem.scope, None);
6764 }
6765
6766 #[test]
6767 fn network_internal_port_parses() {
6768 let manifest = RuntimeManifest::from_yaml_str(
6769 r#"
6770name: worker
6771version: 0.1.0
6772artifacts: []
6773
6774network:
6775 internal_port: 58172
6776"#,
6777 )
6778 .unwrap();
6779
6780 assert_eq!(
6781 manifest.network.unwrap().internal_port,
6782 Some(58172),
6783 "network.internal_port should parse to Some(58172)"
6784 );
6785 }
6786
6787 #[test]
6788 fn network_internal_port_absent_when_omitted() {
6789 let manifest = RuntimeManifest::from_yaml_str(
6790 r#"
6791name: worker
6792version: 0.1.0
6793artifacts: []
6794"#,
6795 )
6796 .unwrap();
6797
6798 assert!(manifest.network.is_none());
6799 }
6800
6801 #[test]
6802 fn shell_capabilities_parse_with_allow_and_env_fields() {
6803 let manifest = RuntimeManifest::from_yaml_str(
6804 r#"
6805name: cap
6806version: 0.0.1
6807artifacts: []
6808capabilities:
6809 shell:
6810 allow:
6811 - bash
6812 - git
6813 strip_env:
6814 - "*_TOKEN"
6815 - SECRET_KEY
6816 baseline_env:
6817 - SSH_AUTH_SOCK
6818"#,
6819 )
6820 .unwrap();
6821
6822 let shell = manifest
6823 .capabilities
6824 .unwrap()
6825 .shell
6826 .expect("shell capabilities should be present");
6827 assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
6828 assert_eq!(
6829 shell.strip_env,
6830 Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
6831 );
6832 assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
6833 }
6834
6835 #[test]
6836 fn shell_capabilities_require_non_empty_allowlist() {
6837 let err = RuntimeManifest::from_yaml_str(
6838 r#"
6839name: cap
6840version: 0.0.1
6841artifacts: []
6842capabilities:
6843 shell:
6844 allow: []
6845"#,
6846 )
6847 .unwrap_err();
6848
6849 let msg = err.to_string();
6850 assert!(msg.contains("capabilities.shell.allow"));
6851 assert!(msg.contains("at least one"));
6852 }
6853
6854 #[test]
6855 fn interpreter_runtime_parses_accepted_shape() {
6856 let manifest = RuntimeManifest::from_yaml_str(
6857 r#"
6858name: cap
6859version: 0.0.1
6860artifacts: []
6861capabilities:
6862 shell:
6863 allow:
6864 - python3
6865 interpreter_runtime:
6866 - binary: python3
6867 dirs:
6868 - path: /usr/lib/python3.11
6869 list_dir: true
6870 - path: /usr/lib/python3.11/lib-dynload
6871 list_dir: false
6872"#,
6873 )
6874 .unwrap();
6875
6876 let shell = manifest.capabilities.unwrap().shell.unwrap();
6877 assert_eq!(
6878 shell.interpreter_runtime,
6879 vec![InterpreterRuntimeGrant {
6880 binary: "python3".to_string(),
6881 dirs: vec![
6882 InterpreterRuntimeDir {
6883 path: "/usr/lib/python3.11".to_string(),
6884 list_dir: true,
6885 },
6886 InterpreterRuntimeDir {
6887 path: "/usr/lib/python3.11/lib-dynload".to_string(),
6888 list_dir: false,
6889 },
6890 ],
6891 }]
6892 );
6893 }
6894
6895 #[test]
6896 fn interpreter_runtime_defaults_to_empty_when_absent() {
6897 let manifest = RuntimeManifest::from_yaml_str(
6898 r#"
6899name: cap
6900version: 0.0.1
6901artifacts: []
6902capabilities:
6903 shell:
6904 allow:
6905 - python3
6906"#,
6907 )
6908 .unwrap();
6909
6910 let shell = manifest.capabilities.unwrap().shell.unwrap();
6911 assert!(shell.interpreter_runtime.is_empty());
6912 }
6913
6914 fn interpreter_runtime_reject(grant_yaml: &str) -> String {
6917 let yaml = format!(
6918 r#"
6919name: cap
6920version: 0.0.1
6921artifacts: []
6922capabilities:
6923 shell:
6924 allow:
6925 - python3
6926 interpreter_runtime:
6927{grant_yaml}
6928"#,
6929 );
6930 RuntimeManifest::from_yaml_str(&yaml)
6931 .expect_err("grant should be rejected")
6932 .to_string()
6933 }
6934
6935 #[test]
6936 fn interpreter_runtime_rejects_binary_not_in_allow() {
6937 let msg = interpreter_runtime_reject(
6938 " - binary: ruby\n dirs:\n - path: /usr/lib/ruby\n list_dir: true\n",
6939 );
6940 assert!(
6941 msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
6942 "{msg}"
6943 );
6944 assert!(msg.contains("ruby"), "{msg}");
6945 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
6946 }
6947
6948 #[test]
6949 fn interpreter_runtime_rejects_relative_path() {
6950 let msg = interpreter_runtime_reject(
6951 " - binary: python3\n dirs:\n - path: usr/lib/python3.11\n list_dir: true\n",
6952 );
6953 assert!(
6954 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
6955 "{msg}"
6956 );
6957 assert!(msg.contains("absolute"), "{msg}");
6958 }
6959
6960 fn manifest_with_plan(block: &str) -> String {
6964 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n{block}")
6965 }
6966
6967 #[test]
6968 fn plan_submit_true_grants_the_capsule() {
6969 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n"))
6970 .expect("a plan block parses");
6971 assert_eq!(
6972 manifest.capabilities.unwrap().plan,
6973 Some(PlanCapabilities { submit: true })
6974 );
6975 }
6976
6977 #[test]
6980 fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
6981 let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: false\n"))
6982 .expect("an explicit denial parses");
6983 assert_eq!(
6984 explicit.capabilities.unwrap().plan,
6985 Some(PlanCapabilities { submit: false })
6986 );
6987
6988 let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
6989 .expect("no capabilities block parses");
6990 assert!(absent.capabilities.is_none());
6991 }
6992
6993 #[test]
6996 fn plan_without_submit_is_rejected() {
6997 let err = RuntimeManifest::from_yaml_str(&manifest_with_plan(" {}\n"))
6998 .expect_err("an empty plan block is refused");
6999 let msg = err.to_string();
7000 assert!(msg.contains("capabilities.plan.submit"), "{msg}");
7001 assert!(msg.contains("never"), "{msg}");
7002 }
7003
7004 #[test]
7006 fn an_unknown_plan_key_is_reported_by_path() {
7007 let manifest =
7008 RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n depth: 3\n"))
7009 .expect("an unknown key is a warning, not a parse error");
7010 assert!(
7011 manifest
7012 .unknown_keys
7013 .iter()
7014 .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
7015 "{:?}",
7016 manifest.unknown_keys
7017 );
7018 }
7019
7020 fn manifest_with_install(block: &str) -> String {
7024 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n install:\n{block}")
7025 }
7026
7027 #[test]
7028 fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
7029 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
7030 " skill: [code-review, \"style-*\"]\n tool: [\"b-*\", a, \"*\"]\n",
7031 ))
7032 .expect("an install block parses");
7033 assert_eq!(
7034 manifest.capabilities.unwrap().install,
7035 Some(InstallCapabilities {
7036 skill: vec!["code-review".into(), "style-*".into()],
7037 tool: vec!["b-*".into(), "a".into(), "*".into()],
7038 })
7039 );
7040 }
7041
7042 #[test]
7045 fn capabilities_install_accepts_either_list_alone() {
7046 let skill_only = RuntimeManifest::from_yaml_str(&manifest_with_install(" skill: [a]\n"))
7047 .expect("skill alone parses");
7048 assert_eq!(
7049 skill_only.capabilities.unwrap().install,
7050 Some(InstallCapabilities {
7051 skill: vec!["a".into()],
7052 tool: vec![],
7053 })
7054 );
7055 let tool_only =
7056 RuntimeManifest::from_yaml_str(&manifest_with_install(" tool: [\"b-*\"]\n"))
7057 .expect("tool alone parses");
7058 assert_eq!(
7059 tool_only.capabilities.unwrap().install,
7060 Some(InstallCapabilities {
7061 skill: vec![],
7062 tool: vec!["b-*".into()],
7063 })
7064 );
7065 }
7066
7067 #[test]
7068 fn capabilities_install_absent_is_none() {
7069 let manifest = RuntimeManifest::from_yaml_str(
7070 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n submit: true\n",
7071 )
7072 .expect("a capabilities block without install parses");
7073 assert_eq!(manifest.capabilities.unwrap().install, None);
7074 }
7075
7076 #[test]
7079 fn capabilities_install_that_names_nothing_is_refused() {
7080 for block in [
7081 " {}\n",
7082 " skill: []\n",
7083 " skill: []\n tool: []\n",
7084 " driver: [x]\n",
7085 ] {
7086 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
7087 .expect_err("an install block naming nothing is refused");
7088 match &err {
7089 RuntimeManifestError::InvalidCapabilities { field, message } => {
7090 assert_eq!(field, "capabilities.install", "{block:?}");
7091 assert!(message.contains("at least one"), "{message}");
7092 assert!(message.contains("omit the block"), "{message}");
7093 }
7094 other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
7095 }
7096 }
7097 }
7098
7099 #[test]
7100 fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
7101 for entry in ["a*b", "*x", "**", "Foo", "-a", "a/b", " "] {
7102 for key in ["skill", "tool"] {
7103 let block = format!(" {key}: [{entry:?}]\n");
7104 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
7105 .expect_err("a bad entry is refused");
7106 match &err {
7107 RuntimeManifestError::InvalidCapabilities { field, message } => {
7108 assert_eq!(field, &format!("capabilities.install.{key}"));
7109 assert!(message.contains(&format!("{entry:?}")), "{message}");
7110 assert!(
7111 message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
7112 "{message}"
7113 );
7114 }
7115 other => panic!("{entry:?}: expected InvalidCapabilities, got {other:?}"),
7116 }
7117 }
7118 }
7119 }
7120
7121 #[test]
7123 fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
7124 for runtime in ["tool", "hook", "driver", "skill"] {
7125 let yaml = format!(
7126 "name: cap\nversion: 0.0.1\nartifacts:\n - name: a\n version: 0.1.0\n \
7127 runtime: {runtime}\n capabilities:\n install:\n skill: [x]\n"
7128 );
7129 let err = RuntimeManifest::from_yaml_str(&yaml)
7130 .expect_err("a per-artifact install block is refused");
7131 match &err {
7132 RuntimeManifestError::InvalidArtifact { index, message } => {
7133 assert_eq!(*index, 0);
7134 assert!(message.contains("capabilities.install"), "{message}");
7135 assert!(message.contains("capsule-wide"), "{message}");
7136 }
7137 other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
7138 }
7139 }
7140 }
7141
7142 #[test]
7144 fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
7145 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
7146 " skill: [a]\n driver: [x]\n",
7147 ))
7148 .expect("an unknown key beside a real entry is a warning, not a parse error");
7149 assert!(
7150 manifest
7151 .unknown_keys
7152 .iter()
7153 .any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
7154 "{:?}",
7155 manifest.unknown_keys
7156 );
7157 assert_eq!(
7158 manifest.capabilities.unwrap().install,
7159 Some(InstallCapabilities {
7160 skill: vec!["a".into()],
7161 tool: vec![],
7162 })
7163 );
7164 }
7165
7166 fn manifest_with_task_io(runtime: &str, block: &str) -> String {
7170 format!(
7171 "name: cap\nversion: 0.0.1\nartifacts:\n - name: gate\n version: 1.2.3\n \
7172 runtime: {runtime}\n capabilities:\n task_io:\n{block}"
7173 )
7174 }
7175
7176 #[test]
7177 fn task_io_read_true_grants_the_hook() {
7178 let manifest =
7179 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: true\n"))
7180 .expect("task_io on a hook entry parses");
7181 assert_eq!(
7182 manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
7183 Some(TaskIoCapabilities { read: true })
7184 );
7185 }
7186
7187 #[test]
7190 fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
7191 let explicit =
7192 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: false\n"))
7193 .expect("an explicit denial parses");
7194 assert_eq!(
7195 explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
7196 Some(TaskIoCapabilities { read: false })
7197 );
7198
7199 let absent = RuntimeManifest::from_yaml_str(
7200 "name: cap\nversion: 0.0.1\nartifacts:\n - name: gate\n version: 1.2.3\n \
7201 runtime: hook\n",
7202 )
7203 .expect("no capabilities block parses");
7204 assert!(absent.artifacts[0].capabilities.is_none());
7205 }
7206
7207 #[test]
7210 fn task_io_without_read_is_rejected() {
7211 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " {}\n"))
7212 .unwrap_err();
7213 let msg = err.to_string();
7214 assert!(
7215 msg.contains("capabilities.task_io.read"),
7216 "error was: {msg}"
7217 );
7218 assert!(msg.contains("explicitly"), "error was: {msg}");
7219 }
7220
7221 #[test]
7224 fn task_io_outside_a_hook_entry_is_rejected() {
7225 for runtime in ["tool", "driver", "skill"] {
7226 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
7227 runtime,
7228 " read: true\n",
7229 ))
7230 .unwrap_err();
7231 let msg = err.to_string();
7232 assert!(
7233 msg.contains("capabilities") && msg.contains("runtime: hook"),
7234 "{runtime} entry: error was: {msg}"
7235 );
7236 }
7237 }
7238
7239 #[test]
7242 fn task_io_in_the_capsule_wide_block_is_rejected() {
7243 let err = RuntimeManifest::from_yaml_str(
7244 "name: cap\nversion: 0.0.1\ncapabilities:\n task_io:\n read: true\n",
7245 )
7246 .unwrap_err();
7247 let msg = err.to_string();
7248 assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
7249 assert!(msg.contains("runtime: hook"), "error was: {msg}");
7250 }
7251
7252 fn manifest_with_conversation(runtime: &str, block: &str) -> String {
7256 format!(
7257 "name: cap\nversion: 0.0.1\nartifacts:\n - name: memory\n version: 1.2.3\n \
7258 runtime: {runtime}\n capabilities:\n conversation:\n{block}"
7259 )
7260 }
7261
7262 #[test]
7263 fn conversation_read_lowers_onto_the_hook_entry() {
7264 for (declared, expected) in [("true", true), ("false", false)] {
7265 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
7266 "hook",
7267 &format!(" read: {declared}\n"),
7268 ))
7269 .expect("conversation on a hook entry parses");
7270 assert_eq!(
7271 manifest.artifacts[0]
7272 .capabilities
7273 .as_ref()
7274 .unwrap()
7275 .conversation,
7276 Some(ConversationCapabilities { read: expected })
7277 );
7278 }
7279 }
7280
7281 #[test]
7284 fn conversation_without_read_is_rejected() {
7285 let err =
7286 RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", " {}\n"))
7287 .unwrap_err();
7288 let msg = err.to_string();
7289 assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
7290 assert!(msg.contains("explicitly"), "was: {msg}");
7291 }
7292
7293 #[test]
7296 fn conversation_outside_a_hook_entry_is_rejected() {
7297 for runtime in ["tool", "driver", "skill"] {
7298 let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
7299 runtime,
7300 " read: true\n",
7301 ))
7302 .unwrap_err();
7303 let msg = err.to_string();
7304 assert!(
7305 msg.contains("capabilities") && msg.contains("runtime: hook"),
7306 "{runtime} entry: error was: {msg}"
7307 );
7308 }
7309 }
7310
7311 #[test]
7314 fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
7315 let manifest = RuntimeManifest::from_yaml_str(
7316 "name: cap\nversion: 0.0.1\ncapabilities:\n conversation:\n read: true\n",
7317 )
7318 .expect("a capsule-wide block is accepted");
7319 assert_eq!(
7320 manifest.capabilities.unwrap().conversation,
7321 Some(ConversationCapabilities { read: true })
7322 );
7323 }
7324
7325 #[test]
7330 fn a_context_block_records_by_default() {
7331 let context = context_of("context:\n max_tokens: 1000\n").unwrap();
7332 assert!(context.record);
7333 assert_eq!(context.record_store, None);
7334 }
7335
7336 #[test]
7337 fn record_off_and_on_are_the_two_accepted_values() {
7338 assert!(!context_of("context:\n record: off\n").unwrap().record);
7339 assert!(context_of("context:\n record: on\n").unwrap().record);
7340
7341 let err = RuntimeManifest::from_yaml_str(
7342 "name: cap\nversion: 0.0.1\ncontext:\n record: sometimes\n",
7343 )
7344 .unwrap_err();
7345 let msg = err.to_string();
7346 assert!(msg.contains("context.record"), "was: {msg}");
7347 assert!(msg.contains("sometimes"), "was: {msg}");
7348 }
7349
7350 #[test]
7353 fn record_store_is_kept_verbatim_and_trimmed() {
7354 assert_eq!(
7355 context_of("context:\n record_store: ' shey '\n")
7356 .unwrap()
7357 .record_store,
7358 Some("shey".to_string())
7359 );
7360 assert_eq!(
7361 context_of("context:\n record: off\n record_store: shey\n")
7362 .unwrap()
7363 .record_store,
7364 Some("shey".to_string()),
7365 "a store declared beside record: off is kept and simply never used"
7366 );
7367 }
7368
7369 #[test]
7370 fn interpreter_runtime_rejects_missing_list_dir() {
7371 let msg = interpreter_runtime_reject(
7372 " - binary: python3\n dirs:\n - path: /usr/lib/python3.11\n",
7373 );
7374 assert!(
7375 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
7376 "{msg}"
7377 );
7378 assert!(msg.contains("explicitly"), "{msg}");
7379 }
7380
7381 #[test]
7382 fn interpreter_runtime_rejects_empty_dirs() {
7383 let msg = interpreter_runtime_reject(" - binary: python3\n dirs: []\n");
7384 assert!(
7385 msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
7386 "{msg}"
7387 );
7388 assert!(msg.contains("at least one"), "{msg}");
7389 }
7390
7391 #[test]
7392 fn staged_runtime_parses_accepted_shape() {
7393 let manifest = RuntimeManifest::from_yaml_str(
7394 r#"
7395name: cap
7396version: 0.0.1
7397artifacts: []
7398capabilities:
7399 containment: sealed
7400 shell:
7401 allow:
7402 - python3
7403 - bash
7404 staged_runtime:
7405 - binary: python3
7406 source_path: /opt/testbed/conda/envs/django__django
7407 pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
7408"#,
7409 )
7410 .unwrap();
7411
7412 let shell = manifest.capabilities.unwrap().shell.unwrap();
7413 assert_eq!(
7414 shell.staged_runtime,
7415 vec![StagedRuntimeGrant {
7416 binary: "python3".to_string(),
7417 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
7418 pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
7419 }]
7420 );
7421 assert!(shell.interpreter_runtime.is_empty());
7424 }
7425
7426 #[test]
7427 fn staged_runtime_defaults_to_empty_when_absent() {
7428 let manifest = RuntimeManifest::from_yaml_str(
7429 r#"
7430name: cap
7431version: 0.0.1
7432artifacts: []
7433capabilities:
7434 shell:
7435 allow:
7436 - python3
7437"#,
7438 )
7439 .unwrap();
7440
7441 let shell = manifest.capabilities.unwrap().shell.unwrap();
7442 assert!(shell.staged_runtime.is_empty());
7443 }
7444
7445 fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
7449 let yaml = format!(
7450 r#"
7451name: cap
7452version: 0.0.1
7453artifacts: []
7454capabilities:
7455 shell:
7456 allow:
7457 - python3
7458{extra} staged_runtime:
7459{grant_yaml}
7460"#,
7461 );
7462 RuntimeManifest::from_yaml_str(&yaml)
7463 .expect_err("grant should be rejected")
7464 .to_string()
7465 }
7466
7467 #[test]
7468 fn staged_runtime_rejects_binary_not_in_allow() {
7469 let msg = staged_runtime_reject(
7470 " - binary: ruby\n source_path: /opt/ruby\n pin: ruby-3.2.2\n",
7471 "",
7472 );
7473 assert!(
7474 msg.contains("capabilities.shell.staged_runtime[0].binary"),
7475 "{msg}"
7476 );
7477 assert!(msg.contains("ruby"), "{msg}");
7478 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
7479 }
7480
7481 #[test]
7482 fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
7483 let msg = staged_runtime_reject(
7484 " - binary: python3\n source_path: /opt/py\n pin: cpython-3.9.19\n",
7485 " interpreter_runtime:\n - binary: python3\n dirs:\n - path: /usr/lib/python3.9\n list_dir: true\n",
7486 );
7487 assert!(
7488 msg.contains("capabilities.shell.staged_runtime[0].binary"),
7489 "{msg}"
7490 );
7491 assert!(msg.contains("python3"), "{msg}");
7492 assert!(msg.contains("interpreter_runtime"), "{msg}");
7493 assert!(msg.contains("mutually exclusive"), "{msg}");
7494 }
7495
7496 #[test]
7497 fn staged_runtime_rejects_relative_source_path() {
7498 let msg = staged_runtime_reject(
7499 " - binary: python3\n source_path: opt/testbed/conda\n pin: cpython-3.9.19\n",
7500 "",
7501 );
7502 assert!(
7503 msg.contains("capabilities.shell.staged_runtime[0].source_path"),
7504 "{msg}"
7505 );
7506 assert!(msg.contains("absolute"), "{msg}");
7507 }
7508
7509 #[test]
7510 fn staged_runtime_rejects_missing_or_empty_pin() {
7511 for grant in [
7514 " - binary: python3\n source_path: /opt/testbed/conda\n",
7515 " - binary: python3\n source_path: /opt/testbed/conda\n pin: \" \"\n",
7516 ] {
7517 let msg = staged_runtime_reject(grant, "");
7518 assert!(
7519 msg.contains("capabilities.shell.staged_runtime[0].pin"),
7520 "{msg}"
7521 );
7522 assert!(msg.contains("never inferred"), "{msg}");
7523 }
7524 }
7525
7526 #[test]
7527 fn parses_inference_block() {
7528 let manifest = RuntimeManifest::from_yaml_str(
7529 r#"
7530name: cap
7531version: 0.0.1
7532artifacts:
7533 - name: murmur-driver-anthropic
7534 version: 0.1.0
7535 runtime: driver
7536 gateway:
7537 endpoint: http://127.0.0.1:8080
7538 api_key: literal-key
7539inference:
7540 transport: http
7541 model: test-model
7542 driver:
7543 artifact: murmur-driver-anthropic
7544 config:
7545 temperature: 0.2
7546 headers:
7547 x-test: true
7548"#,
7549 )
7550 .unwrap();
7551
7552 assert_eq!(
7553 manifest.artifacts[0].gateway,
7554 Some(ArtifactGateway {
7555 endpoint: "http://127.0.0.1:8080".to_string(),
7556 api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
7557 keyless: false,
7558 })
7559 );
7560 let inference = manifest.inference.expect("inference should exist");
7561 assert_eq!(inference.transport, "http");
7562 assert_eq!(inference.model, "test-model");
7563 let driver = inference.driver.as_ref().expect("driver should be present");
7564 assert_eq!(driver.artifact, "murmur-driver-anthropic");
7565 assert_eq!(
7566 driver.config,
7567 Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
7568 );
7569 assert!(inference.system_prompt.is_none());
7570 assert!(inference.system_prompt_file.is_none());
7571 }
7572
7573 fn tool_gateway_manifest(block: &str) -> String {
7576 format!(
7577 "name: cap\nversion: 0.0.1\nartifacts:\n - name: web-search\n version: 0.1.0\n runtime: tool\n gateway:{block}\n"
7578 )
7579 }
7580
7581 fn gateway_endpoint_error(endpoint: &str) -> String {
7582 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
7583 "\n endpoint: \"{endpoint}\""
7584 )))
7585 .unwrap_err()
7586 .to_string()
7587 }
7588
7589 #[test]
7590 fn gateway_endpoint_https_is_accepted() {
7591 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7592 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
7593 ))
7594 .unwrap();
7595 assert_eq!(
7596 manifest.artifacts[0].gateway,
7597 Some(ArtifactGateway {
7598 endpoint: "https://api.tavily.com".to_string(),
7599 api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
7600 keyless: false,
7601 })
7602 );
7603 }
7604
7605 #[test]
7606 fn gateway_endpoint_http_localhost_is_accepted() {
7607 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7608 "\n endpoint: http://localhost:11434\n keyless: true",
7609 ))
7610 .unwrap();
7611 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
7612 assert_eq!(gateway.endpoint, "http://localhost:11434");
7613 assert_eq!(gateway.api_key, None);
7614 assert!(gateway.keyless);
7615 }
7616
7617 #[test]
7618 fn gateway_endpoint_http_loopback_ip_is_accepted() {
7619 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7620 "\n endpoint: http://127.0.0.1:8080\n api_key: ${LOCAL_KEY}",
7621 ))
7622 .unwrap();
7623 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
7624 assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
7625 assert_eq!(
7626 gateway.api_key,
7627 Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
7628 );
7629 }
7630
7631 #[test]
7632 fn gateway_endpoint_without_scheme_is_refused() {
7633 let msg = gateway_endpoint_error("api.tavily.com");
7634 assert!(msg.contains("gateway.endpoint"), "{msg}");
7635 assert!(msg.contains("artifact 'web-search'"), "{msg}");
7636 assert!(msg.contains("does not parse as a URL"), "{msg}");
7637 }
7638
7639 #[test]
7640 fn gateway_endpoint_ftp_scheme_is_refused() {
7641 let msg = gateway_endpoint_error("ftp://example.com");
7642 assert!(msg.contains("gateway.endpoint"), "{msg}");
7643 assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
7644 }
7645
7646 #[test]
7647 fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
7648 let msg = gateway_endpoint_error("http://api.attacker.example.com");
7649 assert!(msg.contains("gateway.endpoint"), "{msg}");
7650 assert!(msg.contains("api.attacker.example.com"), "{msg}");
7651 assert!(msg.contains("non-loopback"), "{msg}");
7652 }
7653
7654 #[test]
7655 fn gateway_endpoint_with_a_query_is_refused() {
7656 let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
7657 assert!(msg.contains("gateway.endpoint"), "{msg}");
7658 assert!(msg.contains("carries a query"), "{msg}");
7659 }
7660
7661 #[test]
7662 fn gateway_endpoint_with_a_fragment_is_refused() {
7663 let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
7664 assert!(msg.contains("gateway.endpoint"), "{msg}");
7665 assert!(msg.contains("carries a fragment"), "{msg}");
7666 }
7667
7668 #[test]
7669 fn gateway_endpoint_with_userinfo_is_refused() {
7670 let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
7671 assert!(msg.contains("artifact 'web-search'"), "{msg}");
7672 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
7673 assert!(msg.contains("host 'api.example.com'"), "{msg}");
7674 assert!(msg.contains("gateway.api_key"), "{msg}");
7675 assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
7676 assert!(
7677 !msg.contains("sk-in-url"),
7678 "a password in the URL is never echoed: {msg}"
7679 );
7680 }
7681
7682 #[test]
7683 fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
7684 let msg = gateway_endpoint_error("https://user:sk-in-url@");
7685 assert!(msg.contains("does not parse as a URL"), "{msg}");
7686 assert!(
7687 !msg.contains("sk-in-url"),
7688 "a password in the URL is never echoed: {msg}"
7689 );
7690 }
7691
7692 #[test]
7693 fn gateway_endpoint_with_a_masquerading_host_is_refused() {
7694 let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
7695 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
7696 assert!(
7697 msg.contains("addresses host '127.0.0.1'"),
7698 "the message names the host the URL really addresses: {msg}"
7699 );
7700 }
7701
7702 #[test]
7703 fn gateway_endpoint_with_interpolation_is_refused() {
7704 let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
7705 assert!(msg.contains("artifact 'web-search'"), "{msg}");
7706 assert!(msg.contains("contains '${'"), "{msg}");
7707 assert!(msg.contains("not interpolated"), "{msg}");
7708 }
7709
7710 #[test]
7711 fn gateway_endpoint_http_unspecified_address_is_refused() {
7712 let msg = gateway_endpoint_error("http://0.0.0.0:8080");
7713 assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
7714 }
7715
7716 #[test]
7717 fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
7718 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7719 "\n endpoint: http://[::1]:8080\n api_key: ${LOCAL_KEY}",
7720 ))
7721 .unwrap();
7722 assert_eq!(
7723 manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
7724 "http://[::1]:8080"
7725 );
7726 }
7727
7728 fn gateway_credential_error(block: &str) -> RuntimeManifestError {
7730 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
7731 }
7732
7733 fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
7734 match &error {
7735 RuntimeManifestError::GatewayWithoutCredential {
7736 index,
7737 name,
7738 endpoint: refused,
7739 } => {
7740 assert_eq!(*index, 0);
7741 assert_eq!(name, "web-search");
7742 assert_eq!(refused, endpoint);
7743 }
7744 other => panic!("expected GatewayWithoutCredential, got {other:?}"),
7745 }
7746 let msg = error.to_string();
7747 assert!(
7748 msg.contains(&format!(
7749 "artifact 'web-search' declares gateway.endpoint '{endpoint}' but binds no \
7750 credential"
7751 )),
7752 "{msg}"
7753 );
7754 }
7755
7756 #[test]
7757 fn gateway_credential_absent_is_refused() {
7758 assert_without_credential(
7759 gateway_credential_error("\n endpoint: https://api.tavily.com"),
7760 "https://api.tavily.com",
7761 );
7762 }
7763
7764 #[test]
7765 fn gateway_credential_blank_is_refused() {
7766 for key in ["\"\"", "\" \"", "''"] {
7767 assert_without_credential(
7768 gateway_credential_error(&format!(
7769 "\n endpoint: https://api.tavily.com\n api_key: {key}"
7770 )),
7771 "https://api.tavily.com",
7772 );
7773 }
7774 }
7775
7776 #[test]
7777 fn gateway_credential_null_is_refused() {
7778 for key in ["~", "null", ""] {
7779 assert_without_credential(
7780 gateway_credential_error(&format!(
7781 "\n endpoint: https://api.tavily.com\n api_key: {key}"
7782 )),
7783 "https://api.tavily.com",
7784 );
7785 }
7786 }
7787
7788 #[test]
7789 fn gateway_credential_keyless_false_is_refused() {
7790 assert_without_credential(
7791 gateway_credential_error(
7792 "\n endpoint: https://api.tavily.com\n keyless: false",
7793 ),
7794 "https://api.tavily.com",
7795 );
7796 }
7797
7798 #[test]
7799 fn gateway_credential_keyless_is_accepted() {
7800 for (parse, mode) in [
7801 (
7802 RuntimeManifest::from_yaml_str as fn(&str) -> _,
7803 "with secrets",
7804 ),
7805 (
7806 RuntimeManifest::from_yaml_str_without_secrets,
7807 "without secrets",
7808 ),
7809 ] {
7810 let manifest = parse(&tool_gateway_manifest(
7811 "\n endpoint: https://api.example.com\n keyless: true",
7812 ))
7813 .unwrap();
7814 assert_eq!(
7815 manifest.artifacts[0].gateway,
7816 Some(ArtifactGateway {
7817 endpoint: "https://api.example.com".to_string(),
7818 api_key: None,
7819 keyless: true,
7820 }),
7821 "{mode}"
7822 );
7823 }
7824 }
7825
7826 #[test]
7827 fn gateway_credential_keyless_not_a_boolean_is_refused() {
7828 for value in ["\"true\"", "yes please", "1"] {
7829 let error = gateway_credential_error(&format!(
7830 "\n endpoint: https://api.tavily.com\n keyless: {value}"
7831 ));
7832 assert!(
7833 matches!(
7834 error,
7835 RuntimeManifestError::InvalidArtifact { index: 0, .. }
7836 ),
7837 "{value}: {error:?}"
7838 );
7839 assert!(
7840 error
7841 .to_string()
7842 .contains("gateway.keyless with a value that is not a boolean"),
7843 "{error}"
7844 );
7845 }
7846 }
7847
7848 #[test]
7849 fn gateway_credential_and_keyless_together_are_refused() {
7850 for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
7851 let error = gateway_credential_error(&format!(
7852 "\n endpoint: https://api.tavily.com\n api_key: {key}\n keyless: true"
7853 ));
7854 assert!(
7855 matches!(
7856 error,
7857 RuntimeManifestError::InvalidArtifact { index: 0, .. }
7858 ),
7859 "{error:?}"
7860 );
7861 assert!(
7862 error.to_string().contains(
7863 "artifact 'web-search' declares both gateway.api_key and gateway.keyless: \
7864 true; a gateway either presents a key or is keyless"
7865 ),
7866 "{error}"
7867 );
7868 }
7869 }
7870
7871 #[test]
7872 fn gateway_credential_absent_is_refused_without_secrets() {
7873 for block in [
7874 "\n endpoint: https://api.tavily.com",
7875 "\n endpoint: https://api.tavily.com\n api_key: \"\"",
7876 "\n endpoint: https://api.tavily.com\n keyless: false",
7877 ] {
7878 assert_without_credential(
7879 RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
7880 .unwrap_err(),
7881 "https://api.tavily.com",
7882 );
7883 }
7884 let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
7885 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
7886 ))
7887 .unwrap();
7888 let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
7889 assert_eq!(gateway.api_key, None, "the key reference is left unread");
7890 assert!(!gateway.keyless, "a keyed gateway is never keyless");
7891 }
7892
7893 #[test]
7896 fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
7897 for endpoint in [
7898 "http://127.0.0.1:11434",
7899 "http://localhost:11434",
7900 "http://[::1]:11434",
7901 "https://10.0.0.5",
7902 "https://192.168.1.10:8443",
7903 ] {
7904 assert_without_credential(
7905 gateway_credential_error(&format!("\n endpoint: {endpoint}")),
7906 endpoint,
7907 );
7908 }
7909 }
7910
7911 #[test]
7912 fn gateway_endpoint_missing_from_the_block_is_refused() {
7913 for block in [
7914 "\n api_key: ${TAVILY_API_KEY}",
7915 "",
7916 " {}",
7917 "\n endpoint: \" \"",
7918 ] {
7919 let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
7920 .unwrap_err()
7921 .to_string();
7922 assert!(
7923 msg.contains("without 'gateway.endpoint'"),
7924 "{block:?}: {msg}"
7925 );
7926 assert!(msg.contains("artifact 'web-search'"), "{block:?}: {msg}");
7927 }
7928 }
7929
7930 #[test]
7931 fn gateway_block_unknown_key_is_reported() {
7932 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
7933 "\n endpoint: https://api.tavily.com\n keyless: true\n api_kee: x",
7934 ))
7935 .unwrap();
7936 let unknown = manifest.unknown_keys;
7937 assert_eq!(unknown.len(), 1, "{unknown:?}");
7938 assert_eq!(unknown[0].key, "api_kee");
7939 assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
7940 }
7941
7942 #[test]
7943 fn gateway_on_skill_entry_is_refused() {
7944 let msg = RuntimeManifest::from_yaml_str(
7945 "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",
7946 )
7947 .unwrap_err()
7948 .to_string();
7949 assert!(
7950 msg.contains("artifact 'notes' declares 'gateway:'"),
7951 "{msg}"
7952 );
7953 assert!(msg.contains("runtime: skill"), "{msg}");
7954 }
7955
7956 #[test]
7957 fn gateway_on_hook_entry_is_accepted() {
7958 let manifest = RuntimeManifest::from_yaml_str(
7959 "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",
7960 )
7961 .unwrap();
7962 assert!(manifest.artifacts[0].gateway.is_some());
7963 }
7964
7965 fn http_driver_manifest(artifacts: &str) -> String {
7968 format!(
7969 "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur-driver-anthropic\n"
7970 )
7971 }
7972
7973 #[test]
7974 fn http_driver_entry_without_gateway_endpoint_is_refused() {
7975 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
7976 "\n - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: driver",
7977 ))
7978 .unwrap_err()
7979 .to_string();
7980 assert!(msg.contains("'murmur-driver-anthropic'"), "{msg}");
7981 assert!(msg.contains("gateway.endpoint"), "{msg}");
7982 }
7983
7984 #[test]
7985 fn http_driver_absent_from_artifacts_is_refused() {
7986 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
7987 .unwrap_err()
7988 .to_string();
7989 assert!(msg.contains("inference.driver.artifact"), "{msg}");
7990 assert!(msg.contains("not declared in artifacts:"), "{msg}");
7991 }
7992
7993 #[test]
7994 fn gateway_on_unconfigured_driver_entry_is_refused() {
7995 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
7996 "\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",
7997 ))
7998 .unwrap_err()
7999 .to_string();
8000 assert!(msg.contains("'murmur-driver-openai'"), "{msg}");
8001 assert!(
8002 msg.contains("accepted only on the entry inference.driver.artifact names"),
8003 "{msg}"
8004 );
8005
8006 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";
8007 let msg = RuntimeManifest::from_yaml_str(no_inference)
8008 .unwrap_err()
8009 .to_string();
8010 assert!(msg.contains("not the configured inference driver"), "{msg}");
8011 }
8012
8013 #[test]
8014 fn gateway_on_driver_entry_under_process_transport_is_refused() {
8015 let msg = RuntimeManifest::from_yaml_str(
8016 "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",
8017 )
8018 .unwrap_err()
8019 .to_string();
8020 assert!(msg.contains("'murmur-driver-anthropic'"), "{msg}");
8021 assert!(msg.contains("transport: process"), "{msg}");
8022 }
8023
8024 #[test]
8025 fn inference_api_key_is_refused_naming_gateway_api_key() {
8026 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";
8027 for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
8028 let manifest = http_driver_manifest(entry)
8029 .replace(" model:", &format!(" api_key: {value}\n model:"));
8030 let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
8031 let msg = err.to_string();
8032 assert!(msg.contains("'inference.api_key'"), "{msg}");
8033 assert!(msg.contains("was removed"), "{msg}");
8034 assert!(
8035 msg.contains(
8036 "set gateway.api_key on the artifacts: entry 'murmur-driver-anthropic'"
8037 ),
8038 "{msg}"
8039 );
8040 assert!(!msg.contains("sk-literal"), "{msg}");
8041 }
8042
8043 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n api_key: ${KEY}\n";
8044 let msg = RuntimeManifest::from_yaml_str(process)
8045 .unwrap_err()
8046 .to_string();
8047 assert!(msg.contains("'inference.api_key'"), "{msg}");
8048 assert!(
8049 msg.contains("on the artifacts: entry inference.driver.artifact names"),
8050 "{msg}"
8051 );
8052 assert!(
8053 RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
8054 "the refusal does not depend on resolving secrets"
8055 );
8056 }
8057
8058 #[test]
8059 fn inference_endpoint_is_refused_naming_gateway_endpoint() {
8060 let manifest = http_driver_manifest(
8061 "\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",
8062 )
8063 .replace(" model:", " endpoint: https://api.anthropic.com\n model:");
8064 let msg = RuntimeManifest::from_yaml_str(&manifest)
8065 .unwrap_err()
8066 .to_string();
8067 assert!(msg.contains("'inference.endpoint'"), "{msg}");
8068 assert!(
8069 msg.contains("set gateway.endpoint on the artifacts: entry 'murmur-driver-anthropic'"),
8070 "{msg}"
8071 );
8072
8073 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n endpoint: http://localhost:8080\n";
8074 let msg = RuntimeManifest::from_yaml_str(process)
8075 .unwrap_err()
8076 .to_string();
8077 assert!(msg.contains("'inference.endpoint'"), "{msg}");
8078 assert!(msg.contains("was removed"), "{msg}");
8079 }
8080
8081 #[test]
8082 fn without_secrets_leaves_gateway_api_key_unread() {
8083 let manifest = tool_gateway_manifest(
8084 "\n endpoint: https://api.tavily.com\n api_key: tvly-literal",
8085 );
8086 let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
8087 assert_eq!(
8088 resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
8089 Some(ApiKeyReference::Literal("tvly-literal".to_string()))
8090 );
8091 let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
8092 let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
8093 assert_eq!(gateway.endpoint, "https://api.tavily.com");
8094 assert_eq!(gateway.api_key, None);
8095 }
8096
8097 #[test]
8098 fn referenced_env_variables_reads_gateway_api_key() {
8099 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";
8100 let referenced = referenced_env_variables(manifest);
8101 assert_eq!(
8102 referenced,
8103 vec![
8104 ReferencedEnvVariable {
8105 field: "artifacts.murmur-driver-anthropic.gateway.api_key".to_string(),
8106 variable: "ANTHROPIC_API_KEY".to_string(),
8107 },
8108 ReferencedEnvVariable {
8109 field: "artifacts.web-search.gateway.api_key".to_string(),
8110 variable: "TAVILY_API_KEY".to_string(),
8111 },
8112 ]
8113 );
8114 assert!(referenced
8115 .iter()
8116 .all(ReferencedEnvVariable::is_gateway_credential));
8117 }
8118
8119 #[test]
8120 fn parses_inline_system_prompt() {
8121 let manifest = RuntimeManifest::from_yaml_str(
8122 r#"
8123name: cap
8124version: 0.0.1
8125artifacts:
8126 - name: murmur-driver-anthropic
8127 version: 0.1.0
8128 runtime: driver
8129 gateway:
8130 endpoint: http://127.0.0.1:8080
8131 keyless: true
8132inference:
8133 model: test-model
8134 system_prompt: "Always begin with CONFIRMED:"
8135 driver:
8136 artifact: murmur-driver-anthropic
8137"#,
8138 )
8139 .unwrap();
8140
8141 let inference = manifest.inference.unwrap();
8142 assert_eq!(
8143 inference.system_prompt,
8144 Some("Always begin with CONFIRMED:".to_string())
8145 );
8146 assert!(inference.system_prompt_file.is_none());
8147 }
8148
8149 #[test]
8150 fn parses_compaction_system_prompt() {
8151 let manifest = RuntimeManifest::from_yaml_str(
8152 r#"
8153name: cap
8154version: 0.0.1
8155artifacts:
8156 - name: murmur-driver-anthropic
8157 version: 0.1.0
8158 runtime: driver
8159 gateway:
8160 endpoint: http://127.0.0.1:8080
8161 keyless: true
8162inference:
8163 model: test-model
8164 compaction:
8165 model: compaction-model
8166 system_prompt: "task = X, currently editing Y, already tried Z."
8167 driver:
8168 artifact: murmur-driver-anthropic
8169"#,
8170 )
8171 .unwrap();
8172
8173 let compaction = manifest.inference.unwrap().compaction.unwrap();
8174 assert_eq!(
8175 compaction.system_prompt,
8176 Some("task = X, currently editing Y, already tried Z.".to_string())
8177 );
8178 assert_eq!(compaction.model, Some("compaction-model".to_string()));
8179 }
8180
8181 #[test]
8184 fn compaction_system_prompt_absent_stays_none() {
8185 let manifest = RuntimeManifest::from_yaml_str(
8186 r#"
8187name: cap
8188version: 0.0.1
8189artifacts:
8190 - name: murmur-driver-anthropic
8191 version: 0.1.0
8192 runtime: driver
8193 gateway:
8194 endpoint: http://127.0.0.1:8080
8195 keyless: true
8196inference:
8197 model: test-model
8198 compaction:
8199 model: compaction-model
8200 driver:
8201 artifact: murmur-driver-anthropic
8202"#,
8203 )
8204 .unwrap();
8205
8206 let compaction = manifest.inference.unwrap().compaction.unwrap();
8207 assert!(compaction.system_prompt.is_none());
8208 assert!(compaction.system_prompt_file.is_none());
8209 assert_eq!(compaction.model, Some("compaction-model".to_string()));
8210 }
8211
8212 #[test]
8216 fn parses_compaction_system_prompt_file() {
8217 let manifest = RuntimeManifest::from_yaml_str(
8218 r#"
8219name: cap
8220version: 0.0.1
8221artifacts:
8222 - name: murmur-driver-anthropic
8223 version: 0.1.0
8224 runtime: driver
8225 gateway:
8226 endpoint: http://127.0.0.1:8080
8227 keyless: true
8228inference:
8229 model: test-model
8230 compaction:
8231 model: compaction-model
8232 system_prompt_file: "compaction-instructions.md"
8233 driver:
8234 artifact: murmur-driver-anthropic
8235"#,
8236 )
8237 .unwrap();
8238
8239 let compaction = manifest.inference.unwrap().compaction.unwrap();
8240 assert_eq!(
8241 compaction.system_prompt_file,
8242 Some("compaction-instructions.md".to_string())
8243 );
8244 assert!(compaction.system_prompt.is_none());
8245 assert_eq!(compaction.model, Some("compaction-model".to_string()));
8246 }
8247
8248 #[test]
8251 fn compaction_system_prompt_file_independent_of_model() {
8252 let manifest = RuntimeManifest::from_yaml_str(
8253 r#"
8254name: cap
8255version: 0.0.1
8256artifacts:
8257 - name: murmur-driver-anthropic
8258 version: 0.1.0
8259 runtime: driver
8260 gateway:
8261 endpoint: http://127.0.0.1:8080
8262 keyless: true
8263inference:
8264 model: test-model
8265 compaction:
8266 threshold: 0.5
8267 system_prompt_file: "compaction-instructions.md"
8268 driver:
8269 artifact: murmur-driver-anthropic
8270"#,
8271 )
8272 .unwrap();
8273
8274 let compaction = manifest.inference.unwrap().compaction.unwrap();
8275 assert_eq!(
8276 compaction.system_prompt_file,
8277 Some("compaction-instructions.md".to_string())
8278 );
8279 assert!(compaction.model.is_none());
8280 assert_eq!(compaction.threshold, Some(0.5));
8281 }
8282
8283 #[test]
8287 fn parses_compaction_dump_summaries() {
8288 let parse = |line: &str| {
8289 RuntimeManifest::from_yaml_str(&format!(
8290 r#"
8291name: cap
8292version: 0.0.1
8293artifacts:
8294 - name: murmur-driver-anthropic
8295 version: 0.1.0
8296 runtime: driver
8297 gateway:
8298 endpoint: http://127.0.0.1:8080
8299 keyless: true
8300inference:
8301 model: test-model
8302 compaction:
8303 model: compaction-model
8304{line} driver:
8305 artifact: murmur-driver-anthropic
8306"#
8307 ))
8308 .unwrap()
8309 .inference
8310 .unwrap()
8311 .compaction
8312 .unwrap()
8313 };
8314
8315 assert_eq!(
8316 parse(" dump_summaries: true\n").dump_summaries,
8317 Some(true)
8318 );
8319 assert_eq!(
8320 parse(" dump_summaries: false\n").dump_summaries,
8321 Some(false)
8322 );
8323 assert_eq!(parse("").dump_summaries, None);
8324 }
8325
8326 #[test]
8330 fn compaction_dump_summaries_does_not_interact_with_other_fields() {
8331 let compaction = RuntimeManifest::from_yaml_str(
8332 r#"
8333name: cap
8334version: 0.0.1
8335artifacts:
8336 - name: murmur-driver-anthropic
8337 version: 0.1.0
8338 runtime: driver
8339 gateway:
8340 endpoint: http://127.0.0.1:8080
8341 keyless: true
8342inference:
8343 model: test-model
8344 compaction:
8345 threshold: 0.5
8346 model: compaction-model
8347 system_prompt_file: "compaction-instructions.md"
8348 dump_summaries: true
8349 driver:
8350 artifact: murmur-driver-anthropic
8351"#,
8352 )
8353 .unwrap()
8354 .inference
8355 .unwrap()
8356 .compaction
8357 .unwrap();
8358
8359 assert_eq!(compaction.dump_summaries, Some(true));
8360 assert_eq!(compaction.threshold, Some(0.5));
8361 assert_eq!(compaction.model, Some("compaction-model".to_string()));
8362 assert_eq!(
8363 compaction.system_prompt_file,
8364 Some("compaction-instructions.md".to_string())
8365 );
8366 assert!(compaction.system_prompt.is_none());
8367
8368 let err = RuntimeManifest::from_yaml_str(
8370 r#"
8371name: cap
8372version: 0.0.1
8373artifacts:
8374 - name: murmur-driver-anthropic
8375 version: 0.1.0
8376 runtime: driver
8377 gateway:
8378 endpoint: http://127.0.0.1:8080
8379 keyless: true
8380inference:
8381 model: test-model
8382 compaction:
8383 dump_summaries: true
8384 system_prompt: "inline"
8385 system_prompt_file: "compaction-instructions.md"
8386 driver:
8387 artifact: murmur-driver-anthropic
8388"#,
8389 )
8390 .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
8391 assert!(matches!(
8392 err,
8393 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
8394 if field == "inference.compaction.system_prompt"
8395 ));
8396
8397 let err = RuntimeManifest::from_yaml_str(
8399 r#"
8400name: cap
8401version: 0.0.1
8402artifacts:
8403 - name: murmur-driver-anthropic
8404 version: 0.1.0
8405 runtime: driver
8406 gateway:
8407 endpoint: http://127.0.0.1:8080
8408 keyless: true
8409inference:
8410 model: test-model
8411 compaction:
8412 dump_summaries: true
8413 threshold: 1.5
8414 driver:
8415 artifact: murmur-driver-anthropic
8416"#,
8417 )
8418 .expect_err("dump_summaries must not suppress the threshold range check");
8419 assert!(matches!(
8420 err,
8421 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
8422 if field == "inference.compaction.threshold"
8423 ));
8424 }
8425
8426 #[test]
8430 fn compaction_prompt_sources_are_mutually_exclusive() {
8431 let err = RuntimeManifest::from_yaml_str(
8432 r#"
8433name: cap
8434version: 0.0.1
8435artifacts:
8436 - name: murmur-driver-anthropic
8437 version: 0.1.0
8438 runtime: driver
8439 gateway:
8440 endpoint: http://127.0.0.1:8080
8441 keyless: true
8442inference:
8443 model: test-model
8444 compaction:
8445 system_prompt: "inline"
8446 system_prompt_file: "compaction-instructions.md"
8447 driver:
8448 artifact: murmur-driver-anthropic
8449"#,
8450 )
8451 .expect_err("both compaction prompt sources set must fail");
8452
8453 match err {
8454 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
8455 assert_eq!(field, "inference.compaction.system_prompt");
8456 assert_eq!(
8457 message,
8458 "at most one of inference.compaction.system_prompt, \
8459 inference.compaction.system_prompt_file may be set"
8460 );
8461 }
8462 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
8463 }
8464 }
8465
8466 #[test]
8470 fn top_level_and_compaction_prompt_sources_do_not_collide() {
8471 let manifest = RuntimeManifest::from_yaml_str(
8472 r#"
8473name: cap
8474version: 0.0.1
8475artifacts:
8476 - name: murmur-driver-anthropic
8477 version: 0.1.0
8478 runtime: driver
8479 gateway:
8480 endpoint: http://127.0.0.1:8080
8481 keyless: true
8482inference:
8483 model: test-model
8484 system_prompt: "be helpful"
8485 compaction:
8486 system_prompt_file: "compaction-instructions.md"
8487 driver:
8488 artifact: murmur-driver-anthropic
8489"#,
8490 )
8491 .unwrap();
8492
8493 let inference = manifest.inference.unwrap();
8494 assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
8495 assert_eq!(
8496 inference.compaction.unwrap().system_prompt_file,
8497 Some("compaction-instructions.md".to_string())
8498 );
8499 }
8500
8501 #[test]
8502 fn parses_system_prompt_file() {
8503 let manifest = RuntimeManifest::from_yaml_str(
8504 r#"
8505name: cap
8506version: 0.0.1
8507artifacts:
8508 - name: murmur-driver-anthropic
8509 version: 0.1.0
8510 runtime: driver
8511 gateway:
8512 endpoint: http://127.0.0.1:8080
8513 keyless: true
8514inference:
8515 model: test-model
8516 system_prompt_file: "conventions.md"
8517 driver:
8518 artifact: murmur-driver-anthropic
8519"#,
8520 )
8521 .unwrap();
8522
8523 let inference = manifest.inference.unwrap();
8524 assert_eq!(
8525 inference.system_prompt_file,
8526 Some("conventions.md".to_string())
8527 );
8528 assert!(inference.system_prompt.is_none());
8529 }
8530
8531 #[test]
8532 fn rejects_both_inline_and_file_system_prompt() {
8533 let err = RuntimeManifest::from_yaml_str(
8534 r#"
8535name: cap
8536version: 0.0.1
8537artifacts:
8538 - name: murmur-driver-anthropic
8539 version: 0.1.0
8540 runtime: driver
8541 gateway:
8542 endpoint: http://127.0.0.1:8080
8543 keyless: true
8544inference:
8545 model: test-model
8546 system_prompt: "inline"
8547 system_prompt_file: "conventions.md"
8548 driver:
8549 artifact: murmur-driver-anthropic
8550"#,
8551 )
8552 .unwrap_err();
8553
8554 let msg = err.to_string();
8555 assert!(msg.contains("inference.system_prompt"));
8556 assert!(msg.contains("system_prompt_file"));
8557 }
8558
8559 #[test]
8562 fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
8563 let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
8564 let yaml = format!(
8565 "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"
8566 );
8567
8568 assert_eq!(
8569 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
8570 .gateway
8571 .clone()
8572 .unwrap()
8573 .api_key,
8574 Some(ApiKeyReference::Environment(key.to_string()))
8575 );
8576 assert_eq!(
8577 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
8578 .unwrap()
8579 .artifacts[0]
8580 .gateway
8581 .clone()
8582 .unwrap()
8583 .api_key,
8584 None
8585 );
8586 }
8587
8588 #[test]
8589 fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
8590 let literal = ["sk-", "ant-", "debug"].concat();
8591 let debug = format!(
8592 "{:?} {:?}",
8593 ApiKeyReference::Environment("SOME_KEY".to_string()),
8594 ApiKeyReference::Literal(literal.clone())
8595 );
8596 assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
8597 assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
8598 assert!(!debug.contains(&literal), "{debug}");
8599 }
8600
8601 #[test]
8602 fn parse_without_secrets_drops_a_literal_api_key() {
8603 let literal = ["sk-", "ant-", "literal"].concat();
8606 let yaml = format!(
8607 "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"
8608 );
8609
8610 assert_eq!(
8611 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
8612 .gateway
8613 .clone()
8614 .unwrap()
8615 .api_key,
8616 Some(ApiKeyReference::Literal(literal))
8617 );
8618 assert_eq!(
8619 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
8620 .unwrap()
8621 .artifacts[0]
8622 .gateway
8623 .clone()
8624 .unwrap()
8625 .api_key,
8626 None
8627 );
8628 }
8629
8630 #[test]
8633 fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
8634 let process_with_key = format!(
8635 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n model: test-model\n api_key: {}\n",
8636 ["sk-", "ant-", "secret"].concat()
8637 );
8638 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";
8639
8640 for yaml in [process_with_key.as_str(), http_without_model] {
8641 let resolving = RuntimeManifest::from_yaml_str(yaml)
8642 .unwrap_err()
8643 .to_string();
8644 let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
8645 .unwrap_err()
8646 .to_string();
8647 assert_eq!(resolving, without_secrets);
8648 }
8649 }
8650
8651 #[test]
8655 fn referenced_env_variables_reports_a_reference_and_nothing_else() {
8656 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";
8657 let referenced = referenced_env_variables(referencing);
8658 assert_eq!(referenced.len(), 1);
8659 assert_eq!(
8660 referenced[0].field,
8661 "artifacts.murmur-driver-anthropic.gateway.api_key"
8662 );
8663 assert!(referenced[0].is_gateway_credential());
8664 assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
8665
8666 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";
8667 assert!(referenced_env_variables(literal).is_empty());
8668
8669 let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
8670 assert!(referenced_env_variables(no_inference).is_empty());
8671
8672 let unparseable = "name: broken\nversion: [\n";
8673 assert!(referenced_env_variables(unparseable).is_empty());
8674 }
8675
8676 #[test]
8677 fn inference_requires_driver_artifact() {
8678 let err = RuntimeManifest::from_yaml_str(
8679 r#"
8680name: cap
8681version: 0.0.1
8682artifacts: []
8683inference:
8684 transport: http
8685 endpoint: http://127.0.0.1:8080
8686 model: test-model
8687"#,
8688 )
8689 .unwrap_err();
8690
8691 assert!(err.to_string().contains("inference.driver"));
8692 }
8693
8694 #[test]
8695 fn test_runtime_driver_deserializes() {
8696 let manifest = RuntimeManifest::from_yaml_str(
8697 r#"
8698name: cap
8699version: 0.0.1
8700artifacts:
8701 - name: my-provider
8702 version: 1.0.0
8703 runtime: driver
8704"#,
8705 )
8706 .unwrap();
8707
8708 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
8709 }
8710
8711 #[test]
8712 fn test_runtime_unknown_is_error() {
8713 let err = RuntimeManifest::from_yaml_str(
8714 r#"
8715name: cap
8716version: 0.0.1
8717artifacts:
8718 - name: foo
8719 version: 1.0.0
8720 runtime: unknown_type
8721"#,
8722 )
8723 .unwrap_err();
8724
8725 let msg = err.to_string();
8726 assert!(msg.contains("runtime"), "error should mention 'runtime'");
8727 assert!(
8728 msg.contains("unknown_type"),
8729 "error should identify the bad value"
8730 );
8731 }
8732
8733 #[test]
8734 fn test_runtime_all_variants_roundtrip() {
8735 for variant in [
8736 ArtifactRuntime::Tool,
8737 ArtifactRuntime::Driver,
8738 ArtifactRuntime::Hook,
8739 ArtifactRuntime::Skill,
8740 ] {
8741 let json = serde_json::to_string(&variant).unwrap();
8742 let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
8743 assert_eq!(variant, back);
8744 }
8745 }
8746
8747 #[test]
8748 fn skill_source_without_version_defaults_to_local() {
8749 let manifest = RuntimeManifest::from_yaml_str(
8750 r#"
8751name: cap
8752version: 0.0.1
8753artifacts:
8754 - name: my-skill
8755 source: ./skills/my-skill/skill.md
8756 runtime: skill
8757"#,
8758 )
8759 .unwrap();
8760
8761 assert_eq!(manifest.artifacts.len(), 1);
8762 let artifact = &manifest.artifacts[0];
8763 assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
8764 assert_eq!(
8765 artifact.source.as_deref(),
8766 Some("./skills/my-skill/skill.md")
8767 );
8768 assert_eq!(artifact.version, "local");
8769 }
8770
8771 #[test]
8772 fn skill_source_directory_path_is_accepted() {
8773 let manifest = RuntimeManifest::from_yaml_str(
8774 r#"
8775name: cap
8776version: 0.0.1
8777artifacts:
8778 - name: my-skill
8779 source: ./skills/my-skill/
8780 runtime: skill
8781"#,
8782 )
8783 .unwrap();
8784
8785 assert_eq!(
8786 manifest.artifacts[0].source.as_deref(),
8787 Some("./skills/my-skill/")
8788 );
8789 assert_eq!(manifest.artifacts[0].version, "local");
8790 }
8791
8792 #[test]
8793 fn skill_source_with_explicit_version_substitutes_local() {
8794 let manifest = RuntimeManifest::from_yaml_str(
8796 r#"
8797name: cap
8798version: 0.0.1
8799artifacts:
8800 - name: my-skill
8801 source: ./skills/my-skill/skill.md
8802 version: 9.9.9
8803 runtime: skill
8804"#,
8805 )
8806 .unwrap();
8807
8808 assert_eq!(manifest.artifacts[0].version, "local");
8809 }
8810
8811 #[test]
8812 fn local_source_true_allows_source_on_non_skill_runtime() {
8813 for runtime in ["tool", "driver", "hook"] {
8814 let yaml = format!(
8815 r#"
8816name: cap
8817version: 0.0.1
8818artifacts:
8819 - name: my-thing
8820 source: ./local/path
8821 local_source: true
8822 runtime: {runtime}
8823"#
8824 );
8825 let manifest = RuntimeManifest::from_yaml_str(&yaml)
8826 .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
8827 let artifact = &manifest.artifacts[0];
8828 assert!(artifact.local_source, "runtime {runtime}");
8829 assert_eq!(artifact.source.as_deref(), Some("./local/path"));
8830 assert_eq!(artifact.version, "local", "runtime {runtime}");
8831 }
8832 }
8833
8834 #[test]
8835 fn local_source_false_on_skill_with_source_is_error() {
8836 let err = RuntimeManifest::from_yaml_str(
8839 r#"
8840name: cap
8841version: 0.0.1
8842artifacts:
8843 - name: my-skill
8844 source: ./skills/my-skill
8845 local_source: false
8846 runtime: skill
8847"#,
8848 )
8849 .unwrap_err();
8850 let msg = err.to_string();
8851 assert!(msg.contains("'local_source: true'"), "error was: {msg}");
8852 assert!(msg.contains("my-skill"), "error was: {msg}");
8853 }
8854
8855 #[test]
8856 fn local_source_defaults_from_role_when_absent() {
8857 let manifest = RuntimeManifest::from_yaml_str(
8858 r#"
8859name: cap
8860version: 0.0.1
8861artifacts:
8862 - name: my-skill
8863 source: ./skills/my-skill
8864 runtime: skill
8865 - name: my-tool
8866 version: 1.0.0
8867 runtime: tool
8868"#,
8869 )
8870 .unwrap();
8871 assert!(manifest.artifacts[0].local_source, "skill defaults to true");
8872 assert!(
8873 !manifest.artifacts[1].local_source,
8874 "tool defaults to false"
8875 );
8876 }
8877
8878 #[test]
8879 fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
8880 let manifest = RuntimeManifest::from_yaml_str(
8881 r#"
8882name: cap
8883version: 0.0.1
8884artifacts:
8885 - name: my-tool
8886 version: 1.0.0
8887 runtime: tool
8888 prompt_payload: true
8889 - name: murmur-driver-anthropic
8890 version: 0.1.0
8891 runtime: driver
8892 gateway:
8893 endpoint: http://127.0.0.1:8080
8894 keyless: true
8895inference:
8896 model: test-model
8897 system_prompt_artifact: my-tool
8898 driver:
8899 artifact: murmur-driver-anthropic
8900"#,
8901 )
8902 .unwrap();
8903 assert!(manifest.artifacts[0].prompt_payload);
8904 }
8905
8906 #[test]
8907 fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
8908 let err = RuntimeManifest::from_yaml_str(
8909 r#"
8910name: cap
8911version: 0.0.1
8912artifacts:
8913 - name: my-skill
8914 version: 1.0.0
8915 runtime: skill
8916 prompt_payload: false
8917 - name: murmur-driver-anthropic
8918 version: 0.1.0
8919 runtime: driver
8920 gateway:
8921 endpoint: http://127.0.0.1:8080
8922 keyless: true
8923inference:
8924 model: test-model
8925 system_prompt_artifact: my-skill
8926 driver:
8927 artifact: murmur-driver-anthropic
8928"#,
8929 )
8930 .unwrap_err();
8931 let msg = err.to_string();
8932 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
8933 }
8934
8935 #[test]
8936 fn prompt_payload_defaults_from_role_when_absent() {
8937 let manifest = RuntimeManifest::from_yaml_str(
8938 r#"
8939name: cap
8940version: 0.0.1
8941artifacts:
8942 - name: my-skill
8943 version: 1.0.0
8944 runtime: skill
8945 - name: my-tool
8946 version: 1.0.0
8947 runtime: tool
8948"#,
8949 )
8950 .unwrap();
8951 assert!(
8952 manifest.artifacts[0].prompt_payload,
8953 "skill defaults to true"
8954 );
8955 assert!(
8956 !manifest.artifacts[1].prompt_payload,
8957 "tool defaults to false"
8958 );
8959 }
8960
8961 #[test]
8962 fn source_on_non_skill_runtime_is_error() {
8963 for runtime in ["tool", "driver", "hook"] {
8964 let yaml = format!(
8965 r#"
8966name: cap
8967version: 0.0.1
8968artifacts:
8969 - name: my-thing
8970 source: ./local/path
8971 runtime: {runtime}
8972"#
8973 );
8974 let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
8975 let msg = err.to_string();
8976 assert!(
8977 msg.contains("'local_source: true'"),
8978 "runtime {runtime}: error was: {msg}"
8979 );
8980 assert!(
8981 msg.contains("my-thing"),
8982 "runtime {runtime}: error was: {msg}"
8983 );
8984 }
8985 }
8986
8987 #[test]
8988 fn registry_skill_still_requires_version() {
8989 let err = RuntimeManifest::from_yaml_str(
8990 r#"
8991name: cap
8992version: 0.0.1
8993artifacts:
8994 - name: my-skill
8995 runtime: skill
8996"#,
8997 )
8998 .unwrap_err();
8999
9000 let msg = err.to_string();
9001 assert!(
9002 msg.contains("missing required field 'version'"),
9003 "error was: {msg}"
9004 );
9005 }
9006
9007 #[test]
9008 fn rejects_unknown_inference_transport() {
9009 let err = RuntimeManifest::from_yaml_str(
9010 r#"
9011name: cap
9012version: 0.0.1
9013artifacts: []
9014inference:
9015 transport: grpc
9016 model: test-model
9017 driver:
9018 artifact: murmur-driver-anthropic
9019"#,
9020 )
9021 .unwrap_err();
9022
9023 let msg = err.to_string();
9024 assert!(msg.contains("inference.transport"), "error was: {msg}");
9025 assert!(msg.contains("unknown value"), "error was: {msg}");
9026 assert!(msg.contains("grpc"), "error was: {msg}");
9027 }
9028
9029 #[test]
9032 fn process_transport_with_command_and_no_driver_is_refused() {
9033 let err = RuntimeManifest::from_yaml_str(
9034 r#"
9035name: cap
9036version: 0.0.1
9037artifacts: []
9038inference:
9039 transport: process
9040 command: some-cli
9041 model: some-model
9042 max_turns: 20
9043"#,
9044 )
9045 .unwrap_err();
9046
9047 let msg = err.to_string();
9048 assert!(
9049 msg.contains("inference.driver.artifact"),
9050 "error was: {msg}"
9051 );
9052 assert!(msg.contains("inference.command"), "error was: {msg}");
9053 assert!(
9054 msg.contains("murmur-driver-claude-code"),
9055 "error was: {msg}"
9056 );
9057 }
9058
9059 #[test]
9060 fn process_transport_with_neither_driver_nor_command_is_refused() {
9061 let err = RuntimeManifest::from_yaml_str(
9062 r#"
9063name: cap
9064version: 0.0.1
9065artifacts: []
9066inference:
9067 transport: process
9068 model: some-model
9069"#,
9070 )
9071 .unwrap_err();
9072
9073 let msg = err.to_string();
9074 assert!(
9075 msg.contains("inference.driver.artifact"),
9076 "error was: {msg}"
9077 );
9078 assert!(
9079 msg.contains("murmur-driver-claude-code"),
9080 "error was: {msg}"
9081 );
9082 }
9083
9084 #[test]
9085 fn process_transport_allows_absent_model() {
9086 let manifest =
9089 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
9090 .expect("process transport should parse without a model");
9091 let inference = manifest.inference.expect("inference present");
9092 assert_eq!(inference.transport, "process");
9093 assert!(inference.command.is_none());
9094 assert_eq!(
9095 inference.model, "",
9096 "absent model resolves to empty (harness default)"
9097 );
9098 }
9099
9100 fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
9101 format!(
9102 "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n transport: process\n \
9103 driver:\n artifact: fixture-process-driver\n{inference_extra}"
9104 )
9105 }
9106
9107 const PROCESS_DRIVER_ENTRY: &str =
9108 " - name: fixture-process-driver\n version: 0.1.0\n runtime: driver";
9109
9110 #[test]
9111 fn process_transport_accepts_driver() {
9112 let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9113 PROCESS_DRIVER_ENTRY,
9114 " config:\n profile: work\n command: /usr/local/bin/fixture-cli\n",
9115 ))
9116 .unwrap();
9117
9118 let inference = manifest.inference.expect("inference should exist");
9119 assert_eq!(inference.transport, "process");
9120 let driver = inference.driver.expect("driver should be parsed");
9121 assert_eq!(driver.artifact, "fixture-process-driver");
9122 assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
9123 assert_eq!(
9124 inference.command.as_deref(),
9125 Some("/usr/local/bin/fixture-cli")
9126 );
9127 }
9128
9129 #[test]
9130 fn process_transport_accepts_driver_without_command() {
9131 let manifest =
9132 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
9133 .unwrap();
9134
9135 let inference = manifest.inference.expect("inference should exist");
9136 assert_eq!(
9137 inference.driver.map(|d| d.artifact).as_deref(),
9138 Some("fixture-process-driver")
9139 );
9140 assert!(inference.command.is_none());
9141 assert_eq!(inference.model, "");
9142 }
9143
9144 #[test]
9145 fn process_driver_entry_must_be_declared() {
9146 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9147 " - name: other\n version: 0.1.0\n runtime: tool",
9148 "",
9149 ))
9150 .unwrap_err()
9151 .to_string();
9152 assert!(msg.contains("inference.driver.artifact"), "{msg}");
9153 assert!(
9154 msg.contains("artifact 'fixture-process-driver' is not declared in artifacts:"),
9155 "{msg}"
9156 );
9157 }
9158
9159 #[test]
9160 fn process_driver_entry_must_be_runtime_driver() {
9161 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
9162 " - name: fixture-process-driver\n version: 0.1.0\n runtime: tool",
9163 "",
9164 ))
9165 .unwrap_err()
9166 .to_string();
9167 assert!(
9168 msg.contains("must be declared with runtime: driver"),
9169 "{msg}"
9170 );
9171 }
9172
9173 #[test]
9174 fn process_driver_entry_refuses_capabilities() {
9175 let entry =
9176 format!("{PROCESS_DRIVER_ENTRY}\n capabilities:\n env:\n allow: [HOME]");
9177 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
9178 .unwrap_err()
9179 .to_string();
9180 assert!(msg.contains("granted nothing"), "{msg}");
9181 assert!(msg.contains("inference.driver.config"), "{msg}");
9182 }
9183
9184 #[test]
9185 fn process_driver_entry_refuses_config() {
9186 let entry = format!("{PROCESS_DRIVER_ENTRY}\n config:\n profile: work");
9187 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
9188 .unwrap_err()
9189 .to_string();
9190 assert!(msg.contains("granted nothing"), "{msg}");
9191 assert!(msg.contains("remove capabilities:/config:"), "{msg}");
9192 }
9193
9194 #[test]
9195 fn process_transport_rejects_endpoint() {
9196 let err = RuntimeManifest::from_yaml_str(
9197 r#"
9198name: cap
9199version: 0.0.1
9200artifacts: []
9201inference:
9202 transport: process
9203 command: claude
9204 model: claude-haiku-4-5-20251001
9205 endpoint: http://localhost:8080
9206"#,
9207 )
9208 .unwrap_err();
9209
9210 let msg = err.to_string();
9211 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
9212 assert!(msg.contains("was removed"), "error was: {msg}");
9213 }
9214
9215 #[test]
9216 fn process_transport_rejects_api_key() {
9217 let key = ["sk-", "ant-", "secret"].concat();
9220 let err = RuntimeManifest::from_yaml_str(&format!(
9221 r#"
9222name: cap
9223version: 0.0.1
9224artifacts: []
9225inference:
9226 transport: process
9227 command: claude
9228 model: claude-haiku-4-5-20251001
9229 api_key: {key}
9230"#
9231 ))
9232 .unwrap_err();
9233
9234 let msg = err.to_string();
9235 assert!(msg.contains("inference.api_key"), "error was: {msg}");
9236 assert!(msg.contains("was removed"), "error was: {msg}");
9237 assert!(!msg.contains(&key), "error was: {msg}");
9238 }
9239
9240 #[test]
9241 fn process_transport_rejects_max_tokens() {
9242 let err = RuntimeManifest::from_yaml_str(
9245 r#"
9246name: cap
9247version: 0.0.1
9248artifacts: []
9249inference:
9250 transport: process
9251 command: claude
9252 model: claude-haiku-4-5-20251001
9253 max_tokens: 4096
9254"#,
9255 )
9256 .unwrap_err();
9257
9258 let msg = err.to_string();
9259 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
9260 assert!(
9261 msg.contains("not valid with transport: process"),
9262 "error was: {msg}"
9263 );
9264 }
9265
9266 #[test]
9270 fn process_transport_carries_max_session_tokens() {
9271 let manifest = RuntimeManifest::from_yaml_str(
9272 r#"
9273name: cap
9274version: 0.0.1
9275artifacts:
9276 - name: murmur-driver-claude-code
9277 version: 0.1.0
9278 runtime: driver
9279inference:
9280 transport: process
9281 driver:
9282 artifact: murmur-driver-claude-code
9283 model: claude-haiku-4-5-20251001
9284 max_session_tokens: 200000
9285"#,
9286 )
9287 .unwrap();
9288
9289 assert_eq!(
9290 manifest.inference.unwrap().max_session_tokens,
9291 Some(200_000)
9292 );
9293 }
9294
9295 #[test]
9296 fn process_transport_rejects_a_zero_max_session_tokens() {
9297 let err = RuntimeManifest::from_yaml_str(
9298 r#"
9299name: cap
9300version: 0.0.1
9301artifacts:
9302 - name: murmur-driver-claude-code
9303 version: 0.1.0
9304 runtime: driver
9305inference:
9306 transport: process
9307 driver:
9308 artifact: murmur-driver-claude-code
9309 model: claude-haiku-4-5-20251001
9310 max_session_tokens: 0
9311"#,
9312 )
9313 .unwrap_err();
9314
9315 let msg = err.to_string();
9316 assert!(
9317 msg.contains("inference.max_session_tokens"),
9318 "error was: {msg}"
9319 );
9320 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
9321 }
9322
9323 #[test]
9324 fn inference_max_session_tokens_round_trips() {
9325 let manifest = RuntimeManifest::from_yaml_str(
9326 r#"
9327name: cap
9328version: 0.0.1
9329artifacts:
9330 - name: murmur-driver-anthropic
9331 version: 0.1.0
9332 runtime: driver
9333 gateway:
9334 endpoint: https://api.anthropic.com
9335 api_key: test-key
9336inference:
9337 transport: http
9338 model: claude-opus-4-5
9339 max_session_tokens: 5000000000
9340 driver:
9341 artifact: murmur-driver-anthropic
9342"#,
9343 )
9344 .unwrap();
9345
9346 assert_eq!(
9347 manifest.inference.unwrap().max_session_tokens,
9348 Some(5_000_000_000)
9349 );
9350 }
9351
9352 #[test]
9353 fn inference_max_session_tokens_absent_is_none() {
9354 let manifest = RuntimeManifest::from_yaml_str(
9355 r#"
9356name: cap
9357version: 0.0.1
9358artifacts:
9359 - name: murmur-driver-anthropic
9360 version: 0.1.0
9361 runtime: driver
9362 gateway:
9363 endpoint: https://api.anthropic.com
9364 api_key: test-key
9365inference:
9366 transport: http
9367 model: claude-opus-4-5
9368 driver:
9369 artifact: murmur-driver-anthropic
9370"#,
9371 )
9372 .unwrap();
9373
9374 assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
9375 }
9376
9377 #[test]
9378 fn inference_max_session_tokens_zero_is_rejected() {
9379 let err = RuntimeManifest::from_yaml_str(
9380 r#"
9381name: cap
9382version: 0.0.1
9383artifacts:
9384 - name: murmur-driver-anthropic
9385 version: 0.1.0
9386 runtime: driver
9387 gateway:
9388 endpoint: https://api.anthropic.com
9389 api_key: test-key
9390inference:
9391 transport: http
9392 model: claude-opus-4-5
9393 max_session_tokens: 0
9394 driver:
9395 artifact: murmur-driver-anthropic
9396"#,
9397 )
9398 .unwrap_err();
9399
9400 let msg = err.to_string();
9401 assert!(
9402 msg.contains("inference.max_session_tokens"),
9403 "error was: {msg}"
9404 );
9405 assert!(msg.contains("greater than 0"), "error was: {msg}");
9406 }
9407
9408 fn http_manifest_with_inference(extra: &str) -> String {
9410 format!(
9411 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur-driver-anthropic\n \
9412 version: 0.1.0\n runtime: driver\n gateway:\n \
9413 endpoint: https://api.anthropic.com\n api_key: test-key\n\
9414 inference:\n transport: http\n model: claude-opus-4-5\n driver:\n \
9415 artifact: murmur-driver-anthropic\n{extra}"
9416 )
9417 }
9418
9419 #[test]
9420 fn inference_tool_refresh_absent_is_compaction() {
9421 let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
9422 assert_eq!(
9423 manifest.inference.unwrap().tool_refresh,
9424 ToolRefresh::Compaction
9425 );
9426 assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
9427 }
9428
9429 #[test]
9430 fn inference_tool_refresh_parses_both_values() {
9431 for (value, expected) in [
9432 ("compaction", ToolRefresh::Compaction),
9433 ("immediate", ToolRefresh::Immediate),
9434 ] {
9435 let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
9436 " tool_refresh: {value}\n"
9437 )))
9438 .unwrap();
9439 assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
9440 assert_eq!(expected.wire_name(), value);
9441 assert!(
9442 manifest.unknown_keys.is_empty(),
9443 "tool_refresh is a known key: {:?}",
9444 manifest.unknown_keys
9445 );
9446 }
9447 }
9448
9449 #[test]
9450 fn inference_tool_refresh_rejects_an_unknown_value() {
9451 let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
9452 " tool_refresh: sometimes\n",
9453 ))
9454 .unwrap_err();
9455 match err {
9456 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9457 assert_eq!(field, "inference.tool_refresh");
9458 assert_eq!(
9459 message,
9460 "must be one of: compaction, immediate; got 'sometimes'"
9461 );
9462 }
9463 other => panic!("unexpected error: {other:?}"),
9464 }
9465 }
9466
9467 #[test]
9470 fn process_transport_rejects_tool_refresh() {
9471 for value in ["compaction", "immediate"] {
9472 let err = RuntimeManifest::from_yaml_str(&format!(
9473 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n \
9474 command: claude\n model: claude-haiku-4-5-20251001\n tool_refresh: {value}\n"
9475 ))
9476 .unwrap_err();
9477 match err {
9478 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
9479 assert_eq!(field, "inference.tool_refresh");
9480 assert_eq!(message, "is not valid with transport: process");
9481 }
9482 other => panic!("unexpected error for {value}: {other:?}"),
9483 }
9484 }
9485 }
9486
9487 #[test]
9488 fn inference_max_tokens_round_trips() {
9489 let manifest = RuntimeManifest::from_yaml_str(
9490 r#"
9491name: cap
9492version: 0.0.1
9493artifacts:
9494 - name: murmur-driver-anthropic
9495 version: 0.1.0
9496 runtime: driver
9497 gateway:
9498 endpoint: https://api.anthropic.com
9499 api_key: test-key
9500inference:
9501 transport: http
9502 model: claude-opus-4-5
9503 max_tokens: 4096
9504 driver:
9505 artifact: murmur-driver-anthropic
9506"#,
9507 )
9508 .unwrap();
9509
9510 let inference = manifest.inference.unwrap();
9511 assert_eq!(inference.max_tokens, Some(4096));
9512 }
9513
9514 #[test]
9515 fn inference_max_tokens_absent_is_none() {
9516 let manifest = RuntimeManifest::from_yaml_str(
9519 r#"
9520name: cap
9521version: 0.0.1
9522artifacts:
9523 - name: murmur-driver-anthropic
9524 version: 0.1.0
9525 runtime: driver
9526 gateway:
9527 endpoint: https://api.anthropic.com
9528 api_key: test-key
9529inference:
9530 transport: http
9531 model: claude-opus-4-5
9532 driver:
9533 artifact: murmur-driver-anthropic
9534"#,
9535 )
9536 .unwrap();
9537
9538 assert_eq!(manifest.inference.unwrap().max_tokens, None);
9539 }
9540
9541 #[test]
9542 fn inference_max_tokens_zero_is_rejected() {
9543 let err = RuntimeManifest::from_yaml_str(
9544 r#"
9545name: cap
9546version: 0.0.1
9547artifacts:
9548 - name: murmur-driver-anthropic
9549 version: 0.1.0
9550 runtime: driver
9551 gateway:
9552 endpoint: https://api.anthropic.com
9553 api_key: test-key
9554inference:
9555 transport: http
9556 model: claude-opus-4-5
9557 max_tokens: 0
9558 driver:
9559 artifact: murmur-driver-anthropic
9560"#,
9561 )
9562 .unwrap_err();
9563
9564 let msg = err.to_string();
9565 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
9566 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
9567 }
9568
9569 #[test]
9570 fn inference_max_tokens_large_value_is_accepted() {
9571 let manifest = RuntimeManifest::from_yaml_str(
9574 r#"
9575name: cap
9576version: 0.0.1
9577artifacts:
9578 - name: murmur-driver-anthropic
9579 version: 0.1.0
9580 runtime: driver
9581 gateway:
9582 endpoint: https://api.anthropic.com
9583 api_key: test-key
9584inference:
9585 transport: http
9586 model: claude-opus-4-5
9587 max_tokens: 999999
9588 driver:
9589 artifact: murmur-driver-anthropic
9590"#,
9591 )
9592 .unwrap();
9593
9594 assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
9595 }
9596
9597 #[test]
9598 fn inference_and_context_max_tokens_are_independent() {
9599 let manifest = RuntimeManifest::from_yaml_str(
9602 r#"
9603name: cap
9604version: 0.0.1
9605artifacts:
9606 - name: murmur-driver-anthropic
9607 version: 0.1.0
9608 runtime: driver
9609 gateway:
9610 endpoint: https://api.anthropic.com
9611 api_key: test-key
9612inference:
9613 transport: http
9614 model: claude-opus-4-5
9615 max_tokens: 4096
9616 driver:
9617 artifact: murmur-driver-anthropic
9618context:
9619 max_tokens: 200000
9620"#,
9621 )
9622 .unwrap();
9623
9624 assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
9625 assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
9626 }
9627
9628 #[test]
9629 fn hook_config_defaults_when_fields_absent() {
9630 let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
9631 assert_eq!(config.binding, HookBinding::All);
9632 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
9633 assert_eq!(config.commit_policy, HookCommitPolicy::None);
9634 }
9635
9636 #[test]
9637 fn hook_config_parses_all_fields() {
9638 let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
9639 let config = parse_hook_config_from_yaml(yaml).unwrap();
9640 assert_eq!(config.binding, HookBinding::OnCompaction);
9641 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
9642 assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
9643 }
9644
9645 #[test]
9646 fn hook_config_parses_on_task_start() {
9647 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
9648 let config = parse_hook_config_from_yaml(yaml).unwrap();
9649 assert_eq!(config.binding, HookBinding::OnTaskStart);
9650 }
9651
9652 #[test]
9653 fn hook_config_parses_on_task_end() {
9654 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
9655 let config = parse_hook_config_from_yaml(yaml).unwrap();
9656 assert_eq!(config.binding, HookBinding::OnTaskEnd);
9657 }
9658
9659 #[test]
9660 fn hook_config_unknown_binding_lists_task_events() {
9661 let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
9662 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9663 assert!(err.contains("on-task-start"), "error was: {err}");
9664 assert!(err.contains("on-task-end"), "error was: {err}");
9665 }
9666
9667 #[test]
9668 fn hook_config_async_with_commit_is_rejected() {
9669 let yaml =
9670 "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
9671 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9672 assert!(err.contains("async-with-commit"), "error was: {err}");
9673 }
9674
9675 #[test]
9676 fn hook_config_on_stage_async_is_rejected() {
9677 let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
9678 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9679 assert!(err.contains("on-stage"), "error was: {err}");
9680 assert!(err.contains("blocking"), "error was: {err}");
9681 }
9682
9683 #[test]
9684 fn hook_config_async_none_is_valid() {
9685 let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
9686 let config = parse_hook_config_from_yaml(yaml).unwrap();
9687 assert_eq!(config.execution_mode, HookExecutionMode::Async);
9688 assert_eq!(config.commit_policy, HookCommitPolicy::None);
9689 }
9690
9691 #[test]
9692 fn hook_config_parses_reopen_task_commit_policy() {
9693 let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
9694 let config = parse_hook_config_from_yaml(yaml).unwrap();
9695 assert_eq!(config.binding, HookBinding::OnTaskEnd);
9696 assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
9697 }
9698
9699 #[test]
9700 fn hook_config_unknown_commit_policy_lists_reopen_task() {
9701 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
9702 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9703 assert!(err.contains("reopen-task"), "error was: {err}");
9704 }
9705
9706 #[test]
9707 fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
9708 for binding in ["on-shell", "on-tool-call"] {
9709 let yaml =
9710 format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
9711 let config = parse_hook_config_from_yaml(&yaml).unwrap();
9712 assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
9713 }
9714 assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
9715 }
9716
9717 #[test]
9720 fn hook_config_deny_without_a_binding_is_rejected() {
9721 let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
9722 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9723 assert!(
9724 err.contains("requires an explicit binding"),
9725 "error was: {err}"
9726 );
9727 assert!(err.contains("on-shell"), "error was: {err}");
9728 assert!(err.contains("on-tool-call"), "error was: {err}");
9729 }
9730
9731 #[test]
9733 fn hook_config_deny_on_an_ungated_binding_is_rejected() {
9734 let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
9735 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9736 assert!(err.contains("is not valid for binding"), "error was: {err}");
9737 assert!(err.contains("replace-context"), "error was: {err}");
9738 }
9739
9740 #[test]
9742 fn hook_config_async_deny_is_rejected() {
9743 let yaml =
9744 "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
9745 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9746 assert!(err.contains("async-with-commit"), "error was: {err}");
9747 assert!(err.contains("deny"), "error was: {err}");
9748 }
9749
9750 #[test]
9751 fn hook_config_unknown_commit_policy_lists_deny() {
9752 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
9753 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9754 assert!(err.contains("deny"), "error was: {err}");
9755 }
9756
9757 #[test]
9760 fn hook_config_async_reopen_task_is_rejected() {
9761 let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
9762 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
9763 assert!(err.contains("async-with-commit"), "error was: {err}");
9764 assert!(err.contains("reopen-task"), "error was: {err}");
9765 }
9766
9767 #[test]
9773 fn hook_config_non_committing_binding_rejects_any_commit_policy() {
9774 for binding in ["on-session-start", "on-session-end"] {
9775 let yaml = format!(
9776 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
9777 );
9778 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
9779 assert!(err.contains(binding), "error was: {err}");
9780 assert!(err.contains("replace-context"), "error was: {err}");
9781 assert!(
9782 err.contains("honors commit_policy 'none'"),
9783 "error was: {err}"
9784 );
9785 }
9786 }
9787
9788 #[test]
9791 fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
9792 for (binding, declared, honored) in [
9793 ("on-stage", "reopen-task", "write-manifests"),
9794 ("on-compaction", "write-manifests", "replace-context"),
9795 ("on-task-end", "replace-context", "reopen-task"),
9796 ("on-task-start", "replace-context", "seed-context"),
9797 ("on-shell", "replace-context", "deny"),
9798 ("on-tool-call", "replace-context", "deny"),
9799 ] {
9800 let yaml = format!(
9801 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
9802 );
9803 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
9804 assert!(err.contains(binding), "error was: {err}");
9805 assert!(
9806 err.contains(&format!("commit_policy '{declared}' is not valid")),
9807 "error was: {err}"
9808 );
9809 assert!(
9810 err.contains(&format!("honors commit_policy '{honored}'")),
9811 "error was: {err}"
9812 );
9813 }
9814 }
9815
9816 #[test]
9819 fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
9820 for declared in ["replace-context", "write-manifests", "reopen-task"] {
9821 let yaml = format!(
9822 "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
9823 );
9824 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
9825 assert!(err.contains("on-inference"), "error was: {err}");
9826 assert!(err.contains(declared), "error was: {err}");
9827 assert!(
9828 err.contains("honors commit_policy 'none'"),
9829 "error was: {err}"
9830 );
9831 assert!(err.contains("artifact"), "error was: {err}");
9832 }
9833 }
9834
9835 #[test]
9838 fn hook_config_matching_binding_and_commit_policy_is_valid() {
9839 for (binding, policy, expected) in [
9840 (
9841 "on-stage",
9842 "write-manifests",
9843 HookCommitPolicy::WriteManifests,
9844 ),
9845 (
9846 "on-compaction",
9847 "replace-context",
9848 HookCommitPolicy::ReplaceContext,
9849 ),
9850 ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
9851 ("on-shell", "deny", HookCommitPolicy::Deny),
9852 ("on-tool-call", "deny", HookCommitPolicy::Deny),
9853 ] {
9854 let yaml =
9855 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
9856 let config = parse_hook_config_from_yaml(&yaml).unwrap();
9857 assert_eq!(config.commit_policy, expected);
9858 }
9859 }
9860
9861 #[test]
9864 fn hook_config_commit_policy_none_is_valid_for_every_binding() {
9865 for binding in [
9866 "on-stage",
9867 "on-session-start",
9868 "on-task-start",
9869 "on-inference",
9870 "on-tool-call",
9871 "on-shell",
9872 "on-compaction",
9873 "on-task-end",
9874 "on-session-end",
9875 ] {
9876 let yaml =
9877 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
9878 let config = parse_hook_config_from_yaml(&yaml).unwrap();
9879 assert_eq!(config.commit_policy, HookCommitPolicy::None);
9880 }
9881 }
9882
9883 #[test]
9888 fn hook_config_omitted_binding_accepts_every_commit_policy() {
9889 for (policy, expected) in [
9890 ("replace-context", HookCommitPolicy::ReplaceContext),
9891 ("write-manifests", HookCommitPolicy::WriteManifests),
9892 ("reopen-task", HookCommitPolicy::ReopenTask),
9893 ("none", HookCommitPolicy::None),
9894 ] {
9895 let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
9896 let config = parse_hook_config_from_yaml(&yaml).unwrap();
9897 assert_eq!(config.binding, HookBinding::All);
9898 assert_eq!(config.commit_policy, expected);
9899 }
9900 }
9901
9902 #[test]
9907 fn hook_config_shipped_default_artifact_manifests_still_parse() {
9908 let shipped: [(&str, &str, HookConfig); 8] = [
9909 (
9910 "murmur-hook-compact",
9911 "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
9912 HookConfig {
9913 binding: HookBinding::OnCompaction,
9914 execution_mode: HookExecutionMode::Blocking,
9915 commit_policy: HookCommitPolicy::ReplaceContext,
9916 },
9917 ),
9918 (
9919 "murmur-hook-shell-desc",
9920 "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
9921 HookConfig {
9922 binding: HookBinding::OnStage,
9923 execution_mode: HookExecutionMode::Blocking,
9924 commit_policy: HookCommitPolicy::WriteManifests,
9925 },
9926 ),
9927 (
9928 "murmur-hook-regression-verifier",
9930 "execution_mode: blocking\ncommit_policy: reopen-task\n",
9931 HookConfig {
9932 binding: HookBinding::All,
9933 execution_mode: HookExecutionMode::Blocking,
9934 commit_policy: HookCommitPolicy::ReopenTask,
9935 },
9936 ),
9937 (
9938 "murmur-hook-memory",
9939 "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
9940 HookConfig {
9941 binding: HookBinding::OnTaskStart,
9942 execution_mode: HookExecutionMode::Blocking,
9943 commit_policy: HookCommitPolicy::SeedContext,
9944 },
9945 ),
9946 (
9947 "murmur-hook-debug",
9948 "execution_mode: async\ncommit_policy: none\n",
9949 HookConfig {
9950 binding: HookBinding::All,
9951 execution_mode: HookExecutionMode::Async,
9952 commit_policy: HookCommitPolicy::None,
9953 },
9954 ),
9955 (
9956 "murmur-hook-diff-summary",
9957 "execution_mode: blocking\ncommit_policy: none\n",
9958 HookConfig {
9959 binding: HookBinding::All,
9960 execution_mode: HookExecutionMode::Blocking,
9961 commit_policy: HookCommitPolicy::None,
9962 },
9963 ),
9964 ("murmur-hook-eval", "", HookConfig::default()),
9965 ("murmur-hook-grafana", "", HookConfig::default()),
9966 ];
9967
9968 for (name, fields, expected) in shipped {
9969 let yaml = format!("name: {name}\nruntime: hook\n{fields}");
9970 let config = parse_hook_config_from_yaml(&yaml)
9971 .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
9972 assert_eq!(config, expected, "shipped hook {name}");
9973 }
9974 }
9975
9976 #[test]
9980 fn commit_policy_for_binding_matches_the_declarable_pairs() {
9981 assert_eq!(
9982 commit_policy_for_binding(&HookBinding::OnStage),
9983 Some(HookCommitPolicy::WriteManifests)
9984 );
9985 assert_eq!(
9986 commit_policy_for_binding(&HookBinding::OnCompaction),
9987 Some(HookCommitPolicy::ReplaceContext)
9988 );
9989 assert_eq!(
9990 commit_policy_for_binding(&HookBinding::OnTaskEnd),
9991 Some(HookCommitPolicy::ReopenTask)
9992 );
9993 assert_eq!(
9994 commit_policy_for_binding(&HookBinding::OnTaskStart),
9995 Some(HookCommitPolicy::SeedContext)
9996 );
9997 assert_eq!(
9998 commit_policy_for_binding(&HookBinding::OnToolCall),
9999 Some(HookCommitPolicy::Deny)
10000 );
10001 assert_eq!(
10002 commit_policy_for_binding(&HookBinding::OnShell),
10003 Some(HookCommitPolicy::Deny)
10004 );
10005 for binding in [
10009 HookBinding::OnSessionStart,
10010 HookBinding::OnInference,
10011 HookBinding::OnSessionEnd,
10012 HookBinding::All,
10013 ] {
10014 assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
10015 }
10016 }
10017
10018 #[test]
10021 fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
10022 let config = parse_hook_config_from_yaml(
10023 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
10024 commit_policy: seed-context\n",
10025 )
10026 .expect("on-task-start + seed-context is the declarable pairing");
10027 assert_eq!(config.binding, HookBinding::OnTaskStart);
10028 assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
10029
10030 let error = parse_hook_config_from_yaml(
10031 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
10032 commit_policy: seed-context\n",
10033 )
10034 .expect_err("on-compaction cannot commit a seed");
10035 assert!(error.contains("on-compaction"), "{error}");
10036 assert!(error.contains("seed-context"), "{error}");
10037 }
10038
10039 #[test]
10042 fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
10043 let error = parse_hook_config_from_yaml(
10044 "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
10045 )
10046 .expect_err("a misspelled policy is rejected");
10047 assert!(error.contains("seed-context"), "{error}");
10048 }
10049
10050 fn context_of(yaml_block: &str) -> Option<ContextConfig> {
10053 RuntimeManifest::from_yaml_str(&format!(
10054 "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
10055 ))
10056 .expect("the manifest under test parses")
10057 .context
10058 }
10059
10060 #[test]
10061 fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
10062 let context = context_of(
10063 "context:\n max_tokens: 200000\n seed_budget: 0.10\n seed_overflow_margin: 0.10\n",
10064 )
10065 .expect("a declared context block parses to Some");
10066 assert_eq!(context.max_tokens, Some(200_000));
10067 assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
10068 assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
10069 }
10070
10071 #[test]
10072 fn seed_budget_and_overflow_margin_default_when_omitted() {
10073 let context = context_of("context:\n max_tokens: 200000\n")
10074 .expect("a declared context block parses to Some");
10075 assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
10076 assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
10077 }
10078
10079 #[test]
10082 fn seed_budget_defaults_do_not_create_a_context_block() {
10083 assert_eq!(context_of(""), None);
10084 }
10085
10086 #[test]
10087 fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
10088 for (block, field) in [
10089 ("context:\n seed_budget: 1.5\n", "context.seed_budget"),
10090 ("context:\n seed_budget: -0.1\n", "context.seed_budget"),
10091 (
10092 "context:\n seed_overflow_margin: 2.0\n",
10093 "context.seed_overflow_margin",
10094 ),
10095 ] {
10096 let error = RuntimeManifest::from_yaml_str(&format!(
10097 "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
10098 ))
10099 .expect_err("an out-of-range fraction is rejected");
10100 match error {
10101 RuntimeManifestError::InvalidInferenceConfig {
10102 field: got,
10103 ref message,
10104 } => {
10105 assert_eq!(got, field);
10106 assert!(message.contains("0.0 and 1.0"), "{message}");
10107 }
10108 other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
10109 }
10110 }
10111 }
10112
10113 #[test]
10114 fn parse_tool_implementation_defaults_to_wasm() {
10115 let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
10116 assert_eq!(impl_, ArtifactImplementation::Wasm);
10117 }
10118
10119 #[test]
10120 fn parse_tool_implementation_native() {
10121 let impl_ = parse_tool_implementation_from_yaml(
10122 "name: my-tool\nruntime: tool\nimplementation: native\n",
10123 );
10124 assert_eq!(impl_, ArtifactImplementation::Native);
10125 }
10126
10127 #[test]
10128 fn mur_version_parses_correctly() {
10129 let manifest = RuntimeManifest::from_yaml_str(
10130 "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"0.4.5\"\n",
10131 )
10132 .unwrap();
10133 assert_eq!(manifest.mur_version, Some("0.4.5".to_string()));
10134 }
10135
10136 #[test]
10137 fn mur_version_absent_is_none() {
10138 let manifest =
10139 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
10140 assert_eq!(manifest.mur_version, None);
10141 }
10142
10143 #[test]
10144 fn mur_version_empty_string_is_none() {
10145 let manifest = RuntimeManifest::from_yaml_str(
10146 "name: cap\nversion: 0.1.0\nartifacts: []\nmur_version: \"\"\n",
10147 )
10148 .unwrap();
10149 assert_eq!(manifest.mur_version, None);
10150 }
10151
10152 #[test]
10153 fn inference_max_turns_defaults_to_10() {
10154 let manifest = RuntimeManifest::from_yaml_str(
10155 "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",
10156 ).unwrap();
10157 assert_eq!(manifest.inference.unwrap().max_turns, 10);
10158 }
10159
10160 #[test]
10161 fn inference_max_turns_explicit_value() {
10162 let manifest = RuntimeManifest::from_yaml_str(
10163 "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",
10164 ).unwrap();
10165 assert_eq!(manifest.inference.unwrap().max_turns, 20);
10166 }
10167
10168 #[test]
10169 fn inference_max_turns_zero_is_rejected() {
10170 let err = RuntimeManifest::from_yaml_str(
10171 "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",
10172 ).unwrap_err();
10173 let msg = err.to_string();
10174 assert!(msg.contains("inference.max_turns"), "error was: {msg}");
10175 assert!(msg.contains("greater than 0"), "error was: {msg}");
10176 }
10177
10178 #[test]
10180 fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
10181 let manifest = RuntimeManifest::from_yaml_str(
10182 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 45\n",
10183 )
10184 .unwrap();
10185 let lifecycle = manifest.lifecycle.unwrap();
10186 assert_eq!(lifecycle.shell_grace_secs, 45);
10187 assert_eq!(
10189 lifecycle.task_acceptance,
10190 LifecycleConfig::default().task_acceptance
10191 );
10192 assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
10193 assert_eq!(
10194 lifecycle.queue_depth,
10195 LifecycleConfig::default().queue_depth
10196 );
10197 assert_eq!(lifecycle.input_timeout_secs, None);
10198 assert_eq!(
10199 lifecycle.conversation_mode,
10200 LifecycleConfig::default().conversation_mode
10201 );
10202 assert_eq!(
10203 lifecycle.max_task_reopens,
10204 LifecycleConfig::default().max_task_reopens
10205 );
10206 }
10207
10208 #[test]
10209 fn lifecycle_shell_grace_secs_defaults_to_10() {
10210 let manifest = RuntimeManifest::from_yaml_str(
10211 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
10212 )
10213 .unwrap();
10214 let lifecycle = manifest.lifecycle.unwrap();
10215 assert_eq!(lifecycle.shell_grace_secs, 10);
10216 assert_eq!(lifecycle.queue_depth, 3);
10217
10218 let without_block =
10219 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
10220 assert_eq!(without_block.effective_lifecycle().shell_grace_secs, 10);
10221 }
10222
10223 #[test]
10226 fn lifecycle_delegation_deadline_secs_defaults_to_600() {
10227 let without_block =
10228 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
10229 assert_eq!(
10230 without_block.effective_lifecycle().delegation_deadline_secs,
10231 600
10232 );
10233
10234 let without_key = RuntimeManifest::from_yaml_str(
10235 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
10236 )
10237 .unwrap();
10238 let lifecycle = without_key.lifecycle.unwrap();
10239 assert_eq!(lifecycle.delegation_deadline_secs, 600);
10240 assert_eq!(lifecycle.queue_depth, 3);
10241 }
10242
10243 #[test]
10244 fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
10245 let manifest = RuntimeManifest::from_yaml_str(
10246 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
10247 delegation_deadline_secs: 30\n",
10248 )
10249 .unwrap();
10250 let lifecycle = manifest.lifecycle.unwrap();
10251 assert_eq!(lifecycle.delegation_deadline_secs, 30);
10252 assert_eq!(
10254 lifecycle.shell_grace_secs,
10255 LifecycleConfig::default().shell_grace_secs
10256 );
10257 assert_eq!(
10258 lifecycle.max_task_reopens,
10259 LifecycleConfig::default().max_task_reopens
10260 );
10261 }
10262
10263 #[test]
10265 fn lifecycle_delegation_deadline_secs_is_a_known_key() {
10266 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
10267 let manifest = RuntimeManifest::from_yaml_str(
10268 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
10269 delegation_deadline_secs: 30\n delegation_deadline: 30\n",
10270 )
10271 .unwrap();
10272 let reported: Vec<(String, String)> = manifest
10273 .unknown_keys
10274 .iter()
10275 .map(|key| (key.key.clone(), key.block_path.clone()))
10276 .collect();
10277 assert_eq!(
10278 reported,
10279 vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
10280 "the declared key is known and only the misspelt neighbour is reported"
10281 );
10282 assert_eq!(
10283 manifest.lifecycle.unwrap().delegation_deadline_secs,
10284 30,
10285 "the misspelt neighbour does not disturb the declared value"
10286 );
10287 }
10288
10289 #[test]
10292 fn lifecycle_shell_grace_secs_zero_is_accepted() {
10293 let manifest = RuntimeManifest::from_yaml_str(
10294 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 0\n",
10295 )
10296 .unwrap();
10297 let lifecycle = manifest.lifecycle.unwrap();
10298 assert_eq!(lifecycle.shell_grace_secs, 0);
10299 assert_eq!(
10300 lifecycle.max_task_reopens,
10301 LifecycleConfig::default().max_task_reopens
10302 );
10303 }
10304
10305 #[test]
10306 fn lifecycle_max_task_reopens_defaults_to_1() {
10307 let manifest = RuntimeManifest::from_yaml_str(
10308 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n after_task: sleep\n",
10309 )
10310 .unwrap();
10311 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
10312 }
10313
10314 #[test]
10316 fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
10317 let manifest =
10318 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
10319 assert!(manifest.lifecycle.is_none());
10320 assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
10321 }
10322
10323 #[test]
10324 fn lifecycle_max_task_reopens_explicit_value() {
10325 let manifest = RuntimeManifest::from_yaml_str(
10326 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 3\n",
10327 )
10328 .unwrap();
10329 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
10330 }
10331
10332 #[test]
10334 fn lifecycle_max_task_reopens_zero_is_accepted() {
10335 let manifest = RuntimeManifest::from_yaml_str(
10336 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 0\n",
10337 )
10338 .unwrap();
10339 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
10340 }
10341
10342 #[test]
10344 fn lifecycle_max_task_reopens_with_process_transport() {
10345 let manifest = RuntimeManifest::from_yaml_str(
10346 "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",
10347 ).unwrap();
10348 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
10349 }
10350
10351 #[test]
10352 fn inference_max_task_reopens_is_rejected_under_http_transport() {
10353 let err = RuntimeManifest::from_yaml_str(
10354 "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",
10355 ).unwrap_err();
10356 assert!(
10357 matches!(
10358 &err,
10359 RuntimeManifestError::InvalidInferenceConfig { field, .. }
10360 if field == "inference.max_task_reopens"
10361 ),
10362 "error was: {err:?}"
10363 );
10364 let msg = err.to_string();
10365 assert!(
10366 msg.contains("lifecycle.max_task_reopens"),
10367 "error was: {msg}"
10368 );
10369 }
10370
10371 #[test]
10372 fn inference_max_task_reopens_is_rejected_under_process_transport() {
10373 let err = RuntimeManifest::from_yaml_str(
10374 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n max_task_reopens: 2\n",
10375 ).unwrap_err();
10376 assert!(
10377 matches!(
10378 &err,
10379 RuntimeManifestError::InvalidInferenceConfig { field, .. }
10380 if field == "inference.max_task_reopens"
10381 ),
10382 "error was: {err:?}"
10383 );
10384 let msg = err.to_string();
10385 assert!(
10386 msg.contains("lifecycle.max_task_reopens"),
10387 "error was: {msg}"
10388 );
10389 }
10390
10391 #[test]
10393 fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
10394 let err = RuntimeManifest::from_yaml_str(
10395 "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",
10396 ).unwrap_err();
10397 assert!(
10398 matches!(
10399 &err,
10400 RuntimeManifestError::InvalidInferenceConfig { field, .. }
10401 if field == "inference.max_task_reopens"
10402 ),
10403 "error was: {err:?}"
10404 );
10405 }
10406
10407 #[test]
10408 fn skill_is_llm_visible() {
10409 assert!(ArtifactRuntime::Skill.is_llm_visible());
10410 }
10411
10412 #[test]
10413 fn driver_and_hook_are_not_llm_visible() {
10414 assert!(!ArtifactRuntime::Driver.is_llm_visible());
10415 assert!(!ArtifactRuntime::Hook.is_llm_visible());
10416 }
10417
10418 #[test]
10419 fn parses_system_prompt_artifact() {
10420 let manifest = RuntimeManifest::from_yaml_str(
10421 r#"
10422name: cap
10423version: 0.0.1
10424artifacts:
10425 - name: my-skill
10426 version: 1.0.0
10427 runtime: skill
10428 - name: murmur-driver-anthropic
10429 version: 0.1.0
10430 runtime: driver
10431 gateway:
10432 endpoint: http://127.0.0.1:8080
10433 keyless: true
10434inference:
10435 model: test-model
10436 system_prompt_artifact: my-skill
10437 driver:
10438 artifact: murmur-driver-anthropic
10439"#,
10440 )
10441 .unwrap();
10442
10443 let inference = manifest.inference.unwrap();
10444 assert_eq!(
10445 inference.system_prompt_artifact,
10446 Some("my-skill".to_string())
10447 );
10448 assert!(inference.system_prompt.is_none());
10449 assert!(inference.system_prompt_file.is_none());
10450 }
10451
10452 #[test]
10453 fn rejects_system_prompt_artifact_with_inline_prompt() {
10454 let err = RuntimeManifest::from_yaml_str(
10455 r#"
10456name: cap
10457version: 0.0.1
10458artifacts:
10459 - name: my-skill
10460 version: 1.0.0
10461 runtime: skill
10462 - name: murmur-driver-anthropic
10463 version: 0.1.0
10464 runtime: driver
10465 gateway:
10466 endpoint: http://127.0.0.1:8080
10467 keyless: true
10468inference:
10469 model: test-model
10470 system_prompt: "inline"
10471 system_prompt_artifact: my-skill
10472 driver:
10473 artifact: murmur-driver-anthropic
10474"#,
10475 )
10476 .unwrap_err();
10477
10478 let msg = err.to_string();
10479 assert!(msg.contains("at most one"), "error was: {msg}");
10480 }
10481
10482 #[test]
10483 fn rejects_system_prompt_artifact_naming_non_skill() {
10484 let err = RuntimeManifest::from_yaml_str(
10485 r#"
10486name: cap
10487version: 0.0.1
10488artifacts:
10489 - name: my-tool
10490 version: 1.0.0
10491 runtime: tool
10492 - name: murmur-driver-anthropic
10493 version: 0.1.0
10494 runtime: driver
10495 gateway:
10496 endpoint: http://127.0.0.1:8080
10497 keyless: true
10498inference:
10499 model: test-model
10500 system_prompt_artifact: my-tool
10501 driver:
10502 artifact: murmur-driver-anthropic
10503"#,
10504 )
10505 .unwrap_err();
10506
10507 let msg = err.to_string();
10508 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
10509 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
10510 }
10511
10512 #[test]
10513 fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
10514 let err = RuntimeManifest::from_yaml_str(
10515 r#"
10516name: cap
10517version: 0.0.1
10518artifacts:
10519 - name: murmur-driver-anthropic
10520 version: 0.1.0
10521 runtime: driver
10522 gateway:
10523 endpoint: http://127.0.0.1:8080
10524 keyless: true
10525inference:
10526 model: test-model
10527 system_prompt_artifact: missing-skill
10528 driver:
10529 artifact: murmur-driver-anthropic
10530"#,
10531 )
10532 .unwrap_err();
10533
10534 let msg = err.to_string();
10535 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
10536 assert!(msg.contains("not declared"), "error was: {msg}");
10537 }
10538
10539 #[test]
10540 fn rejects_all_three_system_prompt_sources() {
10541 let err = RuntimeManifest::from_yaml_str(
10542 r#"
10543name: cap
10544version: 0.0.1
10545artifacts:
10546 - name: my-skill
10547 version: 1.0.0
10548 runtime: skill
10549 - name: murmur-driver-anthropic
10550 version: 0.1.0
10551 runtime: driver
10552 gateway:
10553 endpoint: http://127.0.0.1:8080
10554 keyless: true
10555inference:
10556 model: test-model
10557 system_prompt: "inline"
10558 system_prompt_file: "conventions.md"
10559 system_prompt_artifact: my-skill
10560 driver:
10561 artifact: murmur-driver-anthropic
10562"#,
10563 )
10564 .unwrap_err();
10565
10566 let msg = err.to_string();
10567 assert!(msg.contains("at most one"), "error was: {msg}");
10568 }
10569
10570 #[test]
10573 fn hook_artifact_capabilities_parse_network_and_filesystem() {
10574 let manifest = RuntimeManifest::from_yaml_str(
10575 r#"
10576name: cap
10577version: 0.0.1
10578artifacts:
10579 - name: telemetry-hook
10580 version: 1.0.0
10581 runtime: hook
10582 capabilities:
10583 network:
10584 allow:
10585 - https://telemetry.example.com
10586 filesystem:
10587 scope: hook-state
10588"#,
10589 )
10590 .unwrap();
10591
10592 let caps = manifest.artifacts[0]
10593 .capabilities
10594 .as_ref()
10595 .expect("hook entry carries a capability grant");
10596 assert_eq!(
10597 caps.network.as_ref().unwrap().allow,
10598 vec!["https://telemetry.example.com".to_string()]
10599 );
10600 assert_eq!(
10601 caps.filesystem.as_ref().unwrap().scope,
10602 Some("hook-state".to_string())
10603 );
10604 }
10605
10606 #[test]
10609 fn hook_artifact_without_capabilities_is_none() {
10610 let manifest = RuntimeManifest::from_yaml_str(
10611 r#"
10612name: cap
10613version: 0.0.1
10614artifacts:
10615 - name: quiet-hook
10616 version: 1.0.0
10617 runtime: hook
10618"#,
10619 )
10620 .unwrap();
10621
10622 assert!(manifest.artifacts[0].capabilities.is_none());
10623 }
10624
10625 #[test]
10628 fn tool_and_driver_artifact_capabilities_parse() {
10629 for role in ["tool", "driver"] {
10630 let yaml = format!(
10631 r#"
10632name: cap
10633version: 0.0.1
10634artifacts:
10635 - name: scoped
10636 version: 1.0.0
10637 runtime: {role}
10638 capabilities:
10639 network:
10640 allow:
10641 - https://api.example.com
10642 filesystem:
10643 scope: cache
10644"#
10645 );
10646 let manifest = RuntimeManifest::from_yaml_str(&yaml)
10647 .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
10648
10649 let caps = manifest.artifacts[0]
10650 .capabilities
10651 .as_ref()
10652 .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
10653 assert_eq!(
10654 caps.network.as_ref().unwrap().allow,
10655 vec!["https://api.example.com".to_string()]
10656 );
10657 assert_eq!(
10658 caps.filesystem.as_ref().unwrap().scope,
10659 Some("cache".to_string())
10660 );
10661 }
10662 }
10663
10664 #[test]
10667 fn tool_artifact_without_capabilities_is_none() {
10668 let manifest = RuntimeManifest::from_yaml_str(
10669 r#"
10670name: cap
10671version: 0.0.1
10672artifacts:
10673 - name: plain-tool
10674 version: 1.0.0
10675 runtime: tool
10676"#,
10677 )
10678 .unwrap();
10679
10680 assert!(manifest.artifacts[0].capabilities.is_none());
10681 }
10682
10683 #[test]
10686 fn skill_artifact_capabilities_are_rejected() {
10687 let yaml = r#"
10688name: cap
10689version: 0.0.1
10690artifacts:
10691 - name: sneaky
10692 version: 1.0.0
10693 runtime: skill
10694 capabilities:
10695 network:
10696 allow:
10697 - https://evil.example.com
10698"#;
10699 let err = match RuntimeManifest::from_yaml_str(yaml) {
10700 Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
10701 Err(err) => err,
10702 };
10703 let msg = err.to_string();
10704 assert!(msg.contains("sneaky"), "error was: {msg}");
10705 assert!(
10706 msg.contains(
10707 "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
10708 entries"
10709 ),
10710 "error was: {msg}"
10711 );
10712 assert!(msg.contains("runtime: skill"), "error was: {msg}");
10713 }
10714
10715 #[test]
10720 fn artifact_config_is_carried_verbatim() {
10721 let manifest = RuntimeManifest::from_yaml_str(
10722 r#"
10723name: cap
10724version: 0.0.1
10725artifacts:
10726 - name: murmur-tool-corpus
10727 version: 0.1.0
10728 runtime: tool
10729 config:
10730 read_recent: { default: 20, max: 100 }
10731"#,
10732 )
10733 .unwrap();
10734
10735 let config = manifest.artifacts[0]
10736 .config
10737 .as_ref()
10738 .expect("a declared block parses");
10739 assert_eq!(
10740 config["read_recent"]["max"],
10741 serde_yaml::Value::Number(100.into())
10742 );
10743 }
10744
10745 #[test]
10747 fn an_undeclared_config_is_none() {
10748 let manifest = RuntimeManifest::from_yaml_str(
10749 r#"
10750name: cap
10751version: 0.0.1
10752artifacts:
10753 - name: git
10754 version: 1.0.0
10755 runtime: tool
10756"#,
10757 )
10758 .unwrap();
10759
10760 assert!(manifest.artifacts[0].config.is_none());
10761 }
10762
10763 #[test]
10767 fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
10768 let manifest = RuntimeManifest::from_yaml_str(
10769 r#"
10770name: cap
10771version: 0.0.1
10772artifacts:
10773 - name: git
10774 version: 1.0.0
10775 runtime: tool
10776 config:
10777"#,
10778 )
10779 .unwrap();
10780
10781 assert_eq!(
10782 manifest.artifacts[0].config,
10783 Some(serde_yaml::Value::Null),
10784 "an empty block must survive parsing as a declaration, not vanish into None"
10785 );
10786 }
10787
10788 #[test]
10791 fn skill_artifact_config_is_rejected() {
10792 let yaml = r#"
10793name: cap
10794version: 0.0.1
10795artifacts:
10796 - name: sneaky
10797 version: 1.0.0
10798 runtime: skill
10799 config:
10800 who: me
10801"#;
10802 let err = match RuntimeManifest::from_yaml_str(yaml) {
10803 Ok(_) => panic!("config: must be rejected for runtime: skill"),
10804 Err(err) => err,
10805 };
10806 assert!(matches!(
10807 err,
10808 RuntimeManifestError::InvalidArtifact { index: 0, .. }
10809 ));
10810 let msg = err.to_string();
10811 assert!(msg.contains("sneaky"), "error was: {msg}");
10812 assert!(msg.contains("runtime: skill"), "error was: {msg}");
10813 assert!(
10814 msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
10815 "error was: {msg}"
10816 );
10817 }
10818
10819 #[test]
10822 fn a_capsule_wide_config_block_is_rejected() {
10823 let yaml = r#"
10824name: cap
10825version: 0.0.1
10826config:
10827 who: capsule
10828artifacts:
10829 - name: git
10830 version: 1.0.0
10831 runtime: tool
10832"#;
10833 let err = match RuntimeManifest::from_yaml_str(yaml) {
10834 Ok(_) => panic!("a capsule-wide config block must be rejected"),
10835 Err(err) => err,
10836 };
10837 let msg = err.to_string();
10838 assert!(msg.contains("config"), "error was: {msg}");
10839 assert!(msg.contains("declared per artifact"), "error was: {msg}");
10840 }
10841
10842 #[test]
10846 fn hook_on_overflow_block_parses() {
10847 let manifest = RuntimeManifest::from_yaml_str(
10848 r#"
10849name: cap
10850version: 0.0.1
10851artifacts:
10852 - name: murmur-hook-grafana
10853 version: 1.0.0
10854 runtime: hook
10855 on_overflow: block
10856"#,
10857 )
10858 .unwrap();
10859
10860 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
10861 }
10862
10863 #[test]
10866 fn hook_on_overflow_defaults_to_drop() {
10867 let manifest = RuntimeManifest::from_yaml_str(
10868 r#"
10869name: cap
10870version: 0.0.1
10871artifacts:
10872 - name: murmur-hook-grafana
10873 version: 1.0.0
10874 runtime: hook
10875"#,
10876 )
10877 .unwrap();
10878
10879 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
10880 assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
10881 }
10882
10883 #[test]
10886 fn on_overflow_on_a_non_hook_artifact_is_rejected() {
10887 let yaml = r#"
10888name: cap
10889version: 0.0.1
10890artifacts:
10891 - name: git
10892 version: 1.0.0
10893 runtime: tool
10894 on_overflow: block
10895"#;
10896 let err = match RuntimeManifest::from_yaml_str(yaml) {
10897 Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
10898 Err(err) => err,
10899 };
10900 assert!(matches!(
10901 err,
10902 RuntimeManifestError::InvalidArtifact { index: 0, .. }
10903 ));
10904 let msg = err.to_string();
10905 assert!(msg.contains("git"), "error was: {msg}");
10906 assert!(
10907 msg.contains("only recognized on 'runtime: hook' entries"),
10908 "error was: {msg}"
10909 );
10910 }
10911
10912 #[test]
10914 fn unknown_on_overflow_value_is_rejected() {
10915 let yaml = r#"
10916name: cap
10917version: 0.0.1
10918artifacts:
10919 - name: murmur-hook-grafana
10920 version: 1.0.0
10921 runtime: hook
10922 on_overflow: spill
10923"#;
10924 let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
10925 let msg = err.to_string();
10926 assert!(msg.contains("spill"), "error was: {msg}");
10927 assert!(msg.contains("expected: drop, block"), "error was: {msg}");
10928 }
10929
10930 #[test]
10933 fn on_overflow_is_accepted_on_any_hook_entry() {
10934 let manifest = RuntimeManifest::from_yaml_str(
10935 r#"
10936name: cap
10937version: 0.0.1
10938artifacts:
10939 - name: murmur-hook-eval
10940 version: 1.0.0
10941 runtime: hook
10942 on_overflow: drop
10943 capabilities:
10944 network:
10945 allow:
10946 - https://telemetry.example.com
10947"#,
10948 )
10949 .unwrap();
10950
10951 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
10952 assert!(manifest.artifacts[0].capabilities.is_some());
10953 }
10954
10955 #[test]
10958 fn capsule_wide_and_hook_capabilities_coexist() {
10959 let manifest = RuntimeManifest::from_yaml_str(
10960 r#"
10961name: cap
10962version: 0.0.1
10963artifacts:
10964 - name: telemetry-hook
10965 version: 1.0.0
10966 runtime: hook
10967 capabilities:
10968 network:
10969 allow:
10970 - https://telemetry.example.com
10971capabilities:
10972 network:
10973 allow:
10974 - https://api.anthropic.com
10975"#,
10976 )
10977 .unwrap();
10978
10979 assert_eq!(
10980 manifest.capabilities.unwrap().network.unwrap().allow,
10981 vec!["https://api.anthropic.com".to_string()]
10982 );
10983 assert_eq!(
10984 manifest.artifacts[0]
10985 .capabilities
10986 .as_ref()
10987 .unwrap()
10988 .network
10989 .as_ref()
10990 .unwrap()
10991 .allow,
10992 vec!["https://telemetry.example.com".to_string()]
10993 );
10994 }
10995
10996 #[test]
11000 fn hook_own_manifest_capabilities_are_not_read_into_config() {
11001 let config = parse_hook_config_from_yaml(
11002 r#"
11003name: telemetry-hook
11004version: 1.0.0
11005binding: on-inference
11006execution_mode: blocking
11007capabilities:
11008 network:
11009 allow:
11010 - https://evil.example.com
11011 filesystem:
11012 scope: /
11013"#,
11014 )
11015 .expect("a self-declared capabilities block is ignored, not an error");
11016
11017 assert_eq!(
11018 config,
11019 HookConfig {
11020 binding: HookBinding::OnInference,
11021 execution_mode: HookExecutionMode::Blocking,
11022 commit_policy: HookCommitPolicy::None,
11023 }
11024 );
11025 }
11026
11027 #[test]
11030 fn containment_classes_order_weakest_to_strongest() {
11031 assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
11032 assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
11033 assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
11034 assert_eq!(
11035 ContainmentClass::ALL.iter().copied().max(),
11036 Some(ContainmentClass::Sealed)
11037 );
11038 }
11039
11040 #[test]
11041 fn containment_class_defaults_to_advisory() {
11042 assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
11043 }
11044
11045 #[test]
11046 fn containment_class_round_trips_through_string() {
11047 for class in ContainmentClass::ALL {
11048 assert_eq!(
11049 class.to_string().parse::<ContainmentClass>(),
11050 Ok(class),
11051 "{class} must survive Display -> FromStr"
11052 );
11053 }
11054 assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
11055 assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
11056 assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
11057 }
11058
11059 #[test]
11060 fn containment_class_rejects_unknown_names() {
11061 let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
11062 assert_eq!(err.value, "paranoid");
11063 assert_eq!(
11064 err.to_string(),
11065 "must be one of: advisory, scoped, sealed; got 'paranoid'"
11066 );
11067 assert!("Sealed".parse::<ContainmentClass>().is_err());
11069 }
11070
11071 #[test]
11072 fn containment_class_serializes_as_its_wire_name() {
11073 assert_eq!(
11074 serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
11075 "\"scoped\""
11076 );
11077 assert_eq!(
11078 serde_yaml::from_str::<ContainmentClass>("sealed").unwrap(),
11079 ContainmentClass::Sealed
11080 );
11081 }
11082
11083 #[test]
11084 fn effective_floor_is_advisory_when_nothing_is_declared() {
11085 assert_eq!(
11086 effective_containment_floor(None, None, None),
11087 ContainmentClass::Advisory
11088 );
11089 }
11090
11091 #[test]
11092 fn effective_floor_takes_the_strongest_across_every_presence_combination() {
11093 use ContainmentClass::{Advisory, Scoped, Sealed};
11094
11095 assert_eq!(
11097 effective_containment_floor(Some(Scoped), None, None),
11098 Scoped
11099 );
11100 assert_eq!(
11101 effective_containment_floor(None, Some(Scoped), None),
11102 Scoped
11103 );
11104 assert_eq!(
11105 effective_containment_floor(None, None, Some(Scoped)),
11106 Scoped
11107 );
11108 assert_eq!(
11109 effective_containment_floor(Some(Sealed), None, None),
11110 Sealed
11111 );
11112 assert_eq!(
11113 effective_containment_floor(None, Some(Sealed), None),
11114 Sealed
11115 );
11116 assert_eq!(
11117 effective_containment_floor(None, None, Some(Sealed)),
11118 Sealed
11119 );
11120
11121 assert_eq!(
11124 effective_containment_floor(Some(Advisory), Some(Sealed), None),
11125 Sealed
11126 );
11127 assert_eq!(
11128 effective_containment_floor(Some(Sealed), Some(Advisory), None),
11129 Sealed
11130 );
11131 assert_eq!(
11132 effective_containment_floor(Some(Advisory), None, Some(Scoped)),
11133 Scoped
11134 );
11135 assert_eq!(
11136 effective_containment_floor(Some(Scoped), None, Some(Advisory)),
11137 Scoped
11138 );
11139 assert_eq!(
11140 effective_containment_floor(None, Some(Advisory), Some(Scoped)),
11141 Scoped
11142 );
11143 assert_eq!(
11144 effective_containment_floor(None, Some(Scoped), Some(Advisory)),
11145 Scoped
11146 );
11147
11148 assert_eq!(
11150 effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
11151 Sealed
11152 );
11153 assert_eq!(
11154 effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
11155 Sealed
11156 );
11157 assert_eq!(
11158 effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
11159 Sealed
11160 );
11161 assert_eq!(
11162 effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
11163 Scoped
11164 );
11165 assert_eq!(
11166 effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
11167 Advisory
11168 );
11169 }
11170
11171 #[test]
11172 fn manifest_parses_each_declared_containment_class() {
11173 for class in ContainmentClass::ALL {
11174 let manifest = RuntimeManifest::from_yaml_str(&format!(
11175 r#"
11176name: cap
11177version: 0.0.1
11178capabilities:
11179 containment: {class}
11180"#
11181 ))
11182 .expect("a valid containment class parses");
11183
11184 assert_eq!(
11185 manifest.capabilities.unwrap().containment,
11186 Some(class),
11187 "capabilities.containment: {class}"
11188 );
11189 }
11190 }
11191
11192 #[test]
11193 fn manifest_without_containment_key_declares_nothing() {
11194 let manifest = RuntimeManifest::from_yaml_str(
11196 r#"
11197name: cap
11198version: 0.0.1
11199"#,
11200 )
11201 .unwrap();
11202 assert!(manifest.capabilities.is_none());
11203
11204 let manifest = RuntimeManifest::from_yaml_str(
11207 r#"
11208name: cap
11209version: 0.0.1
11210capabilities:
11211 network:
11212 allow:
11213 - https://api.example.com
11214"#,
11215 )
11216 .unwrap();
11217 assert_eq!(manifest.capabilities.unwrap().containment, None);
11218 }
11219
11220 #[test]
11221 fn manifest_rejects_an_unknown_containment_class() {
11222 let err = RuntimeManifest::from_yaml_str(
11223 r#"
11224name: cap
11225version: 0.0.1
11226capabilities:
11227 containment: paranoid
11228"#,
11229 )
11230 .expect_err("an unknown containment class is rejected at parse time");
11231
11232 match err {
11233 RuntimeManifestError::InvalidCapabilities { field, message } => {
11234 assert_eq!(field, "capabilities.containment");
11235 assert_eq!(
11236 message,
11237 "must be one of: advisory, scoped, sealed; got 'paranoid'"
11238 );
11239 }
11240 other => panic!("expected InvalidCapabilities, got {other:?}"),
11241 }
11242 }
11243
11244 fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
11247 RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
11248 }
11249
11250 fn export_error(block: &str) -> (String, String) {
11251 match exports_manifest(block).expect_err("this exports block must not parse") {
11252 RuntimeManifestError::InvalidExports { field, message } => (field, message),
11253 other => panic!("expected InvalidExports, got {other:?}"),
11254 }
11255 }
11256
11257 fn peer_fetch_error(block: &str) -> (String, String) {
11258 match exports_manifest(block).expect_err("this capabilities block must not parse") {
11259 RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
11260 other => panic!("expected InvalidCapabilities, got {other:?}"),
11261 }
11262 }
11263
11264 #[test]
11267 fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
11268 assert_eq!(parse_duration_secs("90").unwrap(), 90);
11269 assert_eq!(parse_duration_secs("30s").unwrap(), 30);
11270 assert_eq!(parse_duration_secs("15m").unwrap(), 900);
11271 assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
11272 assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
11273 assert_eq!(parse_duration_secs(" 2h ").unwrap(), 7200);
11274 assert_eq!(parse_duration_secs("0").unwrap(), 0);
11275 }
11276
11277 #[test]
11280 fn parse_duration_secs_rejects_every_other_spelling() {
11281 for input in [
11282 "",
11283 " ",
11284 "5 minutes",
11285 "30S",
11286 "1H",
11287 "5min",
11288 "1.5h",
11289 "-1",
11290 "m",
11291 "h",
11292 "1h30m",
11293 "0x10",
11294 "30 s",
11295 ] {
11296 let error = parse_duration_secs(input)
11297 .expect_err(&format!("'{input}' must not parse as a duration"));
11298 assert!(
11299 error.contains(DURATION_ACCEPTED_FORM),
11300 "'{input}' must be refused with the accepted form; got: {error}"
11301 );
11302 }
11303 }
11304
11305 #[test]
11306 fn parse_duration_secs_refuses_to_overflow() {
11307 assert!(parse_duration_secs("18446744073709551615h")
11308 .unwrap_err()
11309 .contains("overflows"));
11310 }
11311
11312 #[test]
11313 fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
11314 let manifest = exports_manifest("exports:\n peer_files:\n root: out/\n")
11315 .expect("a minimal peer_files block parses");
11316 let peer = manifest.exports.unwrap().peer_files.unwrap();
11317 assert_eq!(peer.root, "out/");
11318 assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
11319 assert_eq!(peer.max_bytes, 10_485_760);
11320 assert_eq!(peer.max_ttl_secs, None);
11323 assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
11324 assert_eq!(peer.effective_max_ttl_secs(), 3600);
11325 }
11326
11327 #[test]
11328 fn peer_files_accepts_every_duration_spelling() {
11329 for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
11330 let manifest = exports_manifest(&format!(
11331 "exports:\n peer_files:\n root: out/\n max_ttl: {declared}\n"
11332 ))
11333 .expect("a declared max_ttl parses");
11334 let peer = manifest.exports.unwrap().peer_files.unwrap();
11335 assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
11336 assert_eq!(peer.effective_max_ttl_secs(), expected);
11337 }
11338 }
11339
11340 #[test]
11343 fn the_two_export_blocks_are_independent() {
11344 let both = exports_manifest(
11345 "exports:\n files:\n root: out/\n mode: read-only\n \
11346 peer_files:\n root: out/handoff/\n",
11347 )
11348 .expect("both blocks parse together")
11349 .exports
11350 .unwrap();
11351 assert_eq!(both.files.unwrap().root, "out/");
11352 assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
11353
11354 let only_files =
11355 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
11356 .unwrap()
11357 .exports
11358 .unwrap();
11359 assert!(only_files.peer_files.is_none());
11360
11361 let only_peer = exports_manifest("exports:\n peer_files:\n root: out/\n")
11362 .unwrap()
11363 .exports
11364 .unwrap();
11365 assert!(only_peer.files.is_none());
11366
11367 let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
11368 assert!(neither.files.is_none() && neither.peer_files.is_none());
11369 }
11370
11371 #[test]
11372 fn peer_files_root_must_be_relative_and_inside_the_workdir() {
11373 for root in ["../out", "/etc", "out/../../etc"] {
11374 let (field, message) =
11375 export_error(&format!("exports:\n peer_files:\n root: {root}\n"));
11376 assert_eq!(field, "exports.peer_files.root", "root: {root}");
11377 assert!(
11378 message.contains(EXPORT_ROOT_ACCEPTED_FORM),
11379 "root: {root}; got: {message}"
11380 );
11381 }
11382 }
11383
11384 #[test]
11385 fn peer_files_root_is_required() {
11386 let (field, message) = export_error("exports:\n peer_files: {}\n");
11387 assert_eq!(field, "exports.peer_files.root");
11388 assert!(message.contains("is required"));
11389 assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
11390 }
11391
11392 #[test]
11393 fn peer_files_max_ttl_states_its_accepted_form() {
11394 for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
11395 let (field, message) = export_error(&format!(
11396 "exports:\n peer_files:\n root: out/\n max_ttl: \"{declared}\"\n"
11397 ));
11398 assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
11399 assert!(
11400 message.contains(DURATION_ACCEPTED_FORM),
11401 "max_ttl: {declared}; got: {message}"
11402 );
11403 }
11404 }
11405
11406 #[test]
11409 fn peer_files_max_ttl_rejects_zero() {
11410 let (field, message) =
11411 export_error("exports:\n peer_files:\n root: out/\n max_ttl: 0\n");
11412 assert_eq!(field, "exports.peer_files.max_ttl");
11413 assert!(message.contains("greater than zero"));
11414 }
11415
11416 #[test]
11417 fn peer_files_max_bytes_states_its_accepted_form() {
11418 let (field, message) =
11419 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 10MB\n");
11420 assert_eq!(field, "exports.peer_files.max_bytes");
11421 assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
11422 }
11423
11424 #[test]
11425 fn peer_files_max_bytes_rejects_zero() {
11426 let (field, message) =
11427 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 0\n");
11428 assert_eq!(field, "exports.peer_files.max_bytes");
11429 assert!(message.contains("greater than zero"));
11430 }
11431
11432 #[test]
11436 fn peer_tasks_consents_only_on_an_explicit_true() {
11437 let absent = exports_manifest("artifacts: []\n").expect("no exports block parses");
11438 assert!(absent.exports.is_none());
11439 assert!(!absent.accepts_peer_tasks());
11440
11441 let refused = exports_manifest("exports:\n peer_tasks:\n accept: false\n")
11442 .expect("an explicit refusal parses");
11443 assert_eq!(
11444 refused.exports.as_ref().unwrap().peer_tasks,
11445 Some(PeerTasksExport { accept: false })
11446 );
11447 assert!(!refused.accepts_peer_tasks());
11448 assert!(!refused.exports.as_ref().unwrap().accepts_peer_tasks());
11449
11450 let accepted = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
11451 .expect("consent parses");
11452 assert_eq!(
11453 accepted.exports.as_ref().unwrap().peer_tasks,
11454 Some(PeerTasksExport { accept: true })
11455 );
11456 assert!(accepted.accepts_peer_tasks());
11457 assert!(accepted.exports.as_ref().unwrap().accepts_peer_tasks());
11458 }
11459
11460 #[test]
11462 fn peer_tasks_alone_declares_no_file_plane() {
11463 let manifest = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
11464 .expect("a peer_tasks-only exports block parses");
11465 let exports = manifest.exports.unwrap();
11466 assert_eq!(exports.files, None);
11467 assert_eq!(exports.peer_files, None);
11468 }
11469
11470 #[test]
11472 fn a_file_export_does_not_consent_to_peer_tasks() {
11473 let manifest =
11474 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
11475 .expect("a files export parses");
11476 assert_eq!(manifest.exports.as_ref().unwrap().peer_tasks, None);
11477 assert!(!manifest.accepts_peer_tasks());
11478 }
11479
11480 #[test]
11483 fn peer_tasks_without_accept_is_refused() {
11484 let (field, message) = export_error("exports:\n peer_tasks: {}\n");
11485 assert_eq!(field, "exports.peer_tasks.accept");
11486 assert_eq!(
11487 message,
11488 "must be set explicitly to true or false — consent to peer tasks is never inferred"
11489 );
11490 }
11491
11492 #[test]
11494 fn peer_tasks_of_the_wrong_type_is_refused_naming_the_field() {
11495 for (block, field) in [
11496 (
11497 "exports:\n peer_tasks:\n accept: \"yes\"\n",
11498 "exports.peer_tasks.accept",
11499 ),
11500 (
11501 "exports:\n peer_tasks:\n accept: 1\n",
11502 "exports.peer_tasks.accept",
11503 ),
11504 ("exports:\n peer_tasks: true\n", "exports.peer_tasks"),
11505 ("exports:\n peer_tasks: [peer-a]\n", "exports.peer_tasks"),
11506 ] {
11507 let message = exports_manifest(block)
11508 .expect_err(&format!("{block:?} must not parse"))
11509 .to_string();
11510 assert!(message.contains(field), "{block:?}: {message}");
11511 }
11512 }
11513
11514 #[test]
11517 fn an_unknown_peer_tasks_key_is_reported_by_path() {
11518 let manifest =
11519 exports_manifest("exports:\n peer_tasks:\n accept: false\n allow: [peer-a]\n")
11520 .expect("an unknown key is a warning, not a parse error");
11521 assert!(
11522 manifest
11523 .unknown_keys
11524 .iter()
11525 .any(|key| key.block_path == "exports.peer_tasks" && key.key == "allow"),
11526 "{:?}",
11527 manifest.unknown_keys
11528 );
11529 assert!(!manifest.accepts_peer_tasks());
11530 }
11531
11532 #[test]
11535 fn peer_fetch_allow_carries_its_declared_destinations() {
11536 let manifest = exports_manifest(
11537 "capabilities:\n peer_fetch:\n allow:\n - localhost:41234\n \
11538 - reporting.internal\n - https://gateway.example.com\n",
11539 )
11540 .expect("a peer_fetch block parses");
11541 assert_eq!(
11542 manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
11543 vec![
11544 "localhost:41234".to_string(),
11545 "reporting.internal".to_string(),
11546 "https://gateway.example.com".to_string(),
11547 ]
11548 );
11549 }
11550
11551 #[test]
11554 fn peer_fetch_allow_must_not_be_empty() {
11555 let (field, message) = peer_fetch_error("capabilities:\n peer_fetch:\n allow: []\n");
11556 assert_eq!(field, "capabilities.peer_fetch.allow");
11557 assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
11558 }
11559
11560 #[test]
11561 fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
11562 for entry in [
11563 "http://[not a host",
11564 "\"\"",
11565 "localhost:notaport",
11566 "http://example.com/path",
11567 "ftp://example.com",
11568 ] {
11569 let (field, message) = peer_fetch_error(&format!(
11570 "capabilities:\n peer_fetch:\n allow:\n - {entry}\n"
11571 ));
11572 assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
11573 assert!(
11574 message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
11575 "entry: {entry}; got: {message}"
11576 );
11577 }
11578 }
11579
11580 #[test]
11583 fn peer_fetch_does_not_widen_network_allow() {
11584 let capabilities = exports_manifest(
11585 "capabilities:\n network:\n allow:\n - api.example.com\n \
11586 peer_fetch:\n allow:\n - localhost:41234\n",
11587 )
11588 .unwrap()
11589 .capabilities
11590 .unwrap();
11591 assert_eq!(
11592 capabilities.network.unwrap().allow,
11593 vec!["api.example.com".to_string()]
11594 );
11595 assert_eq!(
11596 capabilities.peer_fetch.unwrap().allow,
11597 vec!["localhost:41234".to_string()]
11598 );
11599 }
11600
11601 #[test]
11602 fn an_absent_peer_fetch_block_is_absent() {
11603 assert!(exports_manifest("capabilities: {}\n")
11604 .unwrap()
11605 .capabilities
11606 .unwrap()
11607 .peer_fetch
11608 .is_none());
11609 }
11610
11611 #[test]
11612 fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
11613 assert_eq!(parse_byte_size("0").unwrap(), 0);
11614 assert_eq!(parse_byte_size("4096").unwrap(), 4096);
11615 assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
11616 assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
11617 assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
11618 assert_eq!(parse_byte_size(" 10Mi ").unwrap(), 10 * 1024 * 1024);
11619 }
11620
11621 #[test]
11624 fn parse_byte_size_rejects_every_other_spelling() {
11625 for input in [
11626 "", " ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
11627 "1Ki1",
11628 ] {
11629 let error = parse_byte_size(input)
11630 .expect_err(&format!("'{input}' must not parse as a byte size"));
11631 assert!(
11632 error.contains(BYTE_SIZE_ACCEPTED_FORM),
11633 "'{input}' must be refused with the accepted form; got: {error}"
11634 );
11635 }
11636 }
11637
11638 #[test]
11639 fn parse_byte_size_refuses_to_overflow() {
11640 assert!(parse_byte_size("18446744073709551615Gi")
11641 .unwrap_err()
11642 .contains("overflows"));
11643 }
11644
11645 #[test]
11646 fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
11647 let manifest =
11648 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
11649 .expect("a minimal exports block parses");
11650 let files = manifest.exports.unwrap().files.unwrap();
11651 assert_eq!(files.root, "out/");
11652 assert_eq!(files.mode, ExportMode::ReadOnly);
11653 assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
11654 assert_eq!(files.max_bytes, 10_485_760);
11655 }
11656
11657 #[test]
11658 fn exports_files_accepts_both_byte_spellings() {
11659 for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
11660 let manifest = exports_manifest(&format!(
11661 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: {declared}\n"
11662 ))
11663 .expect("a declared max_bytes parses");
11664 assert_eq!(
11665 manifest.exports.unwrap().files.unwrap().max_bytes,
11666 expected,
11667 "max_bytes: {declared}"
11668 );
11669 }
11670 }
11671
11672 #[test]
11675 fn an_exports_block_without_files_declares_no_plane() {
11676 let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
11677 assert!(manifest.exports.unwrap().files.is_none());
11678 }
11679
11680 #[test]
11681 fn an_absent_exports_block_is_absent() {
11682 let manifest = exports_manifest("").expect("a manifest without exports parses");
11683 assert!(manifest.exports.is_none());
11684 }
11685
11686 #[test]
11687 fn exports_files_root_must_be_relative_and_inside_the_workdir() {
11688 for root in ["../out", "/etc", "out/../../etc", "''"] {
11689 let (field, message) = export_error(&format!(
11690 "exports:\n files:\n root: {root}\n mode: read-only\n"
11691 ));
11692 assert_eq!(field, "exports.files.root", "root: {root}");
11693 assert!(
11694 message.contains("must be a relative path inside the workdir"),
11695 "root: {root}; got: {message}"
11696 );
11697 }
11698 }
11699
11700 #[test]
11701 fn exports_files_root_is_required() {
11702 let (field, message) = export_error("exports:\n files:\n mode: read-only\n");
11703 assert_eq!(field, "exports.files.root");
11704 assert_eq!(
11705 message,
11706 "is required and must be a relative path inside the workdir"
11707 );
11708 }
11709
11710 #[test]
11711 fn exports_files_mode_is_required_and_read_only() {
11712 let (field, message) = export_error("exports:\n files:\n root: out/\n");
11713 assert_eq!(field, "exports.files.mode");
11714 assert_eq!(message, "is required and must be 'read-only'");
11715
11716 let (field, message) =
11717 export_error("exports:\n files:\n root: out/\n mode: read-write\n");
11718 assert_eq!(field, "exports.files.mode");
11719 assert_eq!(message, "'read-write' must be 'read-only'");
11720 }
11721
11722 #[test]
11723 fn exports_files_max_bytes_states_its_accepted_form() {
11724 let (field, message) = export_error(
11725 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 10MB\n",
11726 );
11727 assert_eq!(field, "exports.files.max_bytes");
11728 assert_eq!(
11729 message,
11730 "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
11731 );
11732 }
11733
11734 #[test]
11737 fn exports_files_max_bytes_rejects_zero() {
11738 let (field, message) = export_error(
11739 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 0\n",
11740 );
11741 assert_eq!(field, "exports.files.max_bytes");
11742 assert!(message.contains("must be greater than zero"), "{message}");
11743 }
11744
11745 #[test]
11747 fn exports_files_root_accepts_a_current_dir_prefix() {
11748 let manifest =
11749 exports_manifest("exports:\n files:\n root: ./out\n mode: read-only\n")
11750 .expect("./out is a legal root");
11751 assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
11752 }
11753
11754 fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
11757 RuntimeManifest::from_yaml_str(yaml)
11758 .expect("fixture must parse")
11759 .unknown_keys
11760 .into_iter()
11761 .map(|key| (key.key, key.block_path, key.nearest_known))
11762 .collect()
11763 }
11764
11765 #[test]
11768 fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
11769 assert_eq!(
11770 unknown_keys_of(
11771 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n read-only:\n - tests\n"
11772 ),
11773 vec![(
11774 "read-only".to_string(),
11775 "capabilities.filesystem".to_string(),
11776 Some("read_only".to_string())
11777 )]
11778 );
11779 }
11780
11781 #[test]
11785 fn a_manifest_of_recognized_keys_reports_nothing() {
11786 assert!(unknown_keys_of(
11787 r#"
11788name: cap
11789version: 0.1.0
11790mur_version: "0.2.0"
11791artifacts:
11792 - name: notes-tool
11793 version: 0.1.0
11794 runtime: tool
11795 capabilities:
11796 filesystem:
11797 scope: notes
11798 network:
11799 allow:
11800 - example.com:443
11801capabilities:
11802 filesystem:
11803 read_only:
11804 - tests
11805 - bench/fixtures
11806 workdir_exec: false
11807 shell:
11808 allow:
11809 - git
11810 - python3
11811 network:
11812 allow:
11813 - example.com:443
11814 unix_sockets: false
11815"#
11816 )
11817 .is_empty());
11818 }
11819
11820 #[test]
11823 fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
11824 assert_eq!(
11825 unknown_keys_of(
11826 "name: cap\nversion: 0.1.0\nartifacts:\n - name: t\n version: 0.1.0\n \
11827 runtime: tool\n capabilities:\n shell:\n allow:\n - git\n \
11828 allwo:\n - git\n"
11829 ),
11830 vec![(
11831 "allwo".to_string(),
11832 "artifacts[0].capabilities.shell".to_string(),
11833 Some("allow".to_string())
11834 )]
11835 );
11836 }
11837
11838 #[test]
11841 fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
11842 assert_eq!(
11843 unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
11844 vec![("quantum_teleport".to_string(), String::new(), None)]
11845 );
11846 }
11847
11848 #[test]
11853 fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
11854 assert!(unknown_keys_of(
11855 "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
11856 )
11857 .is_empty());
11858 }
11859
11860 #[test]
11862 fn every_build_side_key_is_recognized() {
11863 assert!(unknown_keys_of(
11864 "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
11865 wasm\nrequires_files:\n - tool.wasm\n - assets/logo.png\n"
11866 )
11867 .is_empty());
11868 }
11869
11870 #[test]
11873 fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
11874 assert_eq!(
11875 unknown_keys_of(
11876 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
11877 static\nrequires_files:\n - capsule.wasm\nquantum_teleport: true\n"
11878 ),
11879 vec![("quantum_teleport".to_string(), String::new(), None)]
11880 );
11881 }
11882
11883 #[test]
11887 fn the_build_side_keys_change_nothing_the_run_path_reads() {
11888 let body = "artifacts:\n - name: notes-tool\n version: 0.1.0\n runtime: tool\n\
11889 \x20 - name: murmur-driver-anthropic\n version: 0.1.0\n runtime: \
11890 driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\n\
11891 capabilities:\n filesystem:\n read_only:\n - tests\n\
11892 inference:\n transport: http\n model: test-model\n driver:\n \
11893 artifact: murmur-driver-anthropic\n";
11894 let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
11895 .expect("fixture must parse");
11896 let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
11897 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n \
11898 any: shape\nrequires_files: not-a-list\n{body}"
11899 ))
11900 .expect("a build-side key of any shape never refuses a manifest");
11901
11902 assert!(with_build_keys.unknown_keys.is_empty());
11903 assert!(
11904 !plain.artifacts.is_empty()
11905 && plain.capabilities.is_some()
11906 && plain.inference.is_some(),
11907 "the fixture must populate the blocks the comparison below rests on, or it \
11908 compares absence against absence"
11909 );
11910 assert_eq!(with_build_keys.name, plain.name);
11911 assert_eq!(with_build_keys.version, plain.version);
11912 assert_eq!(with_build_keys.artifacts, plain.artifacts);
11913 assert_eq!(with_build_keys.capabilities, plain.capabilities);
11914 assert_eq!(with_build_keys.inference, plain.inference);
11915 }
11916
11917 #[test]
11920 fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
11921 let manifest = RuntimeManifest::from_yaml_str(
11922 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n scope: notes\n \
11923 read-only:\n - tests\n",
11924 )
11925 .expect("an unrecognized key never refuses a manifest");
11926 assert_eq!(
11927 manifest
11928 .capabilities
11929 .as_ref()
11930 .and_then(|caps| caps.filesystem.as_ref())
11931 .and_then(|fs| fs.scope.as_deref()),
11932 Some("notes")
11933 );
11934 assert_eq!(manifest.unknown_keys.len(), 1);
11935 }
11936
11937 #[test]
11939 fn nested_blocks_outside_capabilities_are_walked_too() {
11940 let keys = unknown_keys_of(
11941 "name: cap\nversion: 0.1.0\ncontext:\n max_tokns: 100\nexports:\n files:\n \
11942 root: out\n mode: read-only\n max_byts: 10\n",
11943 );
11944 assert_eq!(
11945 keys,
11946 vec![
11947 (
11948 "max_tokns".to_string(),
11949 "context".to_string(),
11950 Some("max_tokens".to_string())
11951 ),
11952 (
11953 "max_byts".to_string(),
11954 "exports.files".to_string(),
11955 Some("max_bytes".to_string())
11956 ),
11957 ]
11958 );
11959 }
11960
11961 #[test]
11968 fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
11969 let source = include_str!("runtime_manifest.rs");
11970 let lines: Vec<&str> = source.lines().collect();
11971
11972 let mut checked = 0usize;
11973 for (start, line) in lines.iter().enumerate() {
11974 let Some(name) = line
11975 .strip_prefix("struct Raw")
11976 .and_then(|rest| rest.strip_suffix(" {"))
11977 else {
11978 continue;
11979 };
11980 let name = format!("Raw{name}");
11981 let end = (start + 1..lines.len())
11982 .find(|index| lines[*index] == "}")
11983 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
11984 let body = &lines[start + 1..end];
11985
11986 assert!(
11987 body.contains(&" #[serde(flatten)]")
11988 && body.contains(&" unknown: UnknownKeys,"),
11989 "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
11990 so a key it does not recognize would be dropped instead of reported"
11991 );
11992
11993 let declared = serde_field_names(body);
11994 let known = known_keys_of(source, &name);
11995 assert_eq!(
11996 declared, known,
11997 "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
11998 suggester would propose the wrong set for that block"
11999 );
12000 checked += 1;
12001 }
12002 assert!(
12003 checked >= 30,
12004 "expected every Raw* struct to be scanned, found only {checked}"
12005 );
12006 }
12007
12008 #[test]
12015 fn every_raw_struct_descends_into_the_blocks_it_owns() {
12016 let source = include_str!("runtime_manifest.rs");
12017 let lines: Vec<&str> = source.lines().collect();
12018
12019 let mut structs = 0usize;
12020 let mut children = 0usize;
12021 for (start, line) in lines.iter().enumerate() {
12022 let Some(name) = line
12023 .strip_prefix("struct Raw")
12024 .and_then(|rest| rest.strip_suffix(" {"))
12025 else {
12026 continue;
12027 };
12028 let name = format!("Raw{name}");
12029 let end = (start + 1..lines.len())
12030 .find(|index| lines[*index] == "}")
12031 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
12032 let owned = block_typed_fields(&lines[start + 1..end]);
12033 structs += 1;
12034
12035 if owned.is_empty() {
12036 continue;
12037 }
12038 let block = raw_block_impl_of(source, &name);
12039 assert!(
12040 block.contains("fn walk_children"),
12041 "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
12042 `walk_children`, so every key written inside {} would go unreported",
12043 if owned.len() == 1 { "" } else { "s" },
12044 owned.join(", "),
12045 if owned.len() == 1 { "it" } else { "them" }
12046 );
12047 for field in &owned {
12048 assert!(
12049 mentions_field(&block, field),
12050 "{name}'s `walk_children` never touches `self.{field}`, so every key written \
12051 inside that block would go unreported — descend into it with \
12052 `collect_block(.., &child_path(path, \"{field}\"), out)`"
12053 );
12054 children += 1;
12055 }
12056 }
12057 assert!(
12058 structs >= 30,
12059 "expected every Raw* struct to be scanned, found only {structs}"
12060 );
12061 assert!(
12062 children >= 31,
12063 "expected every nested block field to be scanned, found only {children}"
12064 );
12065 }
12066
12067 fn mentions_field(impl_body: &str, field: &str) -> bool {
12071 let needle = format!("self.{field}");
12072 impl_body.match_indices(&needle).any(|(at, _)| {
12073 impl_body[at + needle.len()..]
12074 .chars()
12075 .next()
12076 .is_none_or(|next| !next.is_alphanumeric() && next != '_')
12077 })
12078 }
12079
12080 fn block_typed_fields(body: &[&str]) -> Vec<String> {
12084 let mut fields = Vec::new();
12085 for line in body {
12086 let trimmed = line.trim();
12087 if trimmed.starts_with("//") || trimmed.starts_with("#[") {
12088 continue;
12089 }
12090 let Some((field, declared)) = line
12091 .strip_prefix(" ")
12092 .and_then(|rest| rest.split_once(':'))
12093 else {
12094 continue;
12095 };
12096 if field.is_empty()
12097 || !field
12098 .chars()
12099 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
12100 {
12101 continue;
12102 }
12103 if declared.contains("Raw") {
12104 fields.push(field.to_string());
12105 }
12106 }
12107 fields
12108 }
12109
12110 fn raw_block_impl_of(source: &str, name: &str) -> String {
12113 let header = format!("impl RawBlock for {name} {{");
12114 let body = source
12115 .split_once(&header)
12116 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
12117 .1;
12118 body.split_once("\n}\n")
12119 .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
12120 .0
12121 .to_string()
12122 }
12123
12124 fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
12131 let raw: RawRuntimeManifest = serde_yaml::from_str(yaml).expect("fixture must parse");
12132 collect_unknown_keys(&raw)
12133 .into_iter()
12134 .map(|key| (key.key, key.block_path))
12135 .collect()
12136 }
12137
12138 #[test]
12142 fn every_block_reports_its_own_unknown_key_at_its_own_path() {
12143 let keys = raw_unknown_keys(
12144 r#"
12145name: every-block
12146version: 0.1.0
12147probe_root: 1
12148artifacts:
12149 - name: t
12150 version: 0.1.0
12151 runtime: tool
12152 probe_artifact: 1
12153 capabilities:
12154 probe_artifact_capabilities: 1
12155 shell:
12156 probe_artifact_shell: 1
12157capabilities:
12158 probe_capabilities: 1
12159 network:
12160 probe_network: 1
12161 peer_fetch:
12162 probe_peer_fetch: 1
12163 filesystem:
12164 probe_filesystem: 1
12165 shell:
12166 probe_shell: 1
12167 interpreter_runtime:
12168 - binary: python3
12169 probe_interpreter_runtime: 1
12170 dirs:
12171 - path: /usr/lib/python3
12172 probe_interpreter_runtime_dir: 1
12173 staged_runtime:
12174 - binary: rg
12175 probe_staged_runtime: 1
12176 spawn:
12177 probe_spawn: 1
12178 env:
12179 probe_env: 1
12180 limits:
12181 probe_limits: 1
12182 resources:
12183 probe_resources: 1
12184 state:
12185 probe_state: 1
12186 task_io:
12187 probe_task_io: 1
12188 conversation:
12189 probe_conversation: 1
12190 install:
12191 probe_install: 1
12192inference:
12193 probe_inference: 1
12194 driver:
12195 probe_driver: 1
12196 compaction:
12197 probe_compaction: 1
12198context:
12199 probe_context: 1
12200observability:
12201 probe_observability: 1
12202 eval:
12203 probe_eval: 1
12204 scorers:
12205 - type: exact_match
12206 probe_scorer: 1
12207trace:
12208 probe_trace: 1
12209network:
12210 probe_network_config: 1
12211lifecycle:
12212 probe_lifecycle: 1
12213exports:
12214 probe_exports: 1
12215 files:
12216 probe_files: 1
12217 peer_files:
12218 probe_peer_files: 1
12219control:
12220 probe_control: 1
12221"#,
12222 );
12223
12224 let expected: Vec<(String, String)> = [
12225 ("probe_root", ""),
12226 ("probe_artifact", "artifacts[0]"),
12227 ("probe_artifact_capabilities", "artifacts[0].capabilities"),
12228 ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
12229 ("probe_capabilities", "capabilities"),
12230 ("probe_network", "capabilities.network"),
12231 ("probe_peer_fetch", "capabilities.peer_fetch"),
12232 ("probe_filesystem", "capabilities.filesystem"),
12233 ("probe_shell", "capabilities.shell"),
12234 (
12235 "probe_interpreter_runtime",
12236 "capabilities.shell.interpreter_runtime[0]",
12237 ),
12238 (
12239 "probe_interpreter_runtime_dir",
12240 "capabilities.shell.interpreter_runtime[0].dirs[0]",
12241 ),
12242 (
12243 "probe_staged_runtime",
12244 "capabilities.shell.staged_runtime[0]",
12245 ),
12246 ("probe_spawn", "capabilities.spawn"),
12247 ("probe_env", "capabilities.env"),
12248 ("probe_limits", "capabilities.limits"),
12249 ("probe_resources", "capabilities.resources"),
12250 ("probe_state", "capabilities.state"),
12251 ("probe_task_io", "capabilities.task_io"),
12252 ("probe_conversation", "capabilities.conversation"),
12253 ("probe_install", "capabilities.install"),
12254 ("probe_inference", "inference"),
12255 ("probe_driver", "inference.driver"),
12256 ("probe_compaction", "inference.compaction"),
12257 ("probe_context", "context"),
12258 ("probe_observability", "observability"),
12259 ("probe_eval", "observability.eval"),
12260 ("probe_scorer", "observability.eval.scorers[0]"),
12261 ("probe_trace", "trace"),
12262 ("probe_network_config", "network"),
12263 ("probe_lifecycle", "lifecycle"),
12264 ("probe_exports", "exports"),
12265 ("probe_files", "exports.files"),
12266 ("probe_peer_files", "exports.peer_files"),
12267 ("probe_control", "control"),
12268 ]
12269 .into_iter()
12270 .map(|(key, path)| (key.to_string(), path.to_string()))
12271 .collect();
12272
12273 assert_eq!(keys, expected);
12274 }
12275
12276 fn serde_field_names(body: &[&str]) -> Vec<String> {
12280 let mut names = Vec::new();
12281 let mut rename: Option<String> = None;
12282 for line in body {
12283 let trimmed = line.trim();
12284 if trimmed.starts_with("#[") {
12285 if let Some(rest) = trimmed.split("rename = \"").nth(1) {
12286 if !trimmed.contains("rename_all") {
12287 rename = rest.split('"').next().map(str::to_string);
12288 }
12289 }
12290 continue;
12291 }
12292 if trimmed.starts_with("//") {
12293 continue;
12294 }
12295 let Some(field) = line
12296 .strip_prefix(" ")
12297 .and_then(|rest| rest.split(':').next())
12298 else {
12299 continue;
12300 };
12301 if field.is_empty()
12302 || !field
12303 .chars()
12304 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
12305 {
12306 continue;
12307 }
12308 if field == "unknown" {
12309 rename = None;
12310 continue;
12311 }
12312 names.push(rename.take().unwrap_or_else(|| field.to_string()));
12313 }
12314 names
12315 }
12316
12317 fn known_keys_of(source: &str, name: &str) -> Vec<String> {
12321 let header = format!("impl RawBlock for {name} {{");
12322 let body = source
12323 .split_once(&header)
12324 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
12325 .1;
12326 let array = body
12327 .split_once("KNOWN_KEYS: &'static [&'static str] = &[")
12328 .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
12329 .1
12330 .split_once("];")
12331 .expect("KNOWN_KEYS array is unterminated")
12332 .0;
12333 array
12334 .split('"')
12335 .skip(1)
12336 .step_by(2)
12337 .map(str::to_string)
12338 .collect()
12339 }
12340}
12341
12342#[cfg(test)]
12343mod control_tests {
12344 use super::*;
12345
12346 const TOOL_ENTRY: &str = " - name: card-api\n version: 0.1.0\n runtime: tool\n gateway:\n endpoint: https://cards.example.com\n api_key: ${CARD_TOKEN}\n";
12347
12348 fn agent(transport: &str, control: &str) -> String {
12351 let driver = if transport == "http" {
12352 " - name: drv\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.example.com\n api_key: ${DRIVER_KEY}\n"
12353 } else {
12354 " - name: drv\n version: 0.1.0\n runtime: driver\n"
12355 };
12356 format!(
12357 "name: cap\nversion: 0.0.1\nartifacts:\n{driver}{TOOL_ENTRY}inference:\n transport: {transport}\n model: m\n driver:\n artifact: drv\n{control}"
12358 )
12359 }
12360
12361 fn refusal(yaml: &str) -> (String, String) {
12362 match RuntimeManifest::from_yaml_str(yaml) {
12363 Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
12364 other => panic!("expected InvalidControl, got {other:?}"),
12365 }
12366 }
12367
12368 #[test]
12369 fn control_absent_empty_and_empty_lists_load_as_none() {
12370 for control in [
12371 "",
12372 "control: {}\n",
12373 "control:\n",
12374 "control:\n settings: []\n secrets: []\n",
12375 ] {
12376 let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
12377 .unwrap_or_else(|err| panic!("{control:?}: {err}"));
12378 assert_eq!(manifest.control, None, "{control:?}");
12379 }
12380 }
12381
12382 #[test]
12383 fn control_parses_settings_and_secrets_in_manifest_order() {
12384 let yaml = agent(
12385 "http",
12386 "control:\n settings: [inference.max_tokens]\n secrets: [CARD_TOKEN]\n",
12387 );
12388 for manifest in [
12389 RuntimeManifest::from_yaml_str(&yaml).unwrap(),
12390 RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
12391 ] {
12392 let control = manifest.control.unwrap();
12393 assert_eq!(
12394 control.settings,
12395 vec![ControllableSetting::InferenceMaxTokens]
12396 );
12397 assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
12398 assert!(control.declares_secret("CARD_TOKEN"));
12399 assert!(!control.declares_secret("DRIVER_KEY"));
12400 }
12401 }
12402
12403 #[test]
12404 fn control_setting_wire_names_round_trip() {
12405 for setting in ControllableSetting::ALL {
12406 assert_eq!(
12407 ControllableSetting::from_wire_name(setting.wire_name()),
12408 Some(*setting)
12409 );
12410 }
12411 assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
12412 }
12413
12414 #[test]
12417 fn control_manifest_refusals_name_the_field() {
12418 let cases: &[(&str, &str, &str, &str)] = &[
12419 (
12420 "http",
12421 "control:\n settings: [inference.model]\n",
12422 "control.settings",
12423 "'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
12424 ),
12425 (
12426 "process",
12427 "control:\n settings: [inference.max_tokens]\n",
12428 "control.settings",
12429 "'inference.max_tokens' is controllable only under inference.transport: http",
12430 ),
12431 (
12432 "http",
12433 "control:\n settings: [inference.max_tokens, inference.max_tokens]\n",
12434 "control.settings",
12435 "'inference.max_tokens' is listed more than once",
12436 ),
12437 (
12438 "http",
12439 "control:\n secrets: [CARD_TOKEN, CARD_TOKEN]\n",
12440 "control.secrets",
12441 "'CARD_TOKEN' is listed more than once",
12442 ),
12443 (
12444 "http",
12445 "control:\n secrets: [card-token]\n",
12446 "control.secrets",
12447 "'card-token' is not a credential name",
12448 ),
12449 (
12450 "http",
12451 "control:\n secrets: [OTHER_TOKEN]\n",
12452 "control.secrets",
12453 "'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
12454 ),
12455 (
12456 "http",
12457 "control:\n secrets: [DRIVER_KEY]\n",
12458 "control.secrets",
12459 "'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.drv.gateway.api_key)",
12460 ),
12461 (
12462 "http",
12463 "control:\n settings: inference.max_tokens\n",
12464 "control.settings",
12465 "must be a list of names",
12466 ),
12467 (
12468 "http",
12469 "control:\n secrets: [1]\n",
12470 "control.secrets",
12471 "must be a list of names",
12472 ),
12473 ];
12474 for (transport, control, field, message) in cases {
12475 let (got_field, got_message) = refusal(&agent(transport, control));
12476 assert_eq!(&got_field, field, "{control:?}");
12477 assert!(
12478 got_message.starts_with(message),
12479 "{control:?}: {got_message}"
12480 );
12481 let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
12482 .unwrap_err()
12483 .to_string();
12484 assert!(
12485 rendered.contains(&format!("invalid control config for '{field}'")),
12486 "{rendered}"
12487 );
12488 }
12489 }
12490
12491 #[test]
12492 fn control_on_a_capsule_with_no_inference_is_refused() {
12493 let yaml = format!(
12494 "name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n secrets: [CARD_TOKEN]\n"
12495 );
12496 let (field, message) = refusal(&yaml);
12497 assert_eq!(field, "control");
12498 assert!(message.contains("no inference: block"), "{message}");
12499 }
12500
12501 #[test]
12502 fn control_unknown_key_is_reported_once_at_its_block() {
12503 let manifest = RuntimeManifest::from_yaml_str(&agent(
12504 "http",
12505 "control:\n settings: [inference.max_tokens]\n agent_may_call: true\n",
12506 ))
12507 .unwrap();
12508 let keys: Vec<(&str, &str)> = manifest
12509 .unknown_keys
12510 .iter()
12511 .map(|key| (key.key.as_str(), key.block_path.as_str()))
12512 .collect();
12513 assert_eq!(keys, vec![("agent_may_call", "control")]);
12514 assert_eq!(
12515 manifest.control.unwrap().settings,
12516 vec![ControllableSetting::InferenceMaxTokens]
12517 );
12518 }
12519}
12520
12521#[cfg(test)]
12522mod driver_choice_tests {
12523 use super::*;
12524
12525 const DRV: &str = " - name: drv\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.example.com\n api_key: ${DRIVER_KEY}\n";
12526 const ALT: &str = " - name: alt\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://alt.example.com\n api_key: ${ALT_KEY}\n";
12527 const ALT_NO_GATEWAY: &str = " - name: alt\n version: 0.1.0\n runtime: driver\n";
12528 const TOOL: &str = " - name: card-api\n version: 0.1.0\n runtime: tool\n";
12529 const GPT: &str = " - name: gpt\n model: gpt-5\n driver:\n artifact: alt\n";
12530 const HAIKU: &str =
12531 " - name: haiku\n model: haiku\n driver:\n artifact: drv\n";
12532 const SWITCHABLE: &str = "control:\n settings: [inference.driver]\n";
12533
12534 fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
12537 let alternates = if alternates.is_empty() {
12538 String::new()
12539 } else {
12540 format!(" alternates:\n{alternates}")
12541 };
12542 format!(
12543 "name: cap\nversion: 0.0.1\nartifacts:\n{DRV}{artifacts}inference:\n transport: http\n model: m\n driver:\n artifact: drv\n{alternates}{control}"
12544 )
12545 }
12546
12547 fn refusal(yaml: &str) -> (String, String) {
12548 match RuntimeManifest::from_yaml_str(yaml) {
12549 Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
12550 | Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
12551 other => panic!("expected an inference or control refusal, got {other:?}"),
12552 }
12553 }
12554
12555 #[test]
12556 fn driver_choice_alternates_parse_in_manifest_order() {
12557 let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
12558 for manifest in [
12559 RuntimeManifest::from_yaml_str(&yaml).unwrap(),
12560 RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
12561 ] {
12562 let inference = manifest.inference.unwrap();
12563 assert_eq!(
12564 inference.alternates,
12565 vec![
12566 InferenceAlternate {
12567 name: "gpt".to_string(),
12568 model: "gpt-5".to_string(),
12569 driver: "alt".to_string(),
12570 },
12571 InferenceAlternate {
12572 name: "haiku".to_string(),
12573 model: "haiku".to_string(),
12574 driver: "drv".to_string(),
12575 },
12576 ]
12577 );
12578 let control = manifest.control.unwrap();
12579 assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
12580 assert!(control.agent_settings.is_empty());
12581 }
12582 }
12583
12584 #[test]
12585 fn driver_choice_absent_alternates_are_empty() {
12586 let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
12587 assert!(manifest.inference.unwrap().alternates.is_empty());
12588 }
12589
12590 #[test]
12591 fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
12592 let manifest = RuntimeManifest::from_yaml_str(&capsule(
12593 ALT,
12594 GPT,
12595 "control:\n agent_settings: [inference.driver]\n",
12596 ))
12597 .unwrap();
12598 let control = manifest.control.unwrap();
12599 assert!(control.settings.is_empty());
12600 assert_eq!(
12601 control.agent_settings,
12602 vec![ControllableSetting::InferenceDriver]
12603 );
12604 assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
12605 assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
12606 assert!(control.secrets.is_empty());
12607 }
12608
12609 #[test]
12610 fn driver_choice_wire_name_round_trips() {
12611 assert_eq!(
12612 ControllableSetting::from_wire_name("inference.driver"),
12613 Some(ControllableSetting::InferenceDriver)
12614 );
12615 assert_eq!(
12616 ControllableSetting::InferenceDriver.wire_name(),
12617 "inference.driver"
12618 );
12619 assert_eq!(
12620 ControllableSetting::AGENT_SETTABLE,
12621 &[ControllableSetting::InferenceDriver]
12622 );
12623 }
12624
12625 #[test]
12627 fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
12628 RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
12629 .expect("an alternate's driver carries its own gateway");
12630 match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
12631 Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
12632 assert!(message.contains("inference.alternates"), "{message}")
12633 }
12634 other => panic!("expected InvalidArtifact, got {other:?}"),
12635 }
12636 }
12637
12638 #[test]
12640 fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
12641 let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
12642 assert_eq!(manifest.inference.unwrap().alternates[0].driver, "drv");
12643 }
12644
12645 #[test]
12646 fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
12647 let manifest = RuntimeManifest::from_yaml_str(&capsule(
12648 ALT,
12649 GPT,
12650 "control:\n settings: [inference.driver]\n secrets: [ALT_KEY]\n",
12651 ))
12652 .unwrap();
12653 assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
12654 let (field, message) = refusal(&capsule(
12655 ALT,
12656 GPT,
12657 "control:\n settings: [inference.driver]\n secrets: [DRIVER_KEY]\n",
12658 ));
12659 assert_eq!(field, "control.secrets");
12660 assert!(
12661 message.contains("keys the configured inference driver's gateway"),
12662 "{message}"
12663 );
12664 }
12665
12666 #[test]
12667 fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
12668 let alternate = " - name: gpt\n model: gpt-5\n temperature: 1\n driver:\n artifact: alt\n";
12669 let manifest =
12670 RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
12671 let keys: Vec<(&str, &str)> = manifest
12672 .unknown_keys
12673 .iter()
12674 .map(|key| (key.key.as_str(), key.block_path.as_str()))
12675 .collect();
12676 assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
12677 }
12678
12679 #[test]
12682 fn driver_choice_manifest_refusals_name_the_field() {
12683 let alternate = |name: &str, model: &str, driver: &str| {
12684 format!(" - name: {name}\n model: {model}\n driver:\n artifact: {driver}\n")
12685 };
12686 let process = "name: cap\nversion: 0.0.1\nartifacts:\n - name: drv\n version: 0.1.0\n runtime: driver\ninference:\n transport: process\n driver:\n artifact: drv\n";
12687 let cases: Vec<(String, &str, &str)> = vec![
12688 (
12689 format!("{process} alternates:\n{GPT}"),
12690 "inference.alternates",
12691 "is not valid with transport: process",
12692 ),
12693 (
12694 format!("{process}control:\n settings: [inference.driver]\n"),
12695 "control.settings",
12696 "'inference.driver' is controllable only under inference.transport: http",
12697 ),
12698 (
12699 capsule(ALT, &alternate("primary", "gpt-5", "alt"), SWITCHABLE),
12700 "inference.alternates[0].name",
12701 "'primary' is reserved",
12702 ),
12703 (
12704 capsule(
12705 ALT,
12706 &format!("{}{}", alternate("gpt", "gpt-5", "alt"), alternate("gpt", "gpt-4", "alt")),
12707 SWITCHABLE,
12708 ),
12709 "inference.alternates[1].name",
12710 "'gpt' names more than one alternate",
12711 ),
12712 (
12713 capsule(ALT, &alternate("GPT", "gpt-5", "alt"), SWITCHABLE),
12714 "inference.alternates[0].name",
12715 "'GPT' is not a driver choice name",
12716 ),
12717 (
12718 capsule(ALT, &alternate("5gpt", "gpt-5", "alt"), SWITCHABLE),
12719 "inference.alternates[0].name",
12720 "'5gpt' is not a driver choice name",
12721 ),
12722 (
12723 capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "alt"), SWITCHABLE),
12724 "inference.alternates[0].name",
12725 "is not a driver choice name",
12726 ),
12727 (
12728 capsule(ALT, " - name: gpt\n model: \"\"\n driver:\n artifact: alt\n", SWITCHABLE),
12729 "inference.alternates[0].model",
12730 "missing required field",
12731 ),
12732 (
12733 capsule(ALT, " - name: gpt\n model: gpt-5\n", SWITCHABLE),
12734 "inference.alternates[0].driver.artifact",
12735 "missing required field",
12736 ),
12737 (
12738 capsule("", &alternate("gpt", "gpt-5", "nowhere"), SWITCHABLE),
12739 "inference.alternates[0].driver.artifact",
12740 "artifact 'nowhere' is not declared in artifacts:",
12741 ),
12742 (
12743 capsule(TOOL, &alternate("gpt", "gpt-5", "card-api"), SWITCHABLE),
12744 "inference.alternates[0].driver.artifact",
12745 "artifact 'card-api' is declared with runtime: tool",
12746 ),
12747 (
12748 capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "alt"), SWITCHABLE),
12749 "inference.alternates[0].driver.artifact",
12750 "artifact 'alt' declares no gateway.endpoint",
12751 ),
12752 (
12753 capsule(
12754 ALT,
12755 " - name: gpt\n model: gpt-5\n driver:\n artifact: alt\n config:\n thinking: enabled\n",
12756 SWITCHABLE,
12757 ),
12758 "inference.alternates[0].driver.config",
12759 "is not valid on an alternate; inference.driver.config applies to every declared driver, and per-driver settings belong in that driver's artifacts[].config",
12760 ),
12761 (
12762 capsule("", &alternate("same", "m", "drv"), SWITCHABLE),
12763 "inference.alternates[0]",
12764 "'same' is driver 'drv' with model 'm', which is the primary choice",
12765 ),
12766 (
12767 capsule(
12768 ALT,
12769 &format!("{}{}", alternate("gpt", "gpt-5", "alt"), alternate("gpt-too", "gpt-5", "alt")),
12770 SWITCHABLE,
12771 ),
12772 "inference.alternates[1]",
12773 "'gpt-too' is driver 'alt' with model 'gpt-5', which alternate 'gpt' already is",
12774 ),
12775 (
12776 capsule(ALT, GPT, ""),
12777 "inference.alternates",
12778 "declares alternates that nothing could ever select",
12779 ),
12780 (
12781 capsule(ALT, GPT, "control:\n settings: [inference.max_tokens]\n"),
12782 "inference.alternates",
12783 "declares alternates that nothing could ever select",
12784 ),
12785 (
12786 capsule("", "", SWITCHABLE),
12787 "control.settings",
12788 "'inference.driver' selects among inference.alternates, and this capsule declares none",
12789 ),
12790 (
12791 capsule("", "", "control:\n agent_settings: [inference.driver]\n"),
12792 "control.agent_settings",
12793 "'inference.driver' selects among inference.alternates, and this capsule declares none",
12794 ),
12795 (
12796 capsule(ALT, GPT, "control:\n agent_settings: [inference.max_tokens]\n"),
12797 "control.agent_settings",
12798 "'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
12799 ),
12800 (
12801 capsule(
12802 ALT,
12803 GPT,
12804 "control:\n agent_settings: [inference.driver, inference.driver]\n",
12805 ),
12806 "control.agent_settings",
12807 "'inference.driver' is listed more than once",
12808 ),
12809 (
12810 capsule(ALT, GPT, "control:\n agent_settings: inference.driver\n"),
12811 "control.agent_settings",
12812 "must be a list of names",
12813 ),
12814 ];
12815 for (yaml, field, message) in &cases {
12816 let (got_field, got_message) = refusal(yaml);
12817 assert_eq!(&got_field, field, "{yaml}");
12818 assert!(got_message.contains(message), "{yaml}\n{got_message}");
12819 let rendered = RuntimeManifest::from_yaml_str(yaml)
12820 .unwrap_err()
12821 .to_string();
12822 assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
12823 }
12824 }
12825}
12826
12827#[cfg(test)]
12828mod network_authentication_tests {
12829 use super::*;
12830
12831 fn network_authentication_of(
12832 block: &str,
12833 ) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
12834 RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
12835 .map(|manifest| manifest.network.and_then(|network| network.authentication))
12836 }
12837
12838 fn network_authentication_refusal(block: &str) -> (String, String) {
12839 match network_authentication_of(block) {
12840 Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
12841 other => panic!("expected InvalidNetworkConfig, got {other:?}"),
12842 }
12843 }
12844
12845 #[test]
12846 fn network_authentication_with_credentials_parses_sorted_by_name() {
12847 let auth = network_authentication_of(
12848 "network:\n authentication:\n scheme: bearer\n credentials:\n \
12849 watcher:\n scopes: [tasks/get, stream/watch]\n \
12850 reader:\n scopes: [resources/files]\n",
12851 )
12852 .unwrap()
12853 .expect("declared");
12854 assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
12855 assert_eq!(
12856 auth.credentials,
12857 vec![
12858 DoorCredential {
12859 name: "reader".to_string(),
12860 scopes: vec!["resources/files".to_string()],
12861 },
12862 DoorCredential {
12863 name: "watcher".to_string(),
12864 scopes: vec!["tasks/get".to_string(), "stream/watch".to_string()],
12866 },
12867 ]
12868 );
12869 }
12870
12871 #[test]
12872 fn network_authentication_scheme_alone_parses() {
12873 let auth = network_authentication_of("network:\n authentication:\n scheme: bearer\n")
12874 .unwrap()
12875 .expect("declared");
12876 assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
12877 assert!(auth.credentials.is_empty());
12878 }
12879
12880 #[test]
12881 fn network_authentication_absent_is_a_public_door() {
12882 assert_eq!(network_authentication_of("").unwrap(), None);
12883 assert_eq!(
12884 network_authentication_of("network:\n internal_port: 8080\n").unwrap(),
12885 None
12886 );
12887 let manifest = RuntimeManifest::from_yaml_str(
12888 "name: cap\nversion: 0.1.0\nnetwork:\n internal_port: 8080\n",
12889 )
12890 .unwrap();
12891 assert_eq!(
12892 manifest.network,
12893 Some(NetworkConfig {
12894 internal_port: Some(8080),
12895 authentication: None,
12896 })
12897 );
12898 }
12899
12900 #[test]
12901 fn network_authentication_without_a_scheme_is_refused() {
12902 let (field, message) = network_authentication_refusal(
12903 "network:\n authentication:\n credentials:\n w:\n scopes: [tasks/get]\n",
12904 );
12905 assert_eq!(field, "network.authentication.scheme");
12906 assert!(message.contains("required"), "{message}");
12907 }
12908
12909 #[test]
12910 fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
12911 for scheme in ["mtls", "Bearer", "basic"] {
12912 let (field, message) = network_authentication_refusal(&format!(
12913 "network:\n authentication:\n scheme: {scheme}\n"
12914 ));
12915 assert_eq!(field, "network.authentication.scheme");
12916 assert!(message.contains("bearer"), "{message}");
12917 assert!(message.contains(scheme), "{message}");
12918 }
12919 }
12920
12921 #[test]
12922 fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
12923 let long = format!("a{}", "b".repeat(32));
12924 for name in [
12925 "operator",
12926 "Watcher",
12927 "1st",
12928 "_x",
12929 "-x",
12930 "wat.cher",
12931 long.as_str(),
12932 ] {
12933 let (field, message) = network_authentication_refusal(&format!(
12934 "network:\n authentication:\n scheme: bearer\n credentials:\n \
12935 '{name}':\n scopes: [tasks/get]\n"
12936 ));
12937 assert_eq!(field, "network.authentication.credentials", "{name}");
12938 assert!(message.contains(name), "{message}");
12939 }
12940 let longest = format!("a{}", "b".repeat(31));
12941 for name in ["w", "a-b_c9", longest.as_str()] {
12942 network_authentication_of(&format!(
12943 "network:\n authentication:\n scheme: bearer\n credentials:\n \
12944 {name}:\n scopes: [tasks/get]\n"
12945 ))
12946 .unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
12947 }
12948 }
12949
12950 #[test]
12951 fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
12952 let credential = |body: &str| {
12953 format!(
12954 "network:\n authentication:\n scheme: bearer\n credentials:\n \
12955 watcher:\n{body}"
12956 )
12957 };
12958 let field = "network.authentication.credentials.watcher.scopes";
12959
12960 let (got, message) = network_authentication_refusal(&credential(" {}\n"));
12961 assert_eq!(got, field);
12962 assert!(message.contains("required"), "{message}");
12963
12964 let (got, message) = network_authentication_refusal(&credential(" scopes: []\n"));
12965 assert_eq!(got, field);
12966 assert!(message.contains("no scope"), "{message}");
12967
12968 let (got, message) =
12969 network_authentication_refusal(&credential(" scopes: [tasks/list]\n"));
12970 assert_eq!(got, field);
12971 assert!(message.contains("tasks/list"), "{message}");
12972 assert!(message.contains("resources/files"), "{message}");
12973
12974 let (got, message) = network_authentication_refusal(&credential(
12975 " scopes: [agent/getAuthenticatedExtendedCard]\n",
12976 ));
12977 assert_eq!(got, field);
12978 assert!(
12979 message.contains("agent/getAuthenticatedExtendedCard"),
12980 "{message}"
12981 );
12982
12983 let (got, message) = network_authentication_refusal(&credential(
12984 " scopes: [tasks/get, stream/watch, tasks/get]\n",
12985 ));
12986 assert_eq!(got, field);
12987 assert!(message.contains("more than once"), "{message}");
12988 }
12989
12990 #[test]
12991 fn network_authentication_accepts_every_door_scope() {
12992 let auth = network_authentication_of(&format!(
12993 "network:\n authentication:\n scheme: bearer\n credentials:\n \
12994 everything:\n scopes: [{}]\n",
12995 DOOR_SCOPES.join(", ")
12996 ))
12997 .unwrap()
12998 .unwrap();
12999 assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
13000 assert_eq!(OPERATOR_CREDENTIAL, "operator");
13001 }
13002
13003 #[test]
13004 fn network_authentication_is_refused_beside_spawn_allow() {
13005 let (field, message) = network_authentication_refusal(
13006 "network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
13007 allow: [worker]\n",
13008 );
13009 assert_eq!(field, "network.authentication");
13010 assert!(message.contains("capabilities.spawn.allow"), "{message}");
13011 assert!(message.contains("token"), "{message}");
13012 let error = RuntimeManifest::from_yaml_str(
13013 "name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n\
13014 capabilities:\n spawn:\n allow: [worker]\n",
13015 )
13016 .unwrap_err()
13017 .to_string();
13018 assert!(error.contains("network.authentication"), "{error}");
13019 assert!(error.contains("capabilities.spawn.allow"), "{error}");
13020
13021 network_authentication_of(
13023 "network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
13024 allow: []\n",
13025 )
13026 .unwrap();
13027 }
13028
13029 #[test]
13030 fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
13031 let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
13032 "name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n \
13033 schemes: x\n credentials:\n watcher:\n scopes: [tasks/get]\n \
13034 scope: [tasks/get]\n",
13035 )
13036 .unwrap()
13037 .unknown_keys
13038 .into_iter()
13039 .map(|key| (key.key, key.block_path, key.nearest_known))
13040 .collect();
13041 assert_eq!(
13042 keys,
13043 vec![
13044 (
13045 "schemes".to_string(),
13046 "network.authentication".to_string(),
13047 Some("scheme".to_string())
13048 ),
13049 (
13050 "scope".to_string(),
13051 "network.authentication.credentials.watcher".to_string(),
13052 Some("scopes".to_string())
13053 ),
13054 ]
13055 );
13056 }
13057}