1use std::{fs, net::IpAddr, path::Path};
2
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use url::Url;
6
7use crate::{
8 artifact_name::{ArtifactName, ArtifactNameError},
9 build::artifact_name_format_error,
10 manifest::{REGISTRY_BLOCK_KEY, REGISTRY_BLOCK_MESSAGE},
11 manifest_path::MANIFEST_FILENAME,
12 shell_words::split_shell_words,
13 trace_capture::{resolve_trace_capture, TraceCapture},
14 unknown_manifest_keys::{nearest_known_key, UnknownManifestKey},
15};
16
17#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum TaskAcceptance {
22 None,
23 #[default]
24 Single,
25 Queue,
26}
27
28#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum AfterTask {
31 #[default]
32 Exit,
33 Sleep,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
37#[serde(rename_all = "snake_case")]
38pub enum ConversationMode {
39 #[default]
40 Stateless,
41 Threaded,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct LifecycleConfig {
46 pub task_acceptance: TaskAcceptance,
47 pub after_task: AfterTask,
48 pub queue_depth: usize,
49 pub input_timeout_secs: Option<u64>,
52 pub conversation_mode: ConversationMode,
56 pub max_task_reopens: u32,
62 pub shell_grace_secs: Option<u64>,
69 pub delegation_deadline_secs: u64,
83 pub deadline_notice_secs: Option<u64>,
88 pub keep_working: Option<KeepWorking>,
93 pub done_check: Option<DoneCheck>,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct KeepWorking {
105 pub message: String,
107 pub limit: u32,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct DoneCheck {
115 pub command: String,
118 pub limit: u32,
121 pub timeout_secs: u64,
123}
124
125impl DoneCheck {
126 pub const DEFAULT_TIMEOUT_SECS: u64 = 600;
128
129 pub fn argv(&self) -> Vec<String> {
131 split_shell_words(&self.command)
132 }
133}
134
135impl LifecycleConfig {
136 pub fn can_receive_background_tasks(&self) -> bool {
144 self.task_acceptance == TaskAcceptance::Queue && self.after_task == AfterTask::Sleep
145 }
146
147 pub fn queue_has_room(&self) -> bool {
156 !(self.task_acceptance == TaskAcceptance::Queue && self.queue_depth == 0)
157 }
158
159 pub fn tasks_held_at_once(&self) -> usize {
167 match self.task_acceptance {
168 TaskAcceptance::None => 0,
169 TaskAcceptance::Single => 1,
170 TaskAcceptance::Queue => self.queue_depth + 1,
171 }
172 }
173}
174
175impl Default for LifecycleConfig {
176 fn default() -> Self {
177 Self {
178 task_acceptance: TaskAcceptance::Single,
179 after_task: AfterTask::Exit,
180 queue_depth: 1,
181 input_timeout_secs: None,
182 conversation_mode: ConversationMode::Stateless,
183 max_task_reopens: 1,
184 shell_grace_secs: None,
185 delegation_deadline_secs: 600,
186 deadline_notice_secs: None,
187 keep_working: None,
188 done_check: None,
189 }
190 }
191}
192
193#[derive(Debug, Clone, Default)]
196pub struct LifecycleOverride {
197 pub task_acceptance: Option<TaskAcceptance>,
198 pub after_task: Option<AfterTask>,
199}
200
201#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
219#[serde(rename_all = "lowercase")]
220pub enum ArtifactRuntime {
221 Tool,
222 Driver,
223 Hook,
224 Skill,
225}
226
227impl ArtifactRuntime {
228 pub fn is_llm_visible(&self) -> bool {
234 match self {
235 ArtifactRuntime::Tool => true,
236 ArtifactRuntime::Skill => true,
237 ArtifactRuntime::Driver => false,
238 ArtifactRuntime::Hook => false,
239 }
240 }
241
242 pub fn as_str(&self) -> &'static str {
243 match self {
244 ArtifactRuntime::Tool => "tool",
245 ArtifactRuntime::Driver => "driver",
246 ArtifactRuntime::Hook => "hook",
247 ArtifactRuntime::Skill => "skill",
248 }
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
257pub enum ArtifactImplementation {
258 Wasm,
259 Native,
260}
261
262pub fn read_tool_implementation(
265 artifact_manifest_path: &Path,
266) -> Result<ArtifactImplementation, RuntimeManifestError> {
267 let content =
268 fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
269 path: artifact_manifest_path.display().to_string(),
270 source,
271 })?;
272 Ok(parse_tool_implementation_from_yaml(&content))
273}
274
275pub fn parse_tool_implementation_from_yaml(yaml: &str) -> ArtifactImplementation {
278 serde_norway::from_str::<serde_norway::Value>(yaml)
279 .map(|v| {
280 v.as_mapping()
281 .and_then(|m| m.get(serde_norway::Value::String("implementation".to_string())))
282 .and_then(serde_norway::Value::as_str)
283 .map(|s| match s {
284 "native" => ArtifactImplementation::Native,
285 _ => ArtifactImplementation::Wasm,
286 })
287 .unwrap_or(ArtifactImplementation::Wasm)
288 })
289 .unwrap_or(ArtifactImplementation::Wasm)
290}
291
292#[derive(Debug, Clone, PartialEq, Eq)]
299pub enum HookBinding {
300 OnStage,
302 OnSessionStart,
304 OnTaskStart,
306 OnInference,
308 OnToolCall,
310 OnShell,
312 OnCompaction,
314 OnTaskEnd,
316 OnSessionEnd,
318 All,
320}
321
322impl HookBinding {
323 #[must_use]
328 pub fn as_str(&self) -> &'static str {
329 match self {
330 Self::OnStage => "on-stage",
331 Self::OnSessionStart => "on-session-start",
332 Self::OnTaskStart => "on-task-start",
333 Self::OnInference => "on-inference",
334 Self::OnToolCall => "on-tool-call",
335 Self::OnShell => "on-shell",
336 Self::OnCompaction => "on-compaction",
337 Self::OnTaskEnd => "on-task-end",
338 Self::OnSessionEnd => "on-session-end",
339 Self::All => "all",
340 }
341 }
342}
343
344#[derive(Debug, Clone, PartialEq, Eq)]
349pub enum HookExecutionMode {
350 Blocking,
352 Async,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq)]
361pub enum HookCommitPolicy {
362 None,
364 ReplaceContext,
366 WriteManifests,
369 ReopenTask,
372 SeedContext,
376 Deny,
384}
385
386impl HookCommitPolicy {
387 #[must_use]
389 pub fn as_str(&self) -> &'static str {
390 match self {
391 Self::None => "none",
392 Self::ReplaceContext => "replace-context",
393 Self::WriteManifests => "write-manifests",
394 Self::ReopenTask => "reopen-task",
395 Self::SeedContext => "seed-context",
396 Self::Deny => "deny",
397 }
398 }
399}
400
401#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
411#[serde(rename_all = "snake_case")]
412pub enum HookOverflowPolicy {
413 #[default]
417 Drop,
418 Block,
421}
422
423impl HookOverflowPolicy {
424 #[must_use]
426 pub fn as_str(&self) -> &'static str {
427 match self {
428 Self::Drop => "drop",
429 Self::Block => "block",
430 }
431 }
432}
433
434#[derive(Debug, Clone, PartialEq, Eq)]
436pub struct HookConfig {
437 pub binding: HookBinding,
438 pub execution_mode: HookExecutionMode,
439 pub commit_policy: HookCommitPolicy,
440}
441
442impl Default for HookConfig {
443 fn default() -> Self {
444 Self {
445 binding: HookBinding::All,
446 execution_mode: HookExecutionMode::Blocking,
447 commit_policy: HookCommitPolicy::None,
448 }
449 }
450}
451
452#[must_use]
476pub fn commit_policy_for_binding(binding: &HookBinding) -> Option<HookCommitPolicy> {
477 match binding {
478 HookBinding::OnStage => Some(HookCommitPolicy::WriteManifests),
479 HookBinding::OnCompaction => Some(HookCommitPolicy::ReplaceContext),
480 HookBinding::OnTaskEnd => Some(HookCommitPolicy::ReopenTask),
481 HookBinding::OnTaskStart => Some(HookCommitPolicy::SeedContext),
482 HookBinding::OnToolCall | HookBinding::OnShell => Some(HookCommitPolicy::Deny),
483 HookBinding::OnSessionStart
484 | HookBinding::OnInference
485 | HookBinding::OnSessionEnd
486 | HookBinding::All => None,
487 }
488}
489
490pub fn read_hook_config(artifact_manifest_path: &Path) -> Result<HookConfig, RuntimeManifestError> {
492 let content =
493 fs::read_to_string(artifact_manifest_path).map_err(|source| RuntimeManifestError::Io {
494 path: artifact_manifest_path.display().to_string(),
495 source,
496 })?;
497 parse_hook_config_from_yaml(&content).map_err(|msg| {
498 RuntimeManifestError::YamlSyntax(format!("{}: {msg}", artifact_manifest_path.display()))
499 })
500}
501
502pub fn parse_hook_config_from_yaml(yaml: &str) -> Result<HookConfig, String> {
512 let v: serde_norway::Value =
513 serde_norway::from_str(yaml).map_err(|e| format!("YAML parse error: {e}"))?;
514
515 let binding = v
516 .as_mapping()
517 .and_then(|m| m.get(serde_norway::Value::String("binding".to_string())))
518 .and_then(serde_norway::Value::as_str)
519 .map(|s| match s {
520 "on-stage" => Ok(HookBinding::OnStage),
521 "on-session-start" => Ok(HookBinding::OnSessionStart),
522 "on-task-start" => Ok(HookBinding::OnTaskStart),
523 "on-inference" => Ok(HookBinding::OnInference),
524 "on-tool-call" => Ok(HookBinding::OnToolCall),
525 "on-shell" => Ok(HookBinding::OnShell),
526 "on-compaction" => Ok(HookBinding::OnCompaction),
527 "on-task-end" => Ok(HookBinding::OnTaskEnd),
528 "on-session-end" => Ok(HookBinding::OnSessionEnd),
529 other => Err(format!(
530 "unknown binding '{other}'; expected: on-stage, on-session-start, on-task-start, \
531 on-inference, on-tool-call, on-shell, on-compaction, on-task-end, on-session-end"
532 )),
533 })
534 .transpose()?
535 .unwrap_or(HookBinding::All);
536
537 let execution_mode = v
538 .as_mapping()
539 .and_then(|m| m.get(serde_norway::Value::String("execution_mode".to_string())))
540 .and_then(serde_norway::Value::as_str)
541 .map(|s| match s {
542 "blocking" => Ok(HookExecutionMode::Blocking),
543 "async" => Ok(HookExecutionMode::Async),
544 other => Err(format!(
545 "unknown execution_mode '{other}'; expected: blocking, async"
546 )),
547 })
548 .transpose()?
549 .unwrap_or(HookExecutionMode::Blocking);
550
551 let commit_policy = v
552 .as_mapping()
553 .and_then(|m| m.get(serde_norway::Value::String("commit_policy".to_string())))
554 .and_then(serde_norway::Value::as_str)
555 .map(|s| match s {
556 "none" => Ok(HookCommitPolicy::None),
557 "replace-context" => Ok(HookCommitPolicy::ReplaceContext),
558 "write-manifests" => Ok(HookCommitPolicy::WriteManifests),
559 "reopen-task" => Ok(HookCommitPolicy::ReopenTask),
560 "seed-context" => Ok(HookCommitPolicy::SeedContext),
561 "deny" => Ok(HookCommitPolicy::Deny),
562 other => Err(format!(
563 "unknown commit_policy '{other}'; expected: none, replace-context, write-manifests, \
564 reopen-task, seed-context, deny"
565 )),
566 })
567 .transpose()?
568 .unwrap_or(HookCommitPolicy::None);
569
570 if execution_mode == HookExecutionMode::Async && commit_policy != HookCommitPolicy::None {
572 return Err(format!(
573 "async-with-commit not supported: execution_mode 'async' requires commit_policy 'none' \
574 (got '{}')",
575 commit_policy.as_str()
576 ));
577 }
578 if binding == HookBinding::OnStage && execution_mode == HookExecutionMode::Async {
579 return Err(
580 "on-stage hooks must be blocking; execution_mode 'async' is not valid for binding \
581 'on-stage'"
582 .to_string(),
583 );
584 }
585 if binding == HookBinding::All && commit_policy == HookCommitPolicy::Deny {
589 return Err(
590 "commit_policy 'deny' requires an explicit binding: 'on-shell' or 'on-tool-call'; \
591 a hook with no binding: is dispatched at every event, including decision points \
592 it was not written to decide"
593 .to_string(),
594 );
595 }
596 if binding != HookBinding::All && commit_policy != HookCommitPolicy::None {
601 let honored = commit_policy_for_binding(&binding);
602 if honored.as_ref() != Some(&commit_policy) {
603 let note = if binding == HookBinding::OnInference {
604 " (on-inference commits an 'artifact' output, which has no commit_policy spelling)"
605 } else {
606 ""
607 };
608 return Err(format!(
609 "commit_policy '{}' is not valid for binding '{}'; binding '{}' honors \
610 commit_policy '{}'{note}",
611 commit_policy.as_str(),
612 binding.as_str(),
613 binding.as_str(),
614 honored.as_ref().map_or("none", HookCommitPolicy::as_str)
615 ));
616 }
617 }
618
619 Ok(HookConfig {
620 binding,
621 execution_mode,
622 commit_policy,
623 })
624}
625
626#[derive(Debug, Clone, PartialEq, Eq)]
627pub struct RuntimeManifest {
628 pub name: String,
629 pub version: String,
630 pub artifacts: Vec<RuntimeArtifact>,
631 pub capabilities: Option<Capabilities>,
632 pub inference: Option<InferenceConfig>,
633 pub context: Option<ContextConfig>,
634 pub observability: Option<ObservabilityConfig>,
635 pub trace: Option<TraceConfig>,
636 pub network: Option<NetworkConfig>,
637 pub lifecycle: Option<LifecycleConfig>,
638 pub exports: Option<Exports>,
641 pub control: Option<ControlConfig>,
645 pub deploy: Option<DeployConfig>,
647 pub unknown_keys: Vec<UnknownManifestKey>,
657}
658
659impl RuntimeManifest {
660 pub fn effective_lifecycle(&self) -> LifecycleConfig {
661 self.lifecycle.clone().unwrap_or_default()
662 }
663
664 pub fn accepts_peer_tasks(&self) -> bool {
671 self.exports
672 .as_ref()
673 .is_some_and(Exports::accepts_peer_tasks)
674 }
675
676 pub fn deploy_mur_version(&self) -> Option<&str> {
679 self.deploy.as_ref()?.mur_version.as_deref()
680 }
681}
682
683#[derive(Debug, Clone, Default, PartialEq, Eq)]
685pub struct DeployConfig {
686 pub mur_version: Option<String>,
690}
691
692#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
699pub enum ControllableSetting {
700 InferenceMaxTokens,
703 InferenceDriver,
707}
708
709impl ControllableSetting {
710 pub const ALL: &'static [ControllableSetting] =
712 &[Self::InferenceMaxTokens, Self::InferenceDriver];
713
714 pub const AGENT_SETTABLE: &'static [ControllableSetting] = &[Self::InferenceDriver];
717
718 pub fn wire_name(self) -> &'static str {
720 match self {
721 Self::InferenceMaxTokens => "inference.max_tokens",
722 Self::InferenceDriver => "inference.driver",
723 }
724 }
725
726 pub fn from_wire_name(name: &str) -> Option<Self> {
728 Self::ALL
729 .iter()
730 .copied()
731 .find(|setting| setting.wire_name() == name)
732 }
733}
734
735#[derive(Debug, Clone, PartialEq, Eq, Default)]
740pub struct ControlConfig {
741 pub settings: Vec<ControllableSetting>,
743 pub secrets: Vec<String>,
747 pub agent_settings: Vec<ControllableSetting>,
751}
752
753impl ControlConfig {
754 pub fn declares_secret(&self, name: &str) -> bool {
756 self.secrets.iter().any(|secret| secret == name)
757 }
758
759 pub fn controller_may_set(&self, setting: ControllableSetting) -> bool {
761 self.settings.contains(&setting)
762 }
763
764 pub fn agent_may_set(&self, setting: ControllableSetting) -> bool {
766 self.agent_settings.contains(&setting)
767 }
768}
769
770#[derive(Debug, Clone, PartialEq, Eq)]
773pub struct NetworkConfig {
774 pub internal_port: Option<u16>,
776 pub authentication: Option<NetworkAuthentication>,
779}
780
781pub const DOOR_SCOPES: &[&str] = &[
785 "SendMessage",
786 "SendStreamingMessage",
787 "murmur.WatchSession",
788 "GetTask",
789 "ListTasks",
790 "CancelTask",
791 "SubscribeToTask",
792 "murmur.StopSession",
793 "resources/files",
794];
795
796pub const RETIRED_DOOR_SCOPES: &[(&str, &str)] = &[
805 ("message/send", "SendMessage"),
806 ("message/stream", "SendStreamingMessage"),
807 ("tasks/get", "GetTask"),
808 ("tasks/cancel", "CancelTask"),
809 ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
810 ("stream/watch", "murmur.WatchSession"),
811 ("session/stop", "murmur.StopSession"),
812];
813
814pub const OPERATOR_CREDENTIAL: &str = "operator";
817
818const MAX_CREDENTIAL_NAME_LEN: usize = 32;
820
821#[derive(Debug, Clone, Copy, PartialEq, Eq)]
825pub enum AuthenticationScheme {
826 Bearer,
828}
829
830impl AuthenticationScheme {
831 pub const ALL: &'static [AuthenticationScheme] = &[Self::Bearer];
833
834 pub fn wire_name(self) -> &'static str {
836 match self {
837 Self::Bearer => "bearer",
838 }
839 }
840}
841
842#[derive(Debug, Clone, PartialEq, Eq)]
845pub struct DoorCredential {
846 pub name: String,
848 pub scopes: Vec<String>,
850}
851
852#[derive(Debug, Clone, PartialEq, Eq)]
855pub struct NetworkAuthentication {
856 pub scheme: AuthenticationScheme,
857 pub credentials: Vec<DoorCredential>,
859}
860
861pub const DEFAULT_EXPORT_MAX_BYTES: u64 = 10 * 1024 * 1024;
865
866#[derive(Debug, Clone, PartialEq, Eq)]
874pub struct Exports {
875 pub files: Option<FileExport>,
878 pub peer_files: Option<PeerFilesExport>,
883 pub peer_tasks: Option<PeerTasksExport>,
887}
888
889impl Exports {
890 pub fn accepts_peer_tasks(&self) -> bool {
896 self.peer_tasks.is_some_and(|peer_tasks| peer_tasks.accept)
897 }
898}
899
900#[derive(Debug, Clone, Copy, PartialEq, Eq)]
911pub struct PeerTasksExport {
912 pub accept: bool,
914}
915
916#[derive(Debug, Clone, PartialEq, Eq)]
918pub struct FileExport {
919 pub root: String,
926 pub mode: ExportMode,
930 pub max_bytes: u64,
934}
935
936pub const DEFAULT_PEER_HANDLE_TTL_SECS: u64 = 3600;
939
940pub const PERSISTENT_PEER_HANDLE_TTL_CEILING_SECS: u64 = 900;
949
950pub const DEFAULT_PEER_FILES_MAX_BYTES: u64 = 10 * 1024 * 1024;
952
953#[derive(Debug, Clone, PartialEq, Eq)]
960pub struct PeerFilesExport {
961 pub root: String,
965 pub max_ttl_secs: Option<u64>,
972 pub max_bytes: u64,
976}
977
978impl PeerFilesExport {
979 pub fn effective_max_ttl_secs(&self) -> u64 {
985 self.max_ttl_secs.unwrap_or(DEFAULT_PEER_HANDLE_TTL_SECS)
986 }
987}
988
989#[derive(Debug, Clone, PartialEq, Eq)]
997pub struct PeerFetchCapabilities {
998 pub allow: Vec<String>,
1002}
1003
1004pub const DURATION_ACCEPTED_FORM: &str =
1007 "must be a duration: an integer, optionally suffixed s/m/h/d";
1008
1009pub fn parse_duration_secs(input: &str) -> Result<u64, String> {
1015 let trimmed = input.trim();
1016 let (digits, multiplier) = match trimmed.strip_suffix('s') {
1017 Some(digits) => (digits, 1u64),
1018 None => match trimmed.strip_suffix('m') {
1019 Some(digits) => (digits, 60),
1020 None => match trimmed.strip_suffix('h') {
1021 Some(digits) => (digits, 3600),
1022 None => match trimmed.strip_suffix('d') {
1023 Some(digits) => (digits, 86_400),
1024 None => (trimmed, 1),
1025 },
1026 },
1027 },
1028 };
1029 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
1032 return Err(format!("'{input}' {DURATION_ACCEPTED_FORM}"));
1033 }
1034 digits
1035 .parse::<u64>()
1036 .ok()
1037 .and_then(|value| value.checked_mul(multiplier))
1038 .ok_or_else(|| format!("'{input}' overflows a 64-bit second count"))
1039}
1040
1041#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1042pub enum ExportMode {
1043 #[serde(rename = "read-only")]
1044 ReadOnly,
1045}
1046
1047impl ExportMode {
1048 pub fn as_str(self) -> &'static str {
1049 match self {
1050 ExportMode::ReadOnly => "read-only",
1051 }
1052 }
1053}
1054
1055impl std::fmt::Display for ExportMode {
1056 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1057 f.write_str(self.as_str())
1058 }
1059}
1060
1061pub const BYTE_SIZE_ACCEPTED_FORM: &str = "must be a byte count, optionally suffixed Ki/Mi/Gi";
1064
1065pub fn parse_byte_size(input: &str) -> Result<u64, String> {
1071 let trimmed = input.trim();
1072 let (digits, multiplier) = match trimmed.strip_suffix("Ki") {
1073 Some(digits) => (digits, 1024u64),
1074 None => match trimmed.strip_suffix("Mi") {
1075 Some(digits) => (digits, 1024 * 1024),
1076 None => match trimmed.strip_suffix("Gi") {
1077 Some(digits) => (digits, 1024 * 1024 * 1024),
1078 None => (trimmed, 1),
1079 },
1080 },
1081 };
1082 if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
1085 return Err(format!("'{input}' {BYTE_SIZE_ACCEPTED_FORM}"));
1086 }
1087 digits
1088 .parse::<u64>()
1089 .ok()
1090 .and_then(|value| value.checked_mul(multiplier))
1091 .ok_or_else(|| format!("'{input}' overflows a 64-bit byte count"))
1092}
1093
1094#[derive(Debug, Clone, PartialEq, Eq)]
1095pub struct RuntimeArtifact {
1096 pub name: String,
1097 pub version: String,
1098 pub runtime: ArtifactRuntime,
1099 pub source: Option<String>,
1105 pub local_source: bool,
1110 pub prompt_payload: bool,
1115 pub capabilities: Option<Capabilities>,
1134 pub on_overflow: HookOverflowPolicy,
1139 pub config: Option<serde_norway::Value>,
1156 pub gateway: Option<ArtifactGateway>,
1167}
1168
1169impl RuntimeArtifact {
1170 #[must_use]
1174 pub fn tool_name(&self) -> &str {
1175 tool_name_of(&self.name)
1176 }
1177
1178 pub fn artifact_name(&self) -> Result<ArtifactName, ArtifactNameError> {
1180 ArtifactName::parse(&self.name)
1181 }
1182}
1183
1184#[must_use]
1187pub fn tool_name_of(name: &str) -> &str {
1188 name.split_once('/').map_or(name, |(_, bare)| bare)
1189}
1190
1191#[derive(Debug, Clone, PartialEq, Eq)]
1195pub struct ArtifactGateway {
1196 pub endpoint: String,
1199 pub api_key: Option<ApiKeyReference>,
1202 pub keyless: bool,
1206}
1207
1208#[derive(Debug, Clone, PartialEq, Eq)]
1209pub struct InferenceDriver {
1210 pub artifact: String,
1211 pub config: Option<String>,
1212}
1213
1214pub const PRIMARY_DRIVER_CHOICE: &str = "primary";
1217
1218#[derive(Debug, Clone, PartialEq, Eq)]
1224pub struct InferenceAlternate {
1225 pub name: String,
1227 pub model: String,
1229 pub driver: String,
1232}
1233
1234#[derive(Clone, PartialEq, Eq)]
1237pub enum ApiKeyReference {
1238 Environment(String),
1241 Literal(String),
1243}
1244
1245impl std::fmt::Debug for ApiKeyReference {
1246 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1247 match self {
1248 Self::Environment(name) => f.debug_tuple("Environment").field(name).finish(),
1249 Self::Literal(_) => f.debug_tuple("Literal").field(&"<redacted>").finish(),
1250 }
1251 }
1252}
1253
1254#[derive(Debug, Clone, PartialEq, Eq)]
1255pub struct InferenceConfig {
1256 pub transport: String,
1257 pub model: String,
1258 pub driver: Option<InferenceDriver>,
1261 pub command: Option<String>,
1265 pub compaction: Option<CompactionConfig>,
1266 pub system_prompt: Option<String>,
1267 pub system_prompt_file: Option<String>,
1268 pub system_prompt_artifact: Option<String>,
1272 pub instruction_files: Option<Vec<String>>,
1278 pub max_turns: Option<u32>,
1281 pub max_tokens: Option<u32>,
1289 pub max_session_tokens: Option<u64>,
1297 pub tool_refresh: ToolRefresh,
1301 pub alternates: Vec<InferenceAlternate>,
1305 pub retry: InferenceRetry,
1309}
1310
1311#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1314pub struct InferenceRetry {
1315 pub max_retries: u32,
1317 pub initial_delay_ms: u32,
1319 pub max_delay_ms: u32,
1322}
1323
1324impl InferenceRetry {
1325 pub const MAX_RETRIES: u32 = 20;
1327 pub const MAX_INITIAL_DELAY_MS: u32 = 60_000;
1329 pub const MAX_DELAY_MS: u32 = 3_600_000;
1331}
1332
1333impl Default for InferenceRetry {
1334 fn default() -> Self {
1335 Self {
1336 max_retries: 6,
1337 initial_delay_ms: 1_000,
1338 max_delay_ms: 60_000,
1339 }
1340 }
1341}
1342
1343#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1350pub enum ToolRefresh {
1351 #[default]
1354 Compaction,
1355 Immediate,
1357}
1358
1359impl ToolRefresh {
1360 pub const ALL: [ToolRefresh; 2] = [Self::Compaction, Self::Immediate];
1362
1363 #[must_use]
1365 pub fn wire_name(self) -> &'static str {
1366 match self {
1367 Self::Compaction => "compaction",
1368 Self::Immediate => "immediate",
1369 }
1370 }
1371}
1372
1373#[derive(Debug, Clone, PartialEq)]
1374pub struct CompactionConfig {
1375 pub threshold: Option<f32>,
1377 pub model: Option<String>,
1379 pub system_prompt: Option<String>,
1382 pub system_prompt_file: Option<String>,
1386 pub dump_summaries: Option<bool>,
1391 pub max_tokens: Option<u32>,
1396 pub driver_config: Option<String>,
1400}
1401
1402impl Eq for CompactionConfig {}
1404
1405pub const DEFAULT_SEED_BUDGET: f32 = 0.10;
1409
1410pub const DEFAULT_SEED_OVERFLOW_MARGIN: f32 = 0.10;
1414
1415#[derive(Debug, Clone, PartialEq)]
1416pub struct ContextConfig {
1417 pub max_tokens: Option<u32>,
1421 pub record: bool,
1425 pub record_store: Option<String>,
1429 pub seed_budget: f32,
1434 pub seed_overflow_margin: f32,
1439 pub retain: Option<ContextRetainConfig>,
1442 pub images: ContextImagesConfig,
1445}
1446
1447pub const DEFAULT_IMAGES_KEEP: u32 = 5;
1449pub const DEFAULT_IMAGES_MAX_BYTES: u64 = 5 * 1024 * 1024;
1451pub const DEFAULT_IMAGES_MAX_PER_TURN: u32 = 5;
1453
1454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1459pub struct ContextImagesConfig {
1460 pub keep: u32,
1464 pub max_bytes: u64,
1466 pub max_per_turn: u32,
1469}
1470
1471impl Default for ContextImagesConfig {
1472 fn default() -> Self {
1473 Self {
1474 keep: DEFAULT_IMAGES_KEEP,
1475 max_bytes: DEFAULT_IMAGES_MAX_BYTES,
1476 max_per_turn: DEFAULT_IMAGES_MAX_PER_TURN,
1477 }
1478 }
1479}
1480
1481impl Eq for ContextConfig {}
1483
1484#[derive(Debug, Clone, PartialEq, Eq)]
1485pub struct ObservabilityConfig {
1486 pub otel_endpoint: Option<String>,
1487 pub eval: Option<EvalConfig>,
1488}
1489
1490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1491pub struct TraceConfig {
1492 pub capture: TraceCapture,
1495 pub retain: Option<TraceRetainConfig>,
1500}
1501
1502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1508pub struct TraceRetainConfig {
1509 pub max_sessions: Option<u32>,
1513 pub max_age_secs: Option<u64>,
1516}
1517
1518#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1524pub struct ContextRetainConfig {
1525 pub max_messages: Option<u32>,
1528 pub max_age_secs: Option<u64>,
1531}
1532
1533#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1534pub struct EvalConfig {
1535 #[serde(skip_serializing_if = "Option::is_none")]
1536 pub dataset_id: Option<String>,
1537 pub scorers: Vec<ScorerConfig>,
1538}
1539
1540#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1541#[serde(tag = "type", rename_all = "snake_case")]
1542pub enum ScorerConfig {
1543 ExitOk { name: String },
1544 MaxTurns { name: String, max: u32 },
1545 MaxTokens { name: String, max: u64 },
1546 ToolSequence { name: String, expected: Vec<String> },
1547 LlmJudge { name: String },
1548}
1549
1550#[derive(Debug, Clone, PartialEq, Eq)]
1551pub struct NetworkCapabilities {
1552 pub allow: Vec<String>,
1553 pub unix_sockets: bool,
1561}
1562
1563#[derive(Debug, Clone, PartialEq, Eq)]
1564pub struct FilesystemCapabilities {
1565 pub scope: Option<String>,
1566 pub workdir_exec: bool,
1581 pub read_only: Vec<String>,
1588 pub records: bool,
1593}
1594
1595#[derive(Debug, Clone, PartialEq, Eq)]
1600pub struct TaskIoCapabilities {
1601 pub read: bool,
1605}
1606
1607#[derive(Debug, Clone, PartialEq, Eq)]
1612pub struct ConversationCapabilities {
1613 pub read: bool,
1616}
1617
1618#[derive(Debug, Clone, PartialEq, Eq)]
1622pub struct PlanCapabilities {
1623 pub submit: bool,
1626}
1627
1628#[derive(Debug, Clone, PartialEq, Eq)]
1635pub struct InstallCapabilities {
1636 pub skill: Vec<String>,
1638 pub tool: Vec<String>,
1640 pub from: InstallFrom,
1642}
1643
1644#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1647pub enum InstallFrom {
1648 #[default]
1650 Stores,
1651 StoresAndSources,
1654}
1655
1656impl InstallFrom {
1657 #[must_use]
1659 pub fn sources(self) -> bool {
1660 self == Self::StoresAndSources
1661 }
1662
1663 #[must_use]
1665 pub fn as_list(self) -> &'static str {
1666 match self {
1667 Self::Stores => "stores",
1668 Self::StoresAndSources => "stores, sources",
1669 }
1670 }
1671}
1672
1673#[derive(Debug, Clone, PartialEq, Eq)]
1674pub struct ShellCapabilities {
1675 pub allow: Vec<String>,
1676 pub strip_env: Option<Vec<String>>,
1677 pub baseline_env: Option<Vec<String>>,
1678 pub interpreter_runtime: Vec<InterpreterRuntimeGrant>,
1685 pub staged_runtime: Vec<StagedRuntimeGrant>,
1691}
1692
1693#[derive(Debug, Clone, PartialEq, Eq)]
1698pub struct InterpreterRuntimeGrant {
1699 pub binary: String,
1703 pub dirs: Vec<InterpreterRuntimeDir>,
1706}
1707
1708#[derive(Debug, Clone, PartialEq, Eq)]
1710pub struct InterpreterRuntimeDir {
1711 pub path: String,
1713 pub list_dir: bool,
1718}
1719
1720#[derive(Debug, Clone, PartialEq, Eq)]
1737pub struct StagedRuntimeGrant {
1738 pub binary: String,
1742 pub source_path: String,
1746 pub pin: String,
1751}
1752
1753#[derive(Debug, Clone, PartialEq, Eq)]
1754pub struct SpawnCapabilities {
1755 pub allow: Vec<String>,
1756 pub workdir_snapshot: Vec<String>,
1761}
1762
1763#[derive(Debug, Clone, PartialEq, Eq, Default)]
1768pub struct EnvCapabilities {
1769 pub allow: Vec<String>,
1770}
1771
1772#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1780pub struct ResourceLimits {
1781 pub memory_bytes: Option<usize>,
1783 pub table_elements: Option<usize>,
1785 pub instances: Option<usize>,
1787 pub deadline_seconds: Option<u64>,
1789}
1790
1791#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1805pub struct ResourceCapabilities {
1806 pub max_processes: Option<u64>,
1811 pub max_open_files: Option<u64>,
1813 pub max_file_size_bytes: Option<u64>,
1815 pub cpu_seconds: Option<u64>,
1818 pub memory_bytes: Option<u64>,
1821 pub cgroup_memory_bytes: Option<u64>,
1823 pub cgroup_pids_max: Option<u64>,
1825 pub cgroup_cpu_percent: Option<u32>,
1827 pub cgroup_io_bytes_per_sec: Option<u64>,
1830 pub workdir_max_bytes: Option<u64>,
1832}
1833
1834#[derive(Debug, Clone, PartialEq, Eq, Default)]
1841pub struct StateCapabilities {
1842 pub store: Option<String>,
1850}
1851
1852#[derive(
1865 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Deserialize, Serialize,
1866)]
1867#[serde(rename_all = "snake_case")]
1868pub enum ContainmentClass {
1869 Unconfined,
1876 #[default]
1880 Advisory,
1881 Scoped,
1885 Sealed,
1893}
1894
1895impl ContainmentClass {
1896 pub fn as_str(self) -> &'static str {
1899 match self {
1900 Self::Unconfined => "unconfined",
1901 Self::Advisory => "advisory",
1902 Self::Scoped => "scoped",
1903 Self::Sealed => "sealed",
1904 }
1905 }
1906
1907 pub const ALL: [ContainmentClass; 4] =
1909 [Self::Unconfined, Self::Advisory, Self::Scoped, Self::Sealed];
1910
1911 pub const DECLARABLE: [ContainmentClass; 3] = [Self::Advisory, Self::Scoped, Self::Sealed];
1914}
1915
1916impl std::fmt::Display for ContainmentClass {
1917 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1918 f.write_str(self.as_str())
1919 }
1920}
1921
1922#[derive(Debug, Clone, PartialEq, Eq)]
1924pub struct ParseContainmentClassError {
1925 pub value: String,
1926}
1927
1928impl std::fmt::Display for ParseContainmentClassError {
1929 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1930 let names: Vec<&str> = ContainmentClass::DECLARABLE
1931 .iter()
1932 .map(|class| class.as_str())
1933 .collect();
1934 write!(
1935 f,
1936 "must be one of: {}; got '{}'",
1937 names.join(", "),
1938 self.value
1939 )
1940 }
1941}
1942
1943impl std::error::Error for ParseContainmentClassError {}
1944
1945impl std::str::FromStr for ContainmentClass {
1946 type Err = ParseContainmentClassError;
1947
1948 fn from_str(value: &str) -> Result<Self, Self::Err> {
1949 match value.trim() {
1950 "unconfined" => Ok(Self::Unconfined),
1951 "advisory" => Ok(Self::Advisory),
1952 "scoped" => Ok(Self::Scoped),
1953 "sealed" => Ok(Self::Sealed),
1954 other => Err(ParseContainmentClassError {
1955 value: other.to_string(),
1956 }),
1957 }
1958 }
1959}
1960
1961pub const UNCONFINED_NOT_DECLARABLE: &str = "unconfined is not a declarable floor; only the operator chooses it, with `mur run --unconfined` or `containment: unconfined` in ~/.murmur/config.yaml";
1963
1964pub fn effective_containment_floor(
1972 workspace_default: Option<ContainmentClass>,
1973 manifest_declared: Option<ContainmentClass>,
1974 cli_flag: Option<ContainmentClass>,
1975) -> ContainmentClass {
1976 [workspace_default, manifest_declared, cli_flag]
1977 .into_iter()
1978 .flatten()
1979 .max()
1980 .unwrap_or_default()
1981}
1982
1983#[derive(Debug, Clone, PartialEq, Eq)]
1984pub struct Capabilities {
1985 pub network: Option<NetworkCapabilities>,
1986 pub peer_fetch: Option<PeerFetchCapabilities>,
1989 pub filesystem: Option<FilesystemCapabilities>,
1990 pub shell: Option<ShellCapabilities>,
1991 pub spawn: Option<SpawnCapabilities>,
1992 pub env: Option<EnvCapabilities>,
1993 pub limits: Option<ResourceLimits>,
1994 pub resources: Option<ResourceCapabilities>,
1995 pub state: Option<StateCapabilities>,
1998 pub task_io: Option<TaskIoCapabilities>,
2001 pub conversation: Option<ConversationCapabilities>,
2004 pub plan: Option<PlanCapabilities>,
2007 pub install: Option<InstallCapabilities>,
2010 pub containment: Option<ContainmentClass>,
2014}
2015
2016#[derive(Debug, Error)]
2017pub enum RuntimeManifestError {
2018 #[error("{} not found at {}", MANIFEST_FILENAME, .0)]
2019 NotFound(String),
2020 #[error("{0}")]
2021 YamlSyntax(String),
2022 #[error("{}: missing required field '{field}'", MANIFEST_FILENAME)]
2023 MissingField { field: String },
2024 #[error(
2025 "{}: invalid artifact declaration at index {index}: {message}",
2026 MANIFEST_FILENAME
2027 )]
2028 InvalidArtifact { index: usize, message: String },
2029 #[error(
2030 "{}: invalid inference config for '{field}': {message}",
2031 MANIFEST_FILENAME
2032 )]
2033 InvalidInferenceConfig { field: String, message: String },
2034 #[error(
2035 "{}: invalid capability config for '{field}': {message}",
2036 MANIFEST_FILENAME
2037 )]
2038 InvalidCapabilities { field: String, message: String },
2039 #[error(
2042 "{}: invalid capability config for 'capabilities.shell.allow[{index}]': '{}' {reason}; \
2043 an entry is a bare binary name",
2044 MANIFEST_FILENAME,
2045 entry.escape_debug()
2046 )]
2047 ShellAllowEntry {
2048 index: usize,
2049 entry: String,
2050 reason: &'static str,
2051 },
2052 #[error(
2053 "{}: invalid exports config for '{field}': {message}",
2054 MANIFEST_FILENAME
2055 )]
2056 InvalidExports { field: String, message: String },
2057 #[error(
2058 "{}: invalid control config for '{field}': {message}",
2059 MANIFEST_FILENAME
2060 )]
2061 InvalidControl { field: String, message: String },
2062 #[error(
2063 "{}: invalid network config for '{field}': {message}",
2064 MANIFEST_FILENAME
2065 )]
2066 InvalidNetworkConfig { field: String, message: String },
2067 #[error(
2070 "{}: invalid network config for '{field}': '{scope}' is a retired name of a door \
2071 method, and the door answers only the names its card lists",
2072 MANIFEST_FILENAME
2073 )]
2074 RetiredDoorScope {
2075 field: String,
2076 scope: String,
2077 replacement: &'static str,
2078 },
2079 #[error(
2082 "{}: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves task_acceptance: \
2083 queue no room for a waiting task",
2084 MANIFEST_FILENAME
2085 )]
2086 QueueWithoutRoom,
2087 #[error(
2090 "{}: 'mur_version' is not a top-level key — the mur release a deploy installs is pinned \
2091 as 'deploy.mur_version'",
2092 MANIFEST_FILENAME
2093 )]
2094 TopLevelMurVersion,
2095 #[error("{}: invalid trace config for '{field}': {message}", MANIFEST_FILENAME)]
2096 InvalidTraceConfig { field: String, message: String },
2097 #[error(
2099 "{}: invalid lifecycle config for '{field}': {message}",
2100 MANIFEST_FILENAME
2101 )]
2102 InvalidLifecycleConfig { field: String, message: String },
2103 #[error("{}: invalid artifact name in '{field}': {message}", MANIFEST_FILENAME)]
2106 InvalidArtifactName {
2107 field: String,
2108 message: String,
2109 hint: Option<String>,
2110 },
2111 #[error("{}", REGISTRY_BLOCK_MESSAGE)]
2113 RegistryBlock,
2114 #[error(
2118 "{}: '{field}' cannot be declared in a manifest — host folders are lent by the operator, \
2119 never asked for by a capsule",
2120 MANIFEST_FILENAME
2121 )]
2122 HostPathsDeclared { field: String },
2123 #[error(
2127 "{}: invalid artifact declaration at index {index}: artifact '{name}' declares \
2128 gateway.endpoint '{endpoint}' but binds no credential",
2129 MANIFEST_FILENAME
2130 )]
2131 GatewayWithoutCredential {
2132 index: usize,
2133 name: String,
2134 endpoint: String,
2135 },
2136 #[error("failed to read {} at {path}: {source}", MANIFEST_FILENAME)]
2137 Io {
2138 path: String,
2139 #[source]
2140 source: std::io::Error,
2141 },
2142}
2143
2144#[derive(Debug, Deserialize)]
2145struct RawRuntimeManifest {
2146 name: Option<String>,
2147 version: Option<String>,
2148 #[serde(default)]
2149 artifacts: Vec<RawArtifact>,
2150 #[serde(default)]
2151 capabilities: Option<RawCapabilities>,
2152 #[serde(default)]
2153 inference: Option<RawInferenceConfig>,
2154 #[serde(default)]
2155 context: Option<RawContextConfig>,
2156 #[serde(default)]
2157 observability: Option<RawObservabilityConfig>,
2158 #[serde(default)]
2159 trace: Option<RawTraceConfig>,
2160 #[serde(default)]
2161 network: Option<RawNetworkConfig>,
2162 #[serde(default)]
2163 lifecycle: Option<RawLifecycleConfig>,
2164 #[serde(default)]
2165 exports: Option<RawExports>,
2166 #[serde(default)]
2167 control: Option<RawControl>,
2168 #[serde(default)]
2169 deploy: Option<RawDeploy>,
2170 #[serde(default, deserialize_with = "deserialize_present")]
2174 mur_version: Option<serde_norway::Value>,
2175 #[serde(default, deserialize_with = "deserialize_present")]
2179 config: Option<serde_norway::Value>,
2180 #[serde(default)]
2185 #[allow(dead_code)]
2186 runtime: Option<serde_norway::Value>,
2187 #[serde(default)]
2191 #[allow(dead_code)]
2192 implementation: Option<serde_norway::Value>,
2193 #[serde(default)]
2197 #[allow(dead_code)]
2198 execution: Option<serde_norway::Value>,
2199 #[serde(default)]
2202 #[allow(dead_code)]
2203 requires_files: Option<serde_norway::Value>,
2204 #[serde(flatten)]
2205 unknown: UnknownKeys,
2206}
2207
2208#[derive(Debug, Deserialize)]
2209struct RawDeploy {
2210 #[serde(default)]
2211 mur_version: Option<String>,
2212 #[serde(flatten)]
2213 unknown: UnknownKeys,
2214}
2215
2216#[derive(Debug, Deserialize)]
2217struct RawControl {
2218 #[serde(default)]
2222 settings: Option<serde_norway::Value>,
2223 #[serde(default)]
2224 secrets: Option<serde_norway::Value>,
2225 #[serde(default)]
2226 agent_settings: Option<serde_norway::Value>,
2227 #[serde(flatten)]
2228 unknown: UnknownKeys,
2229}
2230
2231#[derive(Debug, Deserialize)]
2232struct RawExports {
2233 #[serde(default)]
2234 files: Option<RawFileExport>,
2235 #[serde(default)]
2236 peer_files: Option<RawPeerFilesExport>,
2237 #[serde(default)]
2238 peer_tasks: Option<RawPeerTasksExport>,
2239 #[serde(flatten)]
2240 unknown: UnknownKeys,
2241}
2242
2243#[derive(Debug, Deserialize)]
2244struct RawPeerTasksExport {
2245 #[serde(default)]
2248 accept: Option<bool>,
2249 #[serde(flatten)]
2250 unknown: UnknownKeys,
2251}
2252
2253#[derive(Debug, Deserialize)]
2254struct RawPeerFilesExport {
2255 #[serde(default)]
2256 root: Option<String>,
2257 #[serde(default)]
2261 max_ttl: Option<serde_norway::Value>,
2262 #[serde(default)]
2263 max_bytes: Option<serde_norway::Value>,
2264 #[serde(flatten)]
2265 unknown: UnknownKeys,
2266}
2267
2268#[derive(Debug, Deserialize)]
2269struct RawFileExport {
2270 #[serde(default)]
2271 root: Option<String>,
2272 #[serde(default)]
2273 mode: Option<String>,
2274 #[serde(default)]
2279 max_bytes: Option<serde_norway::Value>,
2280 #[serde(flatten)]
2281 unknown: UnknownKeys,
2282}
2283
2284#[derive(Debug, Deserialize)]
2285struct RawLifecycleConfig {
2286 #[serde(default)]
2287 task_acceptance: Option<TaskAcceptance>,
2288 #[serde(default)]
2289 after_task: Option<AfterTask>,
2290 #[serde(default)]
2291 queue_depth: Option<usize>,
2292 #[serde(default)]
2293 input_timeout_secs: Option<u64>,
2294 #[serde(default)]
2295 conversation: Option<ConversationMode>,
2296 #[serde(default)]
2297 max_task_reopens: Option<u32>,
2298 #[serde(default)]
2299 shell_grace_secs: Option<u64>,
2300 #[serde(default)]
2301 delegation_deadline_secs: Option<u64>,
2302 #[serde(default)]
2303 deadline_notice_secs: Option<u64>,
2304 #[serde(default)]
2305 keep_working: Option<RawKeepWorking>,
2306 #[serde(default)]
2307 done_check: Option<RawDoneCheck>,
2308 #[serde(flatten)]
2309 unknown: UnknownKeys,
2310}
2311
2312#[derive(Debug, Deserialize)]
2313struct RawDoneCheck {
2314 #[serde(default)]
2315 command: Option<String>,
2316 #[serde(default)]
2317 limit: Option<u32>,
2318 #[serde(default)]
2319 timeout_secs: Option<u64>,
2320 #[serde(flatten)]
2321 unknown: UnknownKeys,
2322}
2323
2324#[derive(Debug, Deserialize)]
2325struct RawKeepWorking {
2326 #[serde(default)]
2327 message: Option<String>,
2328 #[serde(default)]
2329 limit: Option<u32>,
2330 #[serde(flatten)]
2331 unknown: UnknownKeys,
2332}
2333
2334#[derive(Debug, Deserialize)]
2335struct RawNetworkConfig {
2336 internal_port: Option<u16>,
2337 #[serde(default)]
2338 authentication: Option<RawNetworkAuthentication>,
2339 #[serde(flatten)]
2340 unknown: UnknownKeys,
2341}
2342
2343#[derive(Debug, Deserialize)]
2344struct RawNetworkAuthentication {
2345 #[serde(default)]
2346 scheme: Option<String>,
2347 #[serde(default)]
2350 credentials: Option<std::collections::BTreeMap<String, RawDoorCredential>>,
2351 #[serde(flatten)]
2352 unknown: UnknownKeys,
2353}
2354
2355#[derive(Debug, Deserialize)]
2356struct RawDoorCredential {
2357 #[serde(default)]
2361 scopes: Option<serde_norway::Value>,
2362 #[serde(flatten)]
2363 unknown: UnknownKeys,
2364}
2365
2366#[derive(Debug, Deserialize)]
2367struct RawContextConfig {
2368 max_tokens: Option<u32>,
2369 #[serde(default)]
2372 record: Option<String>,
2373 #[serde(default)]
2374 record_store: Option<String>,
2375 #[serde(default)]
2376 seed_budget: Option<f32>,
2377 #[serde(default)]
2378 seed_overflow_margin: Option<f32>,
2379 #[serde(default, deserialize_with = "present_yaml_value")]
2382 retain: Option<serde_norway::Value>,
2383 #[serde(default)]
2384 images: Option<RawContextImages>,
2385 #[serde(flatten)]
2386 unknown: UnknownKeys,
2387}
2388
2389#[derive(Debug, Deserialize)]
2392struct RawContextImages {
2393 #[serde(default)]
2394 keep: Option<serde_norway::Value>,
2395 #[serde(default)]
2396 max_bytes: Option<serde_norway::Value>,
2397 #[serde(default)]
2398 max_per_turn: Option<serde_norway::Value>,
2399 #[serde(flatten)]
2400 unknown: UnknownKeys,
2401}
2402
2403#[derive(Debug, Deserialize)]
2404struct RawObservabilityConfig {
2405 #[serde(default)]
2406 otel_endpoint: Option<String>,
2407 #[serde(default)]
2408 eval: Option<RawEvalConfig>,
2409 #[serde(flatten)]
2410 unknown: UnknownKeys,
2411}
2412
2413#[derive(Debug, Deserialize)]
2414struct RawTraceConfig {
2415 #[serde(default)]
2419 capture: Option<String>,
2420 #[serde(default, deserialize_with = "present_yaml_value")]
2422 retain: Option<serde_norway::Value>,
2423 #[serde(flatten)]
2424 unknown: UnknownKeys,
2425}
2426
2427#[derive(Debug, Deserialize)]
2428struct RawEvalConfig {
2429 #[serde(default)]
2430 dataset_id: Option<String>,
2431 #[serde(default)]
2432 scorers: Vec<RawScorerConfig>,
2433 #[serde(flatten)]
2434 unknown: UnknownKeys,
2435}
2436
2437#[derive(Debug, Deserialize)]
2438struct RawScorerConfig {
2439 #[serde(rename = "type")]
2440 scorer_type: String,
2441 name: Option<String>,
2442 max: Option<u64>,
2443 expected: Option<Vec<String>>,
2444 #[serde(flatten)]
2445 unknown: UnknownKeys,
2446}
2447
2448#[derive(Debug, Deserialize)]
2449struct RawArtifact {
2450 name: Option<String>,
2451 version: Option<String>,
2452 runtime: Option<String>,
2453 #[serde(default)]
2454 source: Option<String>,
2455 #[serde(default)]
2456 local_source: Option<bool>,
2457 #[serde(default)]
2458 prompt_payload: Option<bool>,
2459 #[serde(default)]
2460 capabilities: Option<RawCapabilities>,
2461 #[serde(default)]
2462 on_overflow: Option<String>,
2463 #[serde(default, deserialize_with = "deserialize_present")]
2468 config: Option<serde_norway::Value>,
2469 #[serde(default, deserialize_with = "deserialize_present")]
2472 gateway: Option<Option<RawArtifactGateway>>,
2473 #[serde(flatten)]
2474 unknown: UnknownKeys,
2475}
2476
2477#[derive(Debug, Deserialize)]
2478struct RawArtifactGateway {
2479 #[serde(default)]
2480 endpoint: Option<String>,
2481 #[serde(default, deserialize_with = "deserialize_present")]
2484 api_key: Option<Option<String>>,
2485 #[serde(default)]
2487 keyless: Option<serde_norway::Value>,
2488 #[serde(flatten)]
2489 unknown: UnknownKeys,
2490}
2491
2492fn deserialize_present<'de, T, D>(deserializer: D) -> Result<Option<T>, D::Error>
2498where
2499 T: Deserialize<'de>,
2500 D: serde::Deserializer<'de>,
2501{
2502 T::deserialize(deserializer).map(Some)
2503}
2504
2505#[derive(Debug, Deserialize)]
2506struct RawCapabilities {
2507 #[serde(default)]
2508 network: Option<RawNetworkCapabilities>,
2509 #[serde(default)]
2510 peer_fetch: Option<RawPeerFetchCapabilities>,
2511 #[serde(default)]
2512 filesystem: Option<RawFilesystemCapabilities>,
2513 #[serde(default)]
2514 shell: Option<RawShellCapabilities>,
2515 #[serde(default)]
2516 spawn: Option<RawSpawnCapabilities>,
2517 #[serde(default)]
2518 env: Option<RawEnvCapabilities>,
2519 #[serde(default)]
2520 limits: Option<RawResourceLimits>,
2521 #[serde(default)]
2522 resources: Option<RawResourceCapabilities>,
2523 #[serde(default)]
2524 state: Option<RawStateCapabilities>,
2525 #[serde(default)]
2526 task_io: Option<RawTaskIoCapabilities>,
2527 #[serde(default)]
2528 conversation: Option<RawConversationCapabilities>,
2529 #[serde(default)]
2530 plan: Option<RawPlanCapabilities>,
2531 #[serde(default)]
2532 install: Option<RawInstallCapabilities>,
2533 #[serde(default)]
2537 containment: Option<String>,
2538 #[serde(flatten)]
2539 unknown: UnknownKeys,
2540}
2541
2542#[derive(Debug, Deserialize)]
2543struct RawTaskIoCapabilities {
2544 #[serde(default)]
2547 read: Option<bool>,
2548 #[serde(flatten)]
2549 unknown: UnknownKeys,
2550}
2551
2552#[derive(Debug, Deserialize)]
2553struct RawConversationCapabilities {
2554 #[serde(default)]
2557 read: Option<bool>,
2558 #[serde(flatten)]
2559 unknown: UnknownKeys,
2560}
2561
2562#[derive(Debug, Deserialize)]
2563struct RawPlanCapabilities {
2564 #[serde(default)]
2567 submit: Option<bool>,
2568 #[serde(flatten)]
2569 unknown: UnknownKeys,
2570}
2571
2572#[derive(Debug, Deserialize)]
2573struct RawInstallCapabilities {
2574 #[serde(default)]
2575 skill: Vec<String>,
2576 #[serde(default)]
2577 tool: Vec<String>,
2578 #[serde(default)]
2579 from: Option<Vec<String>>,
2580 #[serde(flatten)]
2581 unknown: UnknownKeys,
2582}
2583
2584#[derive(Debug, Deserialize)]
2585struct RawSpawnCapabilities {
2586 #[serde(default)]
2587 allow: Vec<String>,
2588 #[serde(default)]
2589 workdir_snapshot: Vec<String>,
2590 #[serde(flatten)]
2591 unknown: UnknownKeys,
2592}
2593
2594#[derive(Debug, Deserialize)]
2595struct RawEnvCapabilities {
2596 #[serde(default)]
2597 allow: Vec<String>,
2598 #[serde(flatten)]
2599 unknown: UnknownKeys,
2600}
2601
2602#[derive(Debug, Deserialize)]
2603struct RawResourceLimits {
2604 #[serde(default)]
2605 memory_bytes: Option<usize>,
2606 #[serde(default)]
2607 table_elements: Option<usize>,
2608 #[serde(default)]
2609 instances: Option<usize>,
2610 #[serde(default)]
2611 deadline_seconds: Option<u64>,
2612 #[serde(flatten)]
2613 unknown: UnknownKeys,
2614}
2615
2616#[derive(Debug, Deserialize)]
2617struct RawStateCapabilities {
2618 #[serde(default)]
2619 store: Option<String>,
2620 #[serde(flatten)]
2621 unknown: UnknownKeys,
2622}
2623
2624#[derive(Debug, Deserialize)]
2625struct RawResourceCapabilities {
2626 #[serde(default)]
2627 max_processes: Option<u64>,
2628 #[serde(default)]
2629 max_open_files: Option<u64>,
2630 #[serde(default)]
2631 max_file_size_bytes: Option<u64>,
2632 #[serde(default)]
2633 cpu_seconds: Option<u64>,
2634 #[serde(default)]
2635 memory_bytes: Option<u64>,
2636 #[serde(default)]
2637 cgroup_memory_bytes: Option<u64>,
2638 #[serde(default)]
2639 cgroup_pids_max: Option<u64>,
2640 #[serde(default)]
2641 cgroup_cpu_percent: Option<u32>,
2642 #[serde(default)]
2643 cgroup_io_bytes_per_sec: Option<u64>,
2644 #[serde(default)]
2645 workdir_max_bytes: Option<u64>,
2646 #[serde(flatten)]
2647 unknown: UnknownKeys,
2648}
2649
2650#[derive(Debug, Deserialize)]
2651struct RawNetworkCapabilities {
2652 #[serde(default)]
2653 allow: Vec<String>,
2654 #[serde(default)]
2655 unix_sockets: bool,
2656 #[serde(flatten)]
2657 unknown: UnknownKeys,
2658}
2659
2660#[derive(Debug, Deserialize)]
2661struct RawPeerFetchCapabilities {
2662 #[serde(default)]
2663 allow: Vec<String>,
2664 #[serde(flatten)]
2665 unknown: UnknownKeys,
2666}
2667
2668#[derive(Debug, Deserialize)]
2669struct RawFilesystemCapabilities {
2670 #[serde(default)]
2671 scope: Option<String>,
2672 #[serde(default)]
2676 workdir_exec: bool,
2677 #[serde(default)]
2680 read_only: Vec<String>,
2681 #[serde(default)]
2683 records: bool,
2684 #[serde(flatten)]
2685 unknown: UnknownKeys,
2686}
2687
2688#[derive(Debug, Deserialize)]
2689struct RawShellCapabilities {
2690 #[serde(default)]
2691 allow: Vec<String>,
2692 #[serde(default)]
2693 strip_env: Option<Vec<String>>,
2694 #[serde(default)]
2695 baseline_env: Option<Vec<String>>,
2696 #[serde(default)]
2697 interpreter_runtime: Vec<RawInterpreterRuntimeGrant>,
2698 #[serde(default)]
2699 staged_runtime: Vec<RawStagedRuntimeGrant>,
2700 #[serde(flatten)]
2701 unknown: UnknownKeys,
2702}
2703
2704#[derive(Debug, Deserialize)]
2705struct RawInterpreterRuntimeGrant {
2706 #[serde(default)]
2707 binary: Option<String>,
2708 #[serde(default)]
2709 dirs: Vec<RawInterpreterRuntimeDir>,
2710 #[serde(flatten)]
2711 unknown: UnknownKeys,
2712}
2713
2714#[derive(Debug, Deserialize)]
2715struct RawInterpreterRuntimeDir {
2716 #[serde(default)]
2717 path: Option<String>,
2718 #[serde(default)]
2722 list_dir: Option<bool>,
2723 #[serde(flatten)]
2724 unknown: UnknownKeys,
2725}
2726
2727#[derive(Debug, Deserialize)]
2728struct RawStagedRuntimeGrant {
2729 #[serde(default)]
2730 binary: Option<String>,
2731 #[serde(default)]
2732 source_path: Option<String>,
2733 #[serde(default)]
2737 pin: Option<String>,
2738 #[serde(flatten)]
2739 unknown: UnknownKeys,
2740}
2741
2742#[derive(Debug, Deserialize)]
2743struct RawInferenceConfig {
2744 transport: Option<String>,
2745 #[serde(default, deserialize_with = "deserialize_present")]
2748 endpoint: Option<serde_norway::Value>,
2749 model: Option<String>,
2750 #[serde(default, deserialize_with = "deserialize_present")]
2753 api_key: Option<serde_norway::Value>,
2754 #[serde(default)]
2755 command: Option<String>,
2756 #[serde(default)]
2757 system_prompt: Option<String>,
2758 #[serde(default)]
2759 system_prompt_file: Option<String>,
2760 #[serde(default)]
2761 system_prompt_artifact: Option<String>,
2762 #[serde(default)]
2763 instruction_files: Option<Vec<String>>,
2764 #[serde(default)]
2765 driver: Option<RawInferenceDriver>,
2766 #[serde(default)]
2767 compaction: Option<RawCompactionConfig>,
2768 #[serde(default)]
2769 max_turns: Option<u32>,
2770 #[serde(default)]
2773 max_task_reopens: Option<u32>,
2774 #[serde(default)]
2775 max_tokens: Option<u32>,
2776 #[serde(default)]
2777 max_session_tokens: Option<u64>,
2778 #[serde(default)]
2779 tool_refresh: Option<String>,
2780 #[serde(default)]
2781 alternates: Option<Vec<RawInferenceAlternate>>,
2782 #[serde(default)]
2783 retry: Option<RawInferenceRetry>,
2784 #[serde(flatten)]
2785 unknown: UnknownKeys,
2786}
2787
2788#[derive(Debug, Deserialize)]
2789struct RawInferenceRetry {
2790 #[serde(default)]
2791 max_retries: Option<u32>,
2792 #[serde(default)]
2793 initial_delay_ms: Option<u32>,
2794 #[serde(default)]
2795 max_delay_ms: Option<u32>,
2796 #[serde(flatten)]
2797 unknown: UnknownKeys,
2798}
2799
2800#[derive(Debug, Deserialize)]
2801struct RawInferenceAlternate {
2802 #[serde(default)]
2803 name: Option<String>,
2804 #[serde(default)]
2805 model: Option<String>,
2806 #[serde(default)]
2807 driver: Option<RawInferenceDriver>,
2808 #[serde(flatten)]
2809 unknown: UnknownKeys,
2810}
2811
2812#[derive(Debug, Deserialize)]
2813struct RawCompactionConfig {
2814 threshold: Option<f32>,
2815 model: Option<String>,
2816 system_prompt: Option<String>,
2817 system_prompt_file: Option<String>,
2818 dump_summaries: Option<bool>,
2819 max_tokens: Option<u32>,
2820 #[serde(default)]
2821 driver_config: Option<serde_norway::Value>,
2822 #[serde(flatten)]
2823 unknown: UnknownKeys,
2824}
2825
2826#[derive(Debug, Deserialize)]
2827struct RawInferenceDriver {
2828 artifact: Option<String>,
2829 #[serde(default)]
2830 config: Option<serde_norway::Value>,
2831 #[serde(flatten)]
2832 unknown: UnknownKeys,
2833}
2834
2835type UnknownKeys = std::collections::BTreeMap<String, serde::de::IgnoredAny>;
2842
2843trait RawBlock {
2850 const KNOWN_KEYS: &'static [&'static str];
2854
2855 fn unknown_keys(&self) -> &UnknownKeys;
2856
2857 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2865 let _ = (path, out);
2866 }
2867}
2868
2869impl RawBlock for RawRuntimeManifest {
2870 const KNOWN_KEYS: &'static [&'static str] = &[
2871 "name",
2872 "version",
2873 "artifacts",
2874 "capabilities",
2875 "inference",
2876 "context",
2877 "observability",
2878 "trace",
2879 "network",
2880 "lifecycle",
2881 "exports",
2882 "control",
2883 "deploy",
2884 "mur_version",
2885 "config",
2886 "runtime",
2887 "implementation",
2888 "execution",
2889 "requires_files",
2890 ];
2891 fn unknown_keys(&self) -> &UnknownKeys {
2892 &self.unknown
2893 }
2894
2895 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2896 let artifacts = child_path(path, "artifacts");
2897 for (index, artifact) in self.artifacts.iter().enumerate() {
2898 collect_block(artifact, &format!("{artifacts}[{index}]"), out);
2899 }
2900 if let Some(capabilities) = &self.capabilities {
2901 collect_block(capabilities, &child_path(path, "capabilities"), out);
2902 }
2903 if let Some(inference) = &self.inference {
2904 collect_block(inference, &child_path(path, "inference"), out);
2905 }
2906 if let Some(context) = &self.context {
2907 collect_block(context, &child_path(path, "context"), out);
2908 }
2909 if let Some(observability) = &self.observability {
2910 collect_block(observability, &child_path(path, "observability"), out);
2911 }
2912 if let Some(trace) = &self.trace {
2913 collect_block(trace, &child_path(path, "trace"), out);
2914 }
2915 if let Some(network) = &self.network {
2916 collect_block(network, &child_path(path, "network"), out);
2917 }
2918 if let Some(lifecycle) = &self.lifecycle {
2919 collect_block(lifecycle, &child_path(path, "lifecycle"), out);
2920 }
2921 if let Some(exports) = &self.exports {
2922 collect_block(exports, &child_path(path, "exports"), out);
2923 }
2924 if let Some(control) = &self.control {
2925 collect_block(control, &child_path(path, "control"), out);
2926 }
2927 if let Some(deploy) = &self.deploy {
2928 collect_block(deploy, &child_path(path, "deploy"), out);
2929 }
2930 }
2931}
2932
2933impl RawBlock for RawDeploy {
2934 const KNOWN_KEYS: &'static [&'static str] = &["mur_version"];
2935 fn unknown_keys(&self) -> &UnknownKeys {
2936 &self.unknown
2937 }
2938}
2939
2940impl RawBlock for RawControl {
2941 const KNOWN_KEYS: &'static [&'static str] = &["settings", "secrets", "agent_settings"];
2942 fn unknown_keys(&self) -> &UnknownKeys {
2943 &self.unknown
2944 }
2945}
2946
2947impl RawBlock for RawExports {
2948 const KNOWN_KEYS: &'static [&'static str] = &["files", "peer_files", "peer_tasks"];
2949 fn unknown_keys(&self) -> &UnknownKeys {
2950 &self.unknown
2951 }
2952
2953 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
2954 if let Some(files) = &self.files {
2955 collect_block(files, &child_path(path, "files"), out);
2956 }
2957 if let Some(peer_files) = &self.peer_files {
2958 collect_block(peer_files, &child_path(path, "peer_files"), out);
2959 }
2960 if let Some(peer_tasks) = &self.peer_tasks {
2961 collect_block(peer_tasks, &child_path(path, "peer_tasks"), out);
2962 }
2963 }
2964}
2965
2966impl RawBlock for RawPeerTasksExport {
2967 const KNOWN_KEYS: &'static [&'static str] = &["accept"];
2968 fn unknown_keys(&self) -> &UnknownKeys {
2969 &self.unknown
2970 }
2971}
2972
2973impl RawBlock for RawPeerFilesExport {
2974 const KNOWN_KEYS: &'static [&'static str] = &["root", "max_ttl", "max_bytes"];
2975 fn unknown_keys(&self) -> &UnknownKeys {
2976 &self.unknown
2977 }
2978}
2979
2980impl RawBlock for RawFileExport {
2981 const KNOWN_KEYS: &'static [&'static str] = &["root", "mode", "max_bytes"];
2982 fn unknown_keys(&self) -> &UnknownKeys {
2983 &self.unknown
2984 }
2985}
2986
2987fn parse_keep_working(raw: RawKeepWorking) -> Result<KeepWorking, RuntimeManifestError> {
2990 let invalid = |field: &str, message: &str| RuntimeManifestError::InvalidLifecycleConfig {
2991 field: format!("lifecycle.keep_working.{field}"),
2992 message: message.to_string(),
2993 };
2994 let message = match raw.message {
2995 None => return Err(invalid("message", "is required")),
2996 Some(message) if message.trim().is_empty() => {
2997 return Err(invalid("message", "must not be blank"))
2998 }
2999 Some(message) => message,
3000 };
3001 let limit = match raw.limit {
3002 None => return Err(invalid("limit", "is required")),
3003 Some(0) => {
3004 return Err(invalid(
3005 "limit",
3006 "must be at least 1; omit lifecycle.keep_working to turn the option off",
3007 ))
3008 }
3009 Some(limit) => limit,
3010 };
3011 Ok(KeepWorking { message, limit })
3012}
3013
3014fn parse_done_check(
3018 raw: RawDoneCheck,
3019 shell_allow: &[String],
3020) -> Result<DoneCheck, RuntimeManifestError> {
3021 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidLifecycleConfig {
3022 field: format!("lifecycle.done_check.{field}"),
3023 message,
3024 };
3025 let command = match raw.command {
3026 None => return Err(invalid("command", "is required".into())),
3027 Some(command) if command.trim().is_empty() => {
3028 return Err(invalid("command", "must not be blank".into()))
3029 }
3030 Some(command) => command,
3031 };
3032 let Some(program) = split_shell_words(&command).into_iter().next() else {
3033 return Err(invalid("command", "must name a program".into()));
3034 };
3035 if !shell_allow.contains(&program) {
3036 return Err(invalid(
3037 "command",
3038 format!("runs '{program}', which is not in capabilities.shell.allow"),
3039 ));
3040 }
3041 let Some(limit) = raw.limit else {
3042 return Err(invalid("limit", "is required".into()));
3043 };
3044 let timeout_secs = match raw.timeout_secs {
3045 None => DoneCheck::DEFAULT_TIMEOUT_SECS,
3046 Some(0) => return Err(invalid("timeout_secs", "must be at least 1".into())),
3047 Some(secs) => secs,
3048 };
3049 Ok(DoneCheck {
3050 command,
3051 limit,
3052 timeout_secs,
3053 })
3054}
3055
3056impl RawBlock for RawLifecycleConfig {
3057 const KNOWN_KEYS: &'static [&'static str] = &[
3058 "task_acceptance",
3059 "after_task",
3060 "queue_depth",
3061 "input_timeout_secs",
3062 "conversation",
3063 "max_task_reopens",
3064 "shell_grace_secs",
3065 "delegation_deadline_secs",
3066 "deadline_notice_secs",
3067 "keep_working",
3068 "done_check",
3069 ];
3070 fn unknown_keys(&self) -> &UnknownKeys {
3071 &self.unknown
3072 }
3073
3074 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3075 if let Some(keep_working) = &self.keep_working {
3076 collect_block(keep_working, &child_path(path, "keep_working"), out);
3077 }
3078 if let Some(done_check) = &self.done_check {
3079 collect_block(done_check, &child_path(path, "done_check"), out);
3080 }
3081 }
3082}
3083
3084impl RawBlock for RawDoneCheck {
3085 const KNOWN_KEYS: &'static [&'static str] = &["command", "limit", "timeout_secs"];
3086 fn unknown_keys(&self) -> &UnknownKeys {
3087 &self.unknown
3088 }
3089}
3090
3091impl RawBlock for RawKeepWorking {
3092 const KNOWN_KEYS: &'static [&'static str] = &["message", "limit"];
3093 fn unknown_keys(&self) -> &UnknownKeys {
3094 &self.unknown
3095 }
3096}
3097
3098impl RawBlock for RawNetworkConfig {
3099 const KNOWN_KEYS: &'static [&'static str] = &["internal_port", "authentication"];
3100 fn unknown_keys(&self) -> &UnknownKeys {
3101 &self.unknown
3102 }
3103
3104 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3105 if let Some(authentication) = &self.authentication {
3106 collect_block(authentication, &child_path(path, "authentication"), out);
3107 }
3108 }
3109}
3110
3111impl RawBlock for RawNetworkAuthentication {
3112 const KNOWN_KEYS: &'static [&'static str] = &["scheme", "credentials"];
3113 fn unknown_keys(&self) -> &UnknownKeys {
3114 &self.unknown
3115 }
3116
3117 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3118 let credentials_path = child_path(path, "credentials");
3119 for (name, credential) in self.credentials.iter().flatten() {
3120 collect_block(credential, &child_path(&credentials_path, name), out);
3121 }
3122 }
3123}
3124
3125impl RawBlock for RawDoorCredential {
3126 const KNOWN_KEYS: &'static [&'static str] = &["scopes"];
3127 fn unknown_keys(&self) -> &UnknownKeys {
3128 &self.unknown
3129 }
3130}
3131
3132impl RawBlock for RawContextConfig {
3133 const KNOWN_KEYS: &'static [&'static str] = &[
3134 "max_tokens",
3135 "record",
3136 "record_store",
3137 "seed_budget",
3138 "seed_overflow_margin",
3139 "retain",
3140 "images",
3141 ];
3142 fn unknown_keys(&self) -> &UnknownKeys {
3143 &self.unknown
3144 }
3145
3146 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3147 if let Some(images) = &self.images {
3148 collect_block(images, &child_path(path, "images"), out);
3149 }
3150 }
3151}
3152
3153impl RawBlock for RawContextImages {
3154 const KNOWN_KEYS: &'static [&'static str] = &["keep", "max_bytes", "max_per_turn"];
3155 fn unknown_keys(&self) -> &UnknownKeys {
3156 &self.unknown
3157 }
3158}
3159
3160impl RawBlock for RawObservabilityConfig {
3161 const KNOWN_KEYS: &'static [&'static str] = &["otel_endpoint", "eval"];
3162 fn unknown_keys(&self) -> &UnknownKeys {
3163 &self.unknown
3164 }
3165
3166 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3167 if let Some(eval) = &self.eval {
3168 collect_block(eval, &child_path(path, "eval"), out);
3169 }
3170 }
3171}
3172
3173impl RawBlock for RawTraceConfig {
3174 const KNOWN_KEYS: &'static [&'static str] = &["capture", "retain"];
3175 fn unknown_keys(&self) -> &UnknownKeys {
3176 &self.unknown
3177 }
3178}
3179
3180impl RawBlock for RawEvalConfig {
3181 const KNOWN_KEYS: &'static [&'static str] = &["dataset_id", "scorers"];
3182 fn unknown_keys(&self) -> &UnknownKeys {
3183 &self.unknown
3184 }
3185
3186 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3187 let scorers = child_path(path, "scorers");
3188 for (index, scorer) in self.scorers.iter().enumerate() {
3189 collect_block(scorer, &format!("{scorers}[{index}]"), out);
3190 }
3191 }
3192}
3193
3194impl RawBlock for RawScorerConfig {
3195 const KNOWN_KEYS: &'static [&'static str] = &["type", "name", "max", "expected"];
3196 fn unknown_keys(&self) -> &UnknownKeys {
3197 &self.unknown
3198 }
3199}
3200
3201impl RawBlock for RawArtifact {
3202 const KNOWN_KEYS: &'static [&'static str] = &[
3203 "name",
3204 "version",
3205 "runtime",
3206 "source",
3207 "local_source",
3208 "prompt_payload",
3209 "capabilities",
3210 "on_overflow",
3211 "config",
3212 "gateway",
3213 ];
3214 fn unknown_keys(&self) -> &UnknownKeys {
3215 &self.unknown
3216 }
3217
3218 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3219 if let Some(capabilities) = &self.capabilities {
3220 collect_block(capabilities, &child_path(path, "capabilities"), out);
3221 }
3222 if let Some(Some(gateway)) = &self.gateway {
3223 collect_block(gateway, &child_path(path, "gateway"), out);
3224 }
3225 }
3226}
3227
3228impl RawBlock for RawArtifactGateway {
3229 const KNOWN_KEYS: &'static [&'static str] = &["endpoint", "api_key", "keyless"];
3230 fn unknown_keys(&self) -> &UnknownKeys {
3231 &self.unknown
3232 }
3233}
3234
3235impl RawBlock for RawCapabilities {
3236 const KNOWN_KEYS: &'static [&'static str] = &[
3237 "network",
3238 "peer_fetch",
3239 "filesystem",
3240 "shell",
3241 "spawn",
3242 "env",
3243 "limits",
3244 "resources",
3245 "state",
3246 "task_io",
3247 "conversation",
3248 "plan",
3249 "install",
3250 "containment",
3251 ];
3252 fn unknown_keys(&self) -> &UnknownKeys {
3253 &self.unknown
3254 }
3255
3256 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3260 if let Some(network) = &self.network {
3261 collect_block(network, &child_path(path, "network"), out);
3262 }
3263 if let Some(peer_fetch) = &self.peer_fetch {
3264 collect_block(peer_fetch, &child_path(path, "peer_fetch"), out);
3265 }
3266 if let Some(filesystem) = &self.filesystem {
3267 collect_block(filesystem, &child_path(path, "filesystem"), out);
3268 }
3269 if let Some(shell) = &self.shell {
3270 collect_block(shell, &child_path(path, "shell"), out);
3271 }
3272 if let Some(spawn) = &self.spawn {
3273 collect_block(spawn, &child_path(path, "spawn"), out);
3274 }
3275 if let Some(env) = &self.env {
3276 collect_block(env, &child_path(path, "env"), out);
3277 }
3278 if let Some(limits) = &self.limits {
3279 collect_block(limits, &child_path(path, "limits"), out);
3280 }
3281 if let Some(resources) = &self.resources {
3282 collect_block(resources, &child_path(path, "resources"), out);
3283 }
3284 if let Some(state) = &self.state {
3285 collect_block(state, &child_path(path, "state"), out);
3286 }
3287 if let Some(task_io) = &self.task_io {
3288 collect_block(task_io, &child_path(path, "task_io"), out);
3289 }
3290 if let Some(conversation) = &self.conversation {
3291 collect_block(conversation, &child_path(path, "conversation"), out);
3292 }
3293 if let Some(plan) = &self.plan {
3294 collect_block(plan, &child_path(path, "plan"), out);
3295 }
3296 if let Some(install) = &self.install {
3297 collect_block(install, &child_path(path, "install"), out);
3298 }
3299 }
3300}
3301
3302impl RawBlock for RawTaskIoCapabilities {
3303 const KNOWN_KEYS: &'static [&'static str] = &["read"];
3304 fn unknown_keys(&self) -> &UnknownKeys {
3305 &self.unknown
3306 }
3307}
3308
3309impl RawBlock for RawConversationCapabilities {
3310 const KNOWN_KEYS: &'static [&'static str] = &["read"];
3311 fn unknown_keys(&self) -> &UnknownKeys {
3312 &self.unknown
3313 }
3314}
3315
3316impl RawBlock for RawPlanCapabilities {
3317 const KNOWN_KEYS: &'static [&'static str] = &["submit"];
3318 fn unknown_keys(&self) -> &UnknownKeys {
3319 &self.unknown
3320 }
3321}
3322
3323impl RawBlock for RawInstallCapabilities {
3324 const KNOWN_KEYS: &'static [&'static str] = &["skill", "tool", "from"];
3325 fn unknown_keys(&self) -> &UnknownKeys {
3326 &self.unknown
3327 }
3328}
3329
3330impl RawBlock for RawSpawnCapabilities {
3331 const KNOWN_KEYS: &'static [&'static str] = &["allow", "workdir_snapshot"];
3332 fn unknown_keys(&self) -> &UnknownKeys {
3333 &self.unknown
3334 }
3335}
3336
3337impl RawBlock for RawEnvCapabilities {
3338 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3339 fn unknown_keys(&self) -> &UnknownKeys {
3340 &self.unknown
3341 }
3342}
3343
3344impl RawBlock for RawResourceLimits {
3345 const KNOWN_KEYS: &'static [&'static str] = &[
3346 "memory_bytes",
3347 "table_elements",
3348 "instances",
3349 "deadline_seconds",
3350 ];
3351 fn unknown_keys(&self) -> &UnknownKeys {
3352 &self.unknown
3353 }
3354}
3355
3356impl RawBlock for RawStateCapabilities {
3357 const KNOWN_KEYS: &'static [&'static str] = &["store"];
3358 fn unknown_keys(&self) -> &UnknownKeys {
3359 &self.unknown
3360 }
3361}
3362
3363impl RawBlock for RawResourceCapabilities {
3364 const KNOWN_KEYS: &'static [&'static str] = &[
3365 "max_processes",
3366 "max_open_files",
3367 "max_file_size_bytes",
3368 "cpu_seconds",
3369 "memory_bytes",
3370 "cgroup_memory_bytes",
3371 "cgroup_pids_max",
3372 "cgroup_cpu_percent",
3373 "cgroup_io_bytes_per_sec",
3374 "workdir_max_bytes",
3375 ];
3376 fn unknown_keys(&self) -> &UnknownKeys {
3377 &self.unknown
3378 }
3379}
3380
3381impl RawBlock for RawNetworkCapabilities {
3382 const KNOWN_KEYS: &'static [&'static str] = &["allow", "unix_sockets"];
3383 fn unknown_keys(&self) -> &UnknownKeys {
3384 &self.unknown
3385 }
3386}
3387
3388impl RawBlock for RawPeerFetchCapabilities {
3389 const KNOWN_KEYS: &'static [&'static str] = &["allow"];
3390 fn unknown_keys(&self) -> &UnknownKeys {
3391 &self.unknown
3392 }
3393}
3394
3395impl RawBlock for RawFilesystemCapabilities {
3396 const KNOWN_KEYS: &'static [&'static str] = &["scope", "workdir_exec", "read_only", "records"];
3397 fn unknown_keys(&self) -> &UnknownKeys {
3398 &self.unknown
3399 }
3400}
3401
3402impl RawBlock for RawShellCapabilities {
3403 const KNOWN_KEYS: &'static [&'static str] = &[
3404 "allow",
3405 "strip_env",
3406 "baseline_env",
3407 "interpreter_runtime",
3408 "staged_runtime",
3409 ];
3410 fn unknown_keys(&self) -> &UnknownKeys {
3411 &self.unknown
3412 }
3413
3414 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3415 let interpreter_runtime = child_path(path, "interpreter_runtime");
3416 for (index, grant) in self.interpreter_runtime.iter().enumerate() {
3417 collect_block(grant, &format!("{interpreter_runtime}[{index}]"), out);
3418 }
3419 let staged_runtime = child_path(path, "staged_runtime");
3420 for (index, grant) in self.staged_runtime.iter().enumerate() {
3421 collect_block(grant, &format!("{staged_runtime}[{index}]"), out);
3422 }
3423 }
3424}
3425
3426impl RawBlock for RawInterpreterRuntimeGrant {
3427 const KNOWN_KEYS: &'static [&'static str] = &["binary", "dirs"];
3428 fn unknown_keys(&self) -> &UnknownKeys {
3429 &self.unknown
3430 }
3431
3432 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3433 let dirs = child_path(path, "dirs");
3434 for (index, dir) in self.dirs.iter().enumerate() {
3435 collect_block(dir, &format!("{dirs}[{index}]"), out);
3436 }
3437 }
3438}
3439
3440impl RawBlock for RawInterpreterRuntimeDir {
3441 const KNOWN_KEYS: &'static [&'static str] = &["path", "list_dir"];
3442 fn unknown_keys(&self) -> &UnknownKeys {
3443 &self.unknown
3444 }
3445}
3446
3447impl RawBlock for RawStagedRuntimeGrant {
3448 const KNOWN_KEYS: &'static [&'static str] = &["binary", "source_path", "pin"];
3449 fn unknown_keys(&self) -> &UnknownKeys {
3450 &self.unknown
3451 }
3452}
3453
3454impl RawBlock for RawInferenceConfig {
3455 const KNOWN_KEYS: &'static [&'static str] = &[
3456 "transport",
3457 "endpoint",
3458 "model",
3459 "api_key",
3460 "command",
3461 "system_prompt",
3462 "system_prompt_file",
3463 "system_prompt_artifact",
3464 "instruction_files",
3465 "driver",
3466 "compaction",
3467 "max_turns",
3468 "max_task_reopens",
3469 "max_tokens",
3470 "max_session_tokens",
3471 "tool_refresh",
3472 "alternates",
3473 "retry",
3474 ];
3475 fn unknown_keys(&self) -> &UnknownKeys {
3476 &self.unknown
3477 }
3478
3479 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3480 if let Some(driver) = &self.driver {
3481 collect_block(driver, &child_path(path, "driver"), out);
3482 }
3483 if let Some(compaction) = &self.compaction {
3484 collect_block(compaction, &child_path(path, "compaction"), out);
3485 }
3486 if let Some(retry) = &self.retry {
3487 collect_block(retry, &child_path(path, "retry"), out);
3488 }
3489 if let Some(alternates) = &self.alternates {
3490 let alternates_path = child_path(path, "alternates");
3491 for (index, alternate) in alternates.iter().enumerate() {
3492 collect_block(alternate, &format!("{alternates_path}[{index}]"), out);
3493 }
3494 }
3495 }
3496}
3497
3498impl RawBlock for RawInferenceRetry {
3499 const KNOWN_KEYS: &'static [&'static str] =
3500 &["max_retries", "initial_delay_ms", "max_delay_ms"];
3501 fn unknown_keys(&self) -> &UnknownKeys {
3502 &self.unknown
3503 }
3504}
3505
3506impl RawBlock for RawInferenceAlternate {
3507 const KNOWN_KEYS: &'static [&'static str] = &["name", "model", "driver"];
3508 fn unknown_keys(&self) -> &UnknownKeys {
3509 &self.unknown
3510 }
3511
3512 fn walk_children(&self, path: &str, out: &mut Vec<UnknownManifestKey>) {
3513 if let Some(driver) = &self.driver {
3514 collect_block(driver, &child_path(path, "driver"), out);
3515 }
3516 }
3517}
3518
3519impl RawBlock for RawCompactionConfig {
3520 const KNOWN_KEYS: &'static [&'static str] = &[
3521 "threshold",
3522 "model",
3523 "system_prompt",
3524 "system_prompt_file",
3525 "dump_summaries",
3526 "max_tokens",
3527 "driver_config",
3528 ];
3529 fn unknown_keys(&self) -> &UnknownKeys {
3530 &self.unknown
3531 }
3532}
3533
3534impl RawBlock for RawInferenceDriver {
3535 const KNOWN_KEYS: &'static [&'static str] = &["artifact", "config"];
3536 fn unknown_keys(&self) -> &UnknownKeys {
3537 &self.unknown
3538 }
3539}
3540
3541fn collect_unknown_keys(raw: &RawRuntimeManifest) -> Vec<UnknownManifestKey> {
3548 let mut out = Vec::new();
3549 collect_block(raw, "", &mut out);
3550 out
3551}
3552
3553fn collect_block<T: RawBlock>(block: &T, path: &str, out: &mut Vec<UnknownManifestKey>) {
3559 for key in block.unknown_keys().keys() {
3560 out.push(UnknownManifestKey {
3561 key: key.clone(),
3562 block_path: path.to_string(),
3563 nearest_known: nearest_known_key(key, T::KNOWN_KEYS),
3564 });
3565 }
3566 block.walk_children(path, out);
3567}
3568
3569fn child_path(parent: &str, field: &str) -> String {
3573 if parent.is_empty() {
3574 return field.to_string();
3575 }
3576 format!("{parent}.{field}")
3577}
3578
3579pub fn read_runtime_manifest_text(path: &Path) -> Result<String, RuntimeManifestError> {
3586 fs::read_to_string(path).map_err(|source| {
3587 if source.kind() == std::io::ErrorKind::NotFound {
3588 return RuntimeManifestError::NotFound(path.display().to_string());
3589 }
3590
3591 RuntimeManifestError::Io {
3592 path: path.display().to_string(),
3593 source,
3594 }
3595 })
3596}
3597
3598#[must_use = "validated runtime manifest is required before run"]
3599pub fn load_runtime_manifest(path: &Path) -> Result<RuntimeManifest, RuntimeManifestError> {
3600 let content = read_runtime_manifest_text(path)?;
3601
3602 RuntimeManifest::from_yaml_str(&content)
3603}
3604
3605#[derive(Debug, Clone, PartialEq, Eq)]
3607pub struct ReferencedEnvVariable {
3608 pub field: String,
3610 pub variable: String,
3612}
3613
3614impl ReferencedEnvVariable {
3615 pub fn is_gateway_credential(&self) -> bool {
3618 self.field.starts_with("artifacts.") && self.field.ends_with(".gateway.api_key")
3619 }
3620}
3621
3622#[derive(Deserialize)]
3630struct ReferencedManifestFields {
3631 #[serde(default)]
3632 artifacts: Vec<ReferencedArtifactFields>,
3633}
3634
3635#[derive(Deserialize)]
3636struct ReferencedArtifactFields {
3637 #[serde(default)]
3638 name: Option<String>,
3639 #[serde(default)]
3640 gateway: Option<ReferencedGatewayFields>,
3641}
3642
3643#[derive(Deserialize)]
3644struct ReferencedGatewayFields {
3645 #[serde(default)]
3646 api_key: Option<String>,
3647}
3648
3649pub fn referenced_env_variables(manifest_yaml: &str) -> Vec<ReferencedEnvVariable> {
3657 let Ok(raw) = serde_norway::from_str::<ReferencedManifestFields>(manifest_yaml) else {
3658 return Vec::new();
3659 };
3660
3661 let mut referenced: Vec<ReferencedEnvVariable> = Vec::new();
3662 for artifact in raw.artifacts {
3663 let Some(api_key) = artifact.gateway.and_then(|gateway| gateway.api_key) else {
3664 continue;
3665 };
3666 if let Some(variable) = parse_env_reference(api_key.trim()) {
3667 let name = artifact.name.unwrap_or_default();
3668 referenced.push(ReferencedEnvVariable {
3669 field: format!("artifacts.{}.gateway.api_key", name.trim()),
3670 variable: variable.to_string(),
3671 });
3672 }
3673 }
3674
3675 referenced.sort_by(|a, b| a.variable.cmp(&b.variable).then(a.field.cmp(&b.field)));
3676 referenced.dedup();
3677 referenced
3678}
3679
3680#[derive(Clone, Copy, PartialEq, Eq)]
3686enum SecretResolution {
3687 Resolve,
3689 Skip,
3692}
3693
3694impl RuntimeManifest {
3695 pub fn from_yaml_str(input: &str) -> Result<Self, RuntimeManifestError> {
3696 Self::parse(input, SecretResolution::Resolve)
3697 }
3698
3699 pub fn from_yaml_str_without_secrets(input: &str) -> Result<Self, RuntimeManifestError> {
3709 Self::parse(input, SecretResolution::Skip)
3710 }
3711
3712 fn parse(input: &str, secrets: SecretResolution) -> Result<Self, RuntimeManifestError> {
3713 let raw: RawRuntimeManifest = serde_norway::from_str(input).map_err(|err| {
3714 if let Some(location) = err.location() {
3715 RuntimeManifestError::YamlSyntax(format!(
3716 "{}: YAML syntax error at line {}, column {}: {}",
3717 MANIFEST_FILENAME,
3718 location.line(),
3719 location.column(),
3720 err
3721 ))
3722 } else {
3723 RuntimeManifestError::YamlSyntax(format!(
3724 "{MANIFEST_FILENAME}: YAML syntax error: {err}"
3725 ))
3726 }
3727 })?;
3728
3729 if raw.unknown.contains_key(REGISTRY_BLOCK_KEY) {
3731 return Err(RuntimeManifestError::RegistryBlock);
3732 }
3733 check_no_host_paths(&raw)?;
3735
3736 let unknown_keys = collect_unknown_keys(&raw);
3739
3740 if raw.mur_version.is_some() {
3741 return Err(RuntimeManifestError::TopLevelMurVersion);
3742 }
3743
3744 let name = raw.name.filter(|s| !s.trim().is_empty()).ok_or_else(|| {
3745 RuntimeManifestError::MissingField {
3746 field: "name".to_string(),
3747 }
3748 })?;
3749
3750 let version = raw
3751 .version
3752 .filter(|s| !s.trim().is_empty())
3753 .ok_or_else(|| RuntimeManifestError::MissingField {
3754 field: "version".to_string(),
3755 })?;
3756
3757 let gateway_references = raw_gateway_references(&raw.artifacts);
3760
3761 let artifacts = raw
3762 .artifacts
3763 .into_iter()
3764 .enumerate()
3765 .map(|(index, artifact)| {
3766 let name = artifact
3767 .name
3768 .filter(|s| !s.trim().is_empty())
3769 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3770 index,
3771 message: "missing required field 'name'".to_string(),
3772 })?;
3773
3774 let source = artifact.source.filter(|s| !s.trim().is_empty());
3775 check_entry_name(index, &name, source.is_some())?;
3776
3777 let runtime = match artifact.runtime.as_deref() {
3778 None | Some("tool") => ArtifactRuntime::Tool,
3779 Some("wasm") | Some("native") => {
3780 return Err(RuntimeManifestError::InvalidArtifact {
3781 index,
3782 message: format!(
3783 "use 'runtime: tool'; implementation is declared in the \
3784 artifact's own manifest (got '{}')",
3785 artifact.runtime.as_deref().unwrap_or("")
3786 ),
3787 });
3788 }
3789 Some("driver") => ArtifactRuntime::Driver,
3790 Some("hook") => ArtifactRuntime::Hook,
3791 Some("skill") => ArtifactRuntime::Skill,
3792 Some(other) => {
3793 return Err(RuntimeManifestError::InvalidArtifact {
3794 index,
3795 message: format!(
3796 "unknown runtime '{other}'; expected one of: tool, driver, hook, skill"
3797 ),
3798 });
3799 }
3800 };
3801
3802 let local_source = artifact
3806 .local_source
3807 .unwrap_or(runtime == ArtifactRuntime::Skill);
3808 let prompt_payload = artifact
3809 .prompt_payload
3810 .unwrap_or(runtime == ArtifactRuntime::Skill);
3811
3812 if source.is_some() && !local_source {
3814 return Err(RuntimeManifestError::InvalidArtifact {
3815 index,
3816 message: format!(
3817 "artifact '{name}' declares 'source:' but does not declare \
3818 'local_source: true' (runtime: {})",
3819 runtime.as_str()
3820 ),
3821 });
3822 }
3823
3824 let version = if source.is_some() {
3827 if let Some(explicit) = artifact.version.filter(|s| !s.trim().is_empty()) {
3828 crate::warn_line(format_args!(
3829 "warning: artifact '{name}' declares both 'source:' and \
3830 'version: {explicit}'; version is ignored for local-source skills \
3831 (using 'local')"
3832 ));
3833 }
3834 "local".to_string()
3835 } else {
3836 artifact
3837 .version
3838 .filter(|s| !s.trim().is_empty())
3839 .ok_or_else(|| RuntimeManifestError::InvalidArtifact {
3840 index,
3841 message: "missing required field 'version'".to_string(),
3842 })?
3843 };
3844
3845 let capabilities = match artifact.capabilities {
3851 None => None,
3852 Some(raw_caps) => {
3853 if raw_caps.install.is_some() {
3856 return Err(RuntimeManifestError::InvalidArtifact {
3857 index,
3858 message: format!(
3859 "artifact '{name}' declares 'capabilities.install:'; the \
3860 grant is capsule-wide and is only recognized in the \
3861 top-level capabilities block"
3862 ),
3863 });
3864 }
3865 if runtime == ArtifactRuntime::Skill {
3866 return Err(RuntimeManifestError::InvalidArtifact {
3867 index,
3868 message: format!(
3869 "artifact '{name}' declares per-artifact 'capabilities:' but \
3870 has 'runtime: {}'; per-artifact capabilities are only \
3871 recognized on 'runtime: hook', 'runtime: tool', and \
3872 'runtime: driver' entries — use the capsule-wide top-level \
3873 'capabilities:' block instead",
3874 runtime.as_str()
3875 ),
3876 });
3877 }
3878 if raw_caps.task_io.is_some() && runtime != ArtifactRuntime::Hook {
3884 return Err(RuntimeManifestError::InvalidArtifact {
3885 index,
3886 message: format!(
3887 "artifact '{name}' declares 'capabilities.task_io:' but has \
3888 'runtime: {}'; the key grants the murmur:task-io/read host \
3889 import and is only recognized on 'runtime: hook' entries",
3890 runtime.as_str()
3891 ),
3892 });
3893 }
3894 if let Some(filesystem) = raw_caps
3898 .filesystem
3899 .as_ref()
3900 .filter(|filesystem| filesystem.records)
3901 {
3902 if runtime != ArtifactRuntime::Hook {
3903 return Err(RuntimeManifestError::InvalidArtifact {
3904 index,
3905 message: format!(
3906 "artifact '{name}' declares \
3907 'capabilities.filesystem.records: true' but has \
3908 'runtime: {}'; the key gives a hook the records \
3909 directory's hooks/ and is only recognized on \
3910 'runtime: hook' entries",
3911 runtime.as_str()
3912 ),
3913 });
3914 }
3915 if filesystem
3916 .scope
3917 .as_deref()
3918 .is_some_and(|scope| !scope.trim().is_empty())
3919 {
3920 return Err(RuntimeManifestError::InvalidArtifact {
3921 index,
3922 message: format!(
3923 "artifact '{name}' declares both \
3924 'capabilities.filesystem.records: true' and \
3925 'capabilities.filesystem.scope'; a hook's \".\" is \
3926 either the records directory's hooks/ or a workdir \
3927 scope — declare one"
3928 ),
3929 });
3930 }
3931 }
3932 if raw_caps.conversation.is_some() && runtime != ArtifactRuntime::Hook {
3936 return Err(RuntimeManifestError::InvalidArtifact {
3937 index,
3938 message: format!(
3939 "artifact '{name}' declares 'capabilities.conversation:' but \
3940 has 'runtime: {}'; the key grants the \
3941 murmur:conversation/read host import and is only recognized \
3942 on 'runtime: hook' entries",
3943 runtime.as_str()
3944 ),
3945 });
3946 }
3947 parse_capabilities(Some(raw_caps))?
3948 }
3949 };
3950
3951 let on_overflow = match artifact.on_overflow.as_deref() {
3957 None => HookOverflowPolicy::default(),
3958 Some(raw) => {
3959 if runtime != ArtifactRuntime::Hook {
3960 return Err(RuntimeManifestError::InvalidArtifact {
3961 index,
3962 message: format!(
3963 "artifact '{name}' declares 'on_overflow:' but has \
3964 'runtime: {}'; the key governs an async hook's job queue \
3965 and is only recognized on 'runtime: hook' entries",
3966 runtime.as_str()
3967 ),
3968 });
3969 }
3970 match raw {
3971 "drop" => HookOverflowPolicy::Drop,
3972 "block" => HookOverflowPolicy::Block,
3973 other => {
3974 return Err(RuntimeManifestError::InvalidArtifact {
3975 index,
3976 message: format!(
3977 "artifact '{name}' declares unknown on_overflow \
3978 '{other}'; expected: drop, block"
3979 ),
3980 });
3981 }
3982 }
3983 }
3984 };
3985
3986 if artifact.config.is_some() && runtime == ArtifactRuntime::Skill {
3993 return Err(RuntimeManifestError::InvalidArtifact {
3994 index,
3995 message: format!(
3996 "artifact '{name}' declares 'config:' but has 'runtime: {}'; the key \
3997 is recognized only on 'runtime: hook', 'runtime: tool', and \
3998 'runtime: driver' entries",
3999 runtime.as_str()
4000 ),
4001 });
4002 }
4003
4004 let gateway = match artifact.gateway {
4005 None => None,
4006 Some(raw_gateway) => {
4007 if runtime == ArtifactRuntime::Skill {
4008 return Err(RuntimeManifestError::InvalidArtifact {
4009 index,
4010 message: format!(
4011 "artifact '{name}' declares 'gateway:' but has 'runtime: {}'; \
4012 the key is recognized only on 'runtime: hook', \
4013 'runtime: tool', and 'runtime: driver' entries",
4014 runtime.as_str()
4015 ),
4016 });
4017 }
4018 Some(parse_artifact_gateway(index, &name, raw_gateway, secrets)?)
4019 }
4020 };
4021
4022 Ok(RuntimeArtifact {
4023 name,
4024 version,
4025 runtime,
4026 source,
4027 local_source,
4028 prompt_payload,
4029 capabilities,
4030 on_overflow,
4031 config: artifact.config,
4032 gateway,
4033 })
4034 })
4035 .collect::<Result<Vec<_>, RuntimeManifestError>>()?;
4036
4037 if raw
4041 .capabilities
4042 .as_ref()
4043 .is_some_and(|caps| caps.task_io.is_some())
4044 {
4045 return Err(RuntimeManifestError::InvalidCapabilities {
4046 field: "capabilities.task_io".to_string(),
4047 message: "is recognized only on 'runtime: hook' artifact entries, not in the \
4048 capsule-wide capabilities block — the grant is per-hook"
4049 .to_string(),
4050 });
4051 }
4052 if raw
4055 .capabilities
4056 .as_ref()
4057 .and_then(|caps| caps.filesystem.as_ref())
4058 .is_some_and(|filesystem| filesystem.records)
4059 {
4060 return Err(RuntimeManifestError::InvalidCapabilities {
4061 field: "capabilities.filesystem.records".to_string(),
4062 message: "is recognized only on 'runtime: hook' artifact entries, not in the \
4063 capsule-wide capabilities block — the grant is per-hook"
4064 .to_string(),
4065 });
4066 }
4067 if raw.config.is_some() {
4072 return Err(RuntimeManifestError::InvalidCapabilities {
4073 field: "config".to_string(),
4074 message: "is declared per artifact, on the 'runtime: hook', 'runtime: tool' or \
4075 'runtime: driver' entry that reads it — there is no capsule-wide \
4076 config block"
4077 .to_string(),
4078 });
4079 }
4080 let capabilities = parse_capabilities(raw.capabilities)?;
4081 let inference = parse_inference(raw.inference)?;
4082 refuse_install_under_process_transport(capabilities.as_ref(), inference.as_ref())?;
4083 validate_driver_gateways(&artifacts, inference.as_ref())?;
4084 validate_driver_choices(&artifacts, inference.as_ref())?;
4085 validate_process_driver_entry(&artifacts, inference.as_ref())?;
4086
4087 if let Some(ref sp_art) = inference
4090 .as_ref()
4091 .and_then(|i| i.system_prompt_artifact.clone())
4092 {
4093 let matching = artifacts.iter().find(|a| &a.name == sp_art);
4094 match matching {
4095 None => {
4096 return Err(RuntimeManifestError::InvalidInferenceConfig {
4097 field: "inference.system_prompt_artifact".to_string(),
4098 message: not_declared_message(&artifacts, sp_art),
4099 });
4100 }
4101 Some(a) if !a.prompt_payload => {
4102 return Err(RuntimeManifestError::InvalidInferenceConfig {
4103 field: "inference.system_prompt_artifact".to_string(),
4104 message: format!(
4105 "artifact '{sp_art}' (runtime: {}) does not declare \
4106 'prompt_payload: true', which system_prompt_artifact requires",
4107 a.runtime.as_str()
4108 ),
4109 });
4110 }
4111 Some(_) => {} }
4113 }
4114 let context = parse_context(raw.context)?;
4115 let observability = parse_observability(raw.observability);
4116 let trace = raw
4117 .trace
4118 .map(|raw_trace| {
4119 let capture = resolve_trace_capture(raw_trace.capture.as_deref())?;
4120 let retain = parse_trace_retain(raw_trace.retain)?;
4121 Ok::<_, RuntimeManifestError>(TraceConfig { capture, retain })
4122 })
4123 .transpose()?;
4124 let network = raw
4125 .network
4126 .map(|n| {
4127 Ok::<_, RuntimeManifestError>(NetworkConfig {
4128 internal_port: n.internal_port,
4129 authentication: parse_network_authentication(n.authentication)?,
4130 })
4131 })
4132 .transpose()?;
4133 refuse_authenticated_delegating_parent(network.as_ref(), capabilities.as_ref())?;
4134 let lifecycle = raw
4135 .lifecycle
4136 .map(|raw_lc| {
4137 let defaults = LifecycleConfig::default();
4138 let lifecycle = LifecycleConfig {
4139 task_acceptance: raw_lc.task_acceptance.unwrap_or(defaults.task_acceptance),
4140 after_task: raw_lc.after_task.unwrap_or(defaults.after_task),
4141 queue_depth: raw_lc.queue_depth.unwrap_or(defaults.queue_depth),
4142 input_timeout_secs: raw_lc.input_timeout_secs,
4143 conversation_mode: raw_lc.conversation.unwrap_or(defaults.conversation_mode),
4144 max_task_reopens: raw_lc.max_task_reopens.unwrap_or(defaults.max_task_reopens),
4145 shell_grace_secs: raw_lc.shell_grace_secs,
4146 delegation_deadline_secs: raw_lc
4147 .delegation_deadline_secs
4148 .unwrap_or(defaults.delegation_deadline_secs),
4149 deadline_notice_secs: raw_lc.deadline_notice_secs,
4150 keep_working: raw_lc.keep_working.map(parse_keep_working).transpose()?,
4151 done_check: raw_lc
4152 .done_check
4153 .map(|raw| {
4154 let shell_allow = capabilities
4155 .as_ref()
4156 .and_then(|capabilities| capabilities.shell.as_ref())
4157 .map_or(&[][..], |shell| shell.allow.as_slice());
4158 parse_done_check(raw, shell_allow)
4159 })
4160 .transpose()?,
4161 };
4162 if lifecycle.queue_has_room() {
4163 Ok(lifecycle)
4164 } else {
4165 Err(RuntimeManifestError::QueueWithoutRoom)
4166 }
4167 })
4168 .transpose()?;
4169
4170 let exports = parse_exports(raw.exports)?;
4171 let control = parse_control(raw.control, &gateway_references, inference.as_ref())?;
4172 validate_alternates_selectable(inference.as_ref(), control.as_ref())?;
4173
4174 Ok(Self {
4175 name,
4176 version,
4177 artifacts,
4178 capabilities,
4179 inference,
4180 context,
4181 observability,
4182 trace,
4183 network,
4184 lifecycle,
4185 exports,
4186 control,
4187 deploy: raw.deploy.map(|deploy| DeployConfig {
4188 mur_version: deploy.mur_version.filter(|s| !s.trim().is_empty()),
4189 }),
4190 unknown_keys,
4191 })
4192 }
4193}
4194
4195fn raw_gateway_references(artifacts: &[RawArtifact]) -> Vec<(String, String)> {
4197 artifacts
4198 .iter()
4199 .filter_map(|artifact| {
4200 let name = artifact.name.as_deref()?.trim();
4201 let written = artifact
4202 .gateway
4203 .as_ref()?
4204 .as_ref()?
4205 .api_key
4206 .as_ref()?
4207 .as_ref()?;
4208 let variable = parse_env_reference(written.trim())?;
4209 Some((name.to_string(), variable.to_string()))
4210 })
4211 .collect()
4212}
4213
4214fn parse_control(
4221 raw: Option<RawControl>,
4222 gateway_references: &[(String, String)],
4223 inference: Option<&InferenceConfig>,
4224) -> Result<Option<ControlConfig>, RuntimeManifestError> {
4225 let Some(raw) = raw else {
4226 return Ok(None);
4227 };
4228 let refuse = |field: &str, message: String| RuntimeManifestError::InvalidControl {
4229 field: field.to_string(),
4230 message,
4231 };
4232 let settings_written = control_string_list("control.settings", raw.settings)?;
4233 let secrets_written = control_string_list("control.secrets", raw.secrets)?;
4234 let agent_settings_written = control_string_list("control.agent_settings", raw.agent_settings)?;
4235 if settings_written.is_empty()
4236 && secrets_written.is_empty()
4237 && agent_settings_written.is_empty()
4238 {
4239 return Ok(None);
4240 }
4241 let Some(inference) = inference else {
4242 return Err(refuse(
4243 "control",
4244 "is declared on a capsule with no inference: block; the control surface is served on \
4245 an agent capsule's A2A listener, and a script capsule opens none"
4246 .to_string(),
4247 ));
4248 };
4249
4250 let mut settings = Vec::new();
4251 for entry in settings_written {
4252 let Some(setting) = ControllableSetting::from_wire_name(&entry) else {
4253 let controllable: Vec<&str> = ControllableSetting::ALL
4254 .iter()
4255 .map(|setting| setting.wire_name())
4256 .collect();
4257 return Err(refuse(
4258 "control.settings",
4259 format!(
4260 "'{entry}' is not a controllable setting; the controllable settings are: {}",
4261 controllable.join(", ")
4262 ),
4263 ));
4264 };
4265 if settings.contains(&setting) {
4266 return Err(refuse(
4267 "control.settings",
4268 format!("'{entry}' is listed more than once"),
4269 ));
4270 }
4271 match setting {
4272 ControllableSetting::InferenceMaxTokens if inference.transport != "http" => {
4273 return Err(refuse(
4274 "control.settings",
4275 format!(
4276 "'{entry}' is controllable only under inference.transport: http; a \
4277 transport: {} harness sets its own output cap",
4278 inference.transport
4279 ),
4280 ));
4281 }
4282 ControllableSetting::InferenceMaxTokens => {}
4283 ControllableSetting::InferenceDriver => {
4284 check_driver_setting(inference).map_err(|message| {
4285 refuse("control.settings", format!("'{entry}' {message}"))
4286 })?;
4287 }
4288 }
4289 settings.push(setting);
4290 }
4291
4292 let mut agent_settings = Vec::new();
4293 for entry in agent_settings_written {
4294 let Some(setting) = ControllableSetting::from_wire_name(&entry)
4295 .filter(|setting| ControllableSetting::AGENT_SETTABLE.contains(setting))
4296 else {
4297 let settable: Vec<&str> = ControllableSetting::AGENT_SETTABLE
4298 .iter()
4299 .map(|setting| setting.wire_name())
4300 .collect();
4301 return Err(refuse(
4302 "control.agent_settings",
4303 format!(
4304 "'{entry}' is not a setting the agent may change; control.agent_settings \
4305 accepts only: {}",
4306 settable.join(", ")
4307 ),
4308 ));
4309 };
4310 if agent_settings.contains(&setting) {
4311 return Err(refuse(
4312 "control.agent_settings",
4313 format!("'{entry}' is listed more than once"),
4314 ));
4315 }
4316 check_driver_setting(inference)
4317 .map_err(|message| refuse("control.agent_settings", format!("'{entry}' {message}")))?;
4318 agent_settings.push(setting);
4319 }
4320
4321 let inference_driver = (inference.transport == "http")
4322 .then(|| {
4323 inference
4324 .driver
4325 .as_ref()
4326 .map(|driver| driver.artifact.as_str())
4327 })
4328 .flatten();
4329 let mut secrets: Vec<String> = Vec::new();
4330 for entry in secrets_written {
4331 if !is_valid_env_variable(&entry) {
4332 return Err(refuse(
4333 "control.secrets",
4334 format!(
4335 "'{entry}' is not a credential name; a name is uppercase letters, digits and \
4336 underscores, and does not start with a digit"
4337 ),
4338 ));
4339 }
4340 if secrets.contains(&entry) {
4341 return Err(refuse(
4342 "control.secrets",
4343 format!("'{entry}' is listed more than once"),
4344 ));
4345 }
4346 let backing: Vec<&str> = gateway_references
4347 .iter()
4348 .filter(|(_, variable)| *variable == entry)
4349 .map(|(artifact, _)| artifact.as_str())
4350 .collect();
4351 if backing.is_empty() {
4352 return Err(refuse(
4353 "control.secrets",
4354 format!(
4355 "'{entry}' is not referenced by any artifact's gateway.api_key as \
4356 ${{{entry}}}; a controller-supplied secret must key some artifact's gateway"
4357 ),
4358 ));
4359 }
4360 if let Some(driver) = inference_driver.filter(|driver| backing.contains(driver)) {
4361 return Err(refuse(
4362 "control.secrets",
4363 format!(
4364 "'{entry}' keys the configured inference driver's gateway \
4365 (artifacts.{driver}.gateway.api_key); the inference credential is resolved \
4366 at launch and cannot be supplied by a controller"
4367 ),
4368 ));
4369 }
4370 secrets.push(entry);
4371 }
4372
4373 Ok(Some(ControlConfig {
4374 settings,
4375 secrets,
4376 agent_settings,
4377 }))
4378}
4379
4380fn check_driver_setting(inference: &InferenceConfig) -> Result<(), String> {
4383 if inference.transport != "http" {
4384 return Err(format!(
4385 "is controllable only under inference.transport: http; a transport: {} harness owns \
4386 the model it runs",
4387 inference.transport
4388 ));
4389 }
4390 if inference.alternates.is_empty() {
4391 return Err(
4392 "selects among inference.alternates, and this capsule declares none; declare an \
4393 alternate to switch to"
4394 .to_string(),
4395 );
4396 }
4397 Ok(())
4398}
4399
4400fn control_string_list(
4402 field: &str,
4403 value: Option<serde_norway::Value>,
4404) -> Result<Vec<String>, RuntimeManifestError> {
4405 let refuse = || RuntimeManifestError::InvalidControl {
4406 field: field.to_string(),
4407 message: "must be a list of names".to_string(),
4408 };
4409 let items = match value {
4410 None | Some(serde_norway::Value::Null) => return Ok(Vec::new()),
4411 Some(serde_norway::Value::Sequence(items)) => items,
4412 Some(_) => return Err(refuse()),
4413 };
4414 items
4415 .into_iter()
4416 .map(|item| match item {
4417 serde_norway::Value::String(name) => Ok(name.trim().to_string()),
4418 _ => Err(refuse()),
4419 })
4420 .collect()
4421}
4422
4423fn parse_network_authentication(
4425 raw: Option<RawNetworkAuthentication>,
4426) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
4427 let Some(raw) = raw else {
4428 return Ok(None);
4429 };
4430 let refuse = |field: &str, message: String| RuntimeManifestError::InvalidNetworkConfig {
4431 field: field.to_string(),
4432 message,
4433 };
4434 let schemes: Vec<&str> = AuthenticationScheme::ALL
4435 .iter()
4436 .map(|scheme| scheme.wire_name())
4437 .collect();
4438 let scheme = match raw.scheme.as_deref().map(str::trim) {
4439 None | Some("") => {
4440 return Err(refuse(
4441 "network.authentication.scheme",
4442 format!("is required; the schemes are: {}", schemes.join(", ")),
4443 ))
4444 }
4445 Some(written) => AuthenticationScheme::ALL
4446 .iter()
4447 .copied()
4448 .find(|scheme| scheme.wire_name() == written)
4449 .ok_or_else(|| {
4450 refuse(
4451 "network.authentication.scheme",
4452 format!(
4453 "'{written}' is not a door authentication scheme; the schemes are: {}",
4454 schemes.join(", ")
4455 ),
4456 )
4457 })?,
4458 };
4459
4460 let mut credentials = Vec::new();
4461 for (name, credential) in raw.credentials.unwrap_or_default() {
4462 if name == OPERATOR_CREDENTIAL {
4463 return Err(refuse(
4464 "network.authentication.credentials",
4465 format!(
4466 "'{OPERATOR_CREDENTIAL}' is reserved: the runtime always mints the operator \
4467 token, which holds every scope"
4468 ),
4469 ));
4470 }
4471 if !is_valid_credential_name(&name) {
4472 return Err(refuse(
4473 "network.authentication.credentials",
4474 format!(
4475 "'{name}' is not a credential name; a name is a lowercase letter followed by \
4476 at most {} lowercase letters, digits, '_' or '-'",
4477 MAX_CREDENTIAL_NAME_LEN - 1
4478 ),
4479 ));
4480 }
4481 let field = format!("network.authentication.credentials.{name}.scopes");
4482 let scopes = door_scope_list(&field, credential.scopes)?;
4483 credentials.push(DoorCredential { name, scopes });
4484 }
4485
4486 Ok(Some(NetworkAuthentication {
4487 scheme,
4488 credentials,
4489 }))
4490}
4491
4492fn is_valid_credential_name(name: &str) -> bool {
4494 let mut chars = name.chars();
4495 chars.next().is_some_and(|first| first.is_ascii_lowercase())
4496 && name.len() <= MAX_CREDENTIAL_NAME_LEN
4497 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
4498}
4499
4500fn door_scope_list(
4502 field: &str,
4503 value: Option<serde_norway::Value>,
4504) -> Result<Vec<String>, RuntimeManifestError> {
4505 let refuse = |message: String| RuntimeManifestError::InvalidNetworkConfig {
4506 field: field.to_string(),
4507 message,
4508 };
4509 let items = match value {
4510 None | Some(serde_norway::Value::Null) => {
4511 return Err(refuse(
4512 "is required: a credential reaches only the scopes it lists".to_string(),
4513 ))
4514 }
4515 Some(serde_norway::Value::Sequence(items)) => items,
4516 Some(_) => return Err(refuse("must be a list of scopes".to_string())),
4517 };
4518 if items.is_empty() {
4519 return Err(refuse(
4520 "lists no scope; a credential must reach at least one".to_string(),
4521 ));
4522 }
4523 let mut scopes: Vec<String> = Vec::new();
4524 for item in items {
4525 let serde_norway::Value::String(scope) = item else {
4526 return Err(refuse("must be a list of scopes".to_string()));
4527 };
4528 let scope = scope.trim().to_string();
4529 if let Some(&(_, replacement)) = RETIRED_DOOR_SCOPES
4530 .iter()
4531 .find(|(retired, _)| *retired == scope)
4532 {
4533 return Err(RuntimeManifestError::RetiredDoorScope {
4534 field: field.to_string(),
4535 scope,
4536 replacement,
4537 });
4538 }
4539 if !DOOR_SCOPES.contains(&scope.as_str()) {
4540 return Err(refuse(format!(
4541 "'{scope}' is not a door scope; the scopes are: {}",
4542 DOOR_SCOPES.join(", ")
4543 )));
4544 }
4545 if scopes.contains(&scope) {
4546 return Err(refuse(format!("'{scope}' is listed more than once")));
4547 }
4548 scopes.push(scope);
4549 }
4550 Ok(scopes)
4551}
4552
4553fn refuse_authenticated_delegating_parent(
4558 network: Option<&NetworkConfig>,
4559 capabilities: Option<&Capabilities>,
4560) -> Result<(), RuntimeManifestError> {
4561 let authenticated = network.is_some_and(|network| network.authentication.is_some());
4562 let delegates = capabilities
4563 .and_then(|capabilities| capabilities.spawn.as_ref())
4564 .is_some_and(|spawn| !spawn.allow.is_empty());
4565 if authenticated && delegates {
4566 return Err(RuntimeManifestError::InvalidNetworkConfig {
4567 field: "network.authentication".to_string(),
4568 message: "cannot be declared together with capabilities.spawn.allow: a delegated \
4569 child posts its outcome to this door, and a delegated child cannot yet be \
4570 issued a token for this door"
4571 .to_string(),
4572 });
4573 }
4574 Ok(())
4575}
4576
4577fn parse_exports(raw: Option<RawExports>) -> Result<Option<Exports>, RuntimeManifestError> {
4584 let Some(raw_exports) = raw else {
4585 return Ok(None);
4586 };
4587 let files = raw_exports.files.map(parse_file_export).transpose()?;
4588 let peer_files = raw_exports
4589 .peer_files
4590 .map(parse_peer_files_export)
4591 .transpose()?;
4592 let peer_tasks = raw_exports
4593 .peer_tasks
4594 .map(|raw| {
4595 raw.accept
4596 .map(|accept| PeerTasksExport { accept })
4597 .ok_or_else(|| RuntimeManifestError::InvalidExports {
4598 field: "exports.peer_tasks.accept".to_string(),
4599 message: "must be set explicitly to true or false — consent to peer tasks is \
4600 never inferred"
4601 .to_string(),
4602 })
4603 })
4604 .transpose()?;
4605 Ok(Some(Exports {
4606 files,
4607 peer_files,
4608 peer_tasks,
4609 }))
4610}
4611
4612fn parse_peer_files_export(
4620 raw: RawPeerFilesExport,
4621) -> Result<PeerFilesExport, RuntimeManifestError> {
4622 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4623 field: field.to_string(),
4624 message,
4625 };
4626
4627 let root = raw
4628 .root
4629 .map(|root| root.trim().to_string())
4630 .filter(|root| !root.is_empty())
4631 .ok_or_else(|| {
4632 invalid(
4633 "exports.peer_files.root",
4634 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4635 )
4636 })?;
4637 check_export_root_shape("exports.peer_files.root", &root)?;
4638
4639 let max_ttl_secs = match raw.max_ttl {
4640 None => None,
4641 Some(value) => {
4642 let text = scalar_text(&value).ok_or_else(|| {
4643 invalid(
4644 "exports.peer_files.max_ttl",
4645 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
4646 )
4647 })?;
4648 let parsed = parse_duration_secs(&text)
4649 .map_err(|message| invalid("exports.peer_files.max_ttl", message))?;
4650 if parsed == 0 {
4651 return Err(invalid(
4652 "exports.peer_files.max_ttl",
4653 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
4654 ));
4655 }
4656 Some(parsed)
4657 }
4658 };
4659
4660 let max_bytes = match raw.max_bytes {
4661 None => DEFAULT_PEER_FILES_MAX_BYTES,
4662 Some(value) => {
4663 let text = scalar_text(&value).ok_or_else(|| {
4664 invalid(
4665 "exports.peer_files.max_bytes",
4666 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4667 )
4668 })?;
4669 let parsed = parse_byte_size(&text)
4670 .map_err(|message| invalid("exports.peer_files.max_bytes", message))?;
4671 if parsed == 0 {
4672 return Err(invalid(
4673 "exports.peer_files.max_bytes",
4674 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4675 ));
4676 }
4677 parsed
4678 }
4679 };
4680
4681 Ok(PeerFilesExport {
4682 root,
4683 max_ttl_secs,
4684 max_bytes,
4685 })
4686}
4687
4688fn present_yaml_value<'de, D>(deserializer: D) -> Result<Option<serde_norway::Value>, D::Error>
4696where
4697 D: serde::Deserializer<'de>,
4698{
4699 serde_norway::Value::deserialize(deserializer).map(Some)
4700}
4701
4702fn scalar_text(value: &serde_norway::Value) -> Option<String> {
4703 match value {
4704 serde_norway::Value::String(text) => Some(text.clone()),
4705 serde_norway::Value::Number(number) => Some(number.to_string()),
4706 _ => None,
4707 }
4708}
4709
4710enum RelativePathRefusal {
4712 ParentDir,
4713 Absolute,
4714}
4715
4716fn relative_path_refusal(path: &str) -> Option<RelativePathRefusal> {
4719 Path::new(path)
4720 .components()
4721 .find_map(|component| match component {
4722 std::path::Component::Normal(_) | std::path::Component::CurDir => None,
4723 std::path::Component::ParentDir => Some(RelativePathRefusal::ParentDir),
4724 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
4725 Some(RelativePathRefusal::Absolute)
4726 }
4727 })
4728}
4729
4730fn check_export_root_shape(field: &str, root: &str) -> Result<(), RuntimeManifestError> {
4734 let message = match relative_path_refusal(root) {
4735 None => return Ok(()),
4736 Some(RelativePathRefusal::ParentDir) => {
4737 format!("'{root}' contains a '..' component; {EXPORT_ROOT_ACCEPTED_FORM}")
4738 }
4739 Some(RelativePathRefusal::Absolute) => {
4740 format!("'{root}' is absolute; {EXPORT_ROOT_ACCEPTED_FORM}")
4741 }
4742 };
4743 Err(RuntimeManifestError::InvalidExports {
4744 field: field.to_string(),
4745 message,
4746 })
4747}
4748
4749const INSTRUCTION_FILE_ACCEPTED_FORM: &str = "must be a relative path inside the work folder";
4750
4751fn parse_instruction_files(entries: Vec<String>) -> Result<Vec<String>, RuntimeManifestError> {
4755 for (index, entry) in entries.iter().enumerate() {
4756 let problem = if entry.trim().is_empty() {
4757 Some("is empty")
4758 } else {
4759 match relative_path_refusal(entry) {
4760 None => None,
4761 Some(RelativePathRefusal::ParentDir) => Some("contains a '..' component"),
4762 Some(RelativePathRefusal::Absolute) => Some("is absolute"),
4763 }
4764 };
4765 if let Some(problem) = problem {
4766 return Err(RuntimeManifestError::InvalidInferenceConfig {
4767 field: format!("inference.instruction_files[{index}]"),
4768 message: format!("'{entry}' {problem}; {INSTRUCTION_FILE_ACCEPTED_FORM}"),
4769 });
4770 }
4771 }
4772 Ok(entries)
4773}
4774
4775fn parse_file_export(raw: RawFileExport) -> Result<FileExport, RuntimeManifestError> {
4776 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidExports {
4777 field: field.to_string(),
4778 message,
4779 };
4780
4781 let root = raw
4782 .root
4783 .map(|root| root.trim().to_string())
4784 .filter(|root| !root.is_empty())
4785 .ok_or_else(|| {
4786 invalid(
4787 "exports.files.root",
4788 format!("is required and {EXPORT_ROOT_ACCEPTED_FORM}"),
4789 )
4790 })?;
4791 check_export_root_shape("exports.files.root", &root)?;
4792
4793 let mode = match raw.mode.as_deref().map(str::trim) {
4794 None | Some("") => {
4795 return Err(invalid(
4796 "exports.files.mode",
4797 "is required and must be 'read-only'".to_string(),
4798 ));
4799 }
4800 Some("read-only") => ExportMode::ReadOnly,
4801 Some(other) => {
4802 return Err(invalid(
4803 "exports.files.mode",
4804 format!("'{other}' must be 'read-only'"),
4805 ));
4806 }
4807 };
4808
4809 let max_bytes = match raw.max_bytes {
4810 None => DEFAULT_EXPORT_MAX_BYTES,
4811 Some(value) => {
4812 let Some(text) = scalar_text(&value) else {
4813 return Err(invalid(
4814 "exports.files.max_bytes",
4815 format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}"),
4816 ));
4817 };
4818 let parsed = parse_byte_size(&text)
4819 .map_err(|message| invalid("exports.files.max_bytes", message))?;
4820 if parsed == 0 {
4821 return Err(invalid(
4822 "exports.files.max_bytes",
4823 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
4824 ));
4825 }
4826 parsed
4827 }
4828 };
4829
4830 Ok(FileExport {
4831 root,
4832 mode,
4833 max_bytes,
4834 })
4835}
4836
4837const EXPORT_ROOT_ACCEPTED_FORM: &str = "must be a relative path inside the workdir";
4840
4841pub const PEER_FETCH_ALLOW_ACCEPTED_FORM: &str = "must be a non-empty list of network destinations";
4843
4844fn check_network_destination_shape(entry: &str) -> Result<(), String> {
4851 let trimmed = entry.trim();
4852 if trimmed.is_empty() {
4853 return Err(format!(
4854 "'{entry}' is empty; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4855 ));
4856 }
4857
4858 if trimmed.contains("://") {
4859 let url = Url::parse(trimmed).map_err(|error| {
4860 format!("'{entry}' is not a valid URL ({error}); {PEER_FETCH_ALLOW_ACCEPTED_FORM}")
4861 })?;
4862 let scheme = url.scheme().to_ascii_lowercase();
4863 if scheme != "http" && scheme != "https" {
4864 return Err(format!(
4865 "'{entry}' has unsupported scheme '{scheme}' (expected http or https); \
4866 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4867 ));
4868 }
4869 if url.host_str().is_none_or(str::is_empty) {
4870 return Err(format!(
4871 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4872 ));
4873 }
4874 if url.query().is_some() || url.fragment().is_some() || !matches!(url.path(), "" | "/") {
4875 return Err(format!(
4876 "'{entry}' must not include a path, query or fragment; \
4877 {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4878 ));
4879 }
4880 return Ok(());
4881 }
4882
4883 if trimmed.contains('/') || trimmed.split_whitespace().count() != 1 {
4884 return Err(format!(
4885 "'{entry}' is not a host[:port]; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4886 ));
4887 }
4888 if let Some((host, port)) = trimmed.rsplit_once(':') {
4890 if !host.ends_with(']') {
4891 if host.is_empty() {
4892 return Err(format!(
4893 "'{entry}' names no host; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4894 ));
4895 }
4896 if port.parse::<u16>().is_err() {
4897 return Err(format!(
4898 "'{entry}' has an unparseable port '{port}'; {PEER_FETCH_ALLOW_ACCEPTED_FORM}"
4899 ));
4900 }
4901 }
4902 }
4903 Ok(())
4904}
4905
4906pub const HOST_PATHS_KEY: &str = "host_paths";
4909
4910fn check_no_host_paths(raw: &RawRuntimeManifest) -> Result<(), RuntimeManifestError> {
4914 fn in_capabilities(capabilities: Option<&RawCapabilities>, prefix: &str) -> Option<String> {
4915 let capabilities = capabilities?;
4916 if capabilities.unknown.contains_key(HOST_PATHS_KEY) {
4917 return Some(format!("{prefix}.{HOST_PATHS_KEY}"));
4918 }
4919 capabilities
4920 .filesystem
4921 .as_ref()
4922 .filter(|filesystem| filesystem.unknown.contains_key(HOST_PATHS_KEY))
4923 .map(|_| format!("{prefix}.filesystem.{HOST_PATHS_KEY}"))
4924 }
4925 let field = if raw.unknown.contains_key(HOST_PATHS_KEY) {
4926 Some(HOST_PATHS_KEY.to_string())
4927 } else {
4928 in_capabilities(raw.capabilities.as_ref(), "capabilities").or_else(|| {
4929 raw.artifacts
4930 .iter()
4931 .enumerate()
4932 .find_map(|(index, artifact)| {
4933 in_capabilities(
4934 artifact.capabilities.as_ref(),
4935 &format!("artifacts[{index}].capabilities"),
4936 )
4937 })
4938 })
4939 };
4940 match field {
4941 Some(field) => Err(RuntimeManifestError::HostPathsDeclared { field }),
4942 None => Ok(()),
4943 }
4944}
4945
4946fn parse_capabilities(
4947 raw: Option<RawCapabilities>,
4948) -> Result<Option<Capabilities>, RuntimeManifestError> {
4949 let Some(raw_caps) = raw else {
4950 return Ok(None);
4951 };
4952
4953 let network = raw_caps.network.map(|raw_network| NetworkCapabilities {
4954 allow: raw_network.allow,
4955 unix_sockets: raw_network.unix_sockets,
4956 });
4957
4958 let peer_fetch = raw_caps
4959 .peer_fetch
4960 .map(|raw_peer_fetch| {
4961 if raw_peer_fetch.allow.is_empty() {
4962 return Err(RuntimeManifestError::InvalidCapabilities {
4963 field: "capabilities.peer_fetch.allow".to_string(),
4964 message: PEER_FETCH_ALLOW_ACCEPTED_FORM.to_string(),
4965 });
4966 }
4967 for entry in &raw_peer_fetch.allow {
4968 check_network_destination_shape(entry).map_err(|message| {
4969 RuntimeManifestError::InvalidCapabilities {
4970 field: "capabilities.peer_fetch.allow".to_string(),
4971 message,
4972 }
4973 })?;
4974 }
4975 Ok(PeerFetchCapabilities {
4976 allow: raw_peer_fetch.allow,
4977 })
4978 })
4979 .transpose()?;
4980
4981 let filesystem = raw_caps
4982 .filesystem
4983 .map(|raw_filesystem| FilesystemCapabilities {
4984 scope: raw_filesystem.scope.and_then(|scope| {
4985 let trimmed = scope.trim();
4986 (!trimmed.is_empty()).then(|| trimmed.to_string())
4987 }),
4988 workdir_exec: raw_filesystem.workdir_exec,
4993 read_only: raw_filesystem
4997 .read_only
4998 .into_iter()
4999 .filter_map(|entry| {
5000 let trimmed = entry.trim();
5001 (!trimmed.is_empty()).then(|| trimmed.to_string())
5002 })
5003 .collect(),
5004 records: raw_filesystem.records,
5006 });
5007
5008 let shell = raw_caps
5009 .shell
5010 .map(|raw_shell| {
5011 if raw_shell.allow.is_empty() {
5012 return Err(RuntimeManifestError::InvalidCapabilities {
5013 field: "capabilities.shell.allow".to_string(),
5014 message: "must contain at least one binary".to_string(),
5015 });
5016 }
5017 for (index, entry) in raw_shell.allow.iter().enumerate() {
5018 if let Some(reason) = shell_allow_entry_error(entry) {
5019 return Err(RuntimeManifestError::ShellAllowEntry {
5020 index,
5021 entry: entry.clone(),
5022 reason,
5023 });
5024 }
5025 }
5026
5027 let interpreter_runtime =
5028 parse_interpreter_runtime(&raw_shell.allow, raw_shell.interpreter_runtime)?;
5029 let staged_runtime = parse_staged_runtime(
5032 &raw_shell.allow,
5033 &interpreter_runtime,
5034 raw_shell.staged_runtime,
5035 )?;
5036
5037 Ok(ShellCapabilities {
5038 allow: raw_shell.allow,
5039 strip_env: raw_shell.strip_env,
5040 baseline_env: raw_shell.baseline_env,
5041 interpreter_runtime,
5042 staged_runtime,
5043 })
5044 })
5045 .transpose()?;
5046
5047 let spawn = raw_caps
5048 .spawn
5049 .map(|raw_spawn| parse_spawn_allow(raw_spawn.allow, raw_spawn.workdir_snapshot))
5050 .transpose()?;
5051
5052 let env = raw_caps.env.map(|raw_env| EnvCapabilities {
5053 allow: raw_env.allow,
5054 });
5055
5056 let limits = raw_caps.limits.map(parse_resource_limits).transpose()?;
5057
5058 let resources = raw_caps
5059 .resources
5060 .map(parse_resource_capabilities)
5061 .transpose()?;
5062
5063 let state = raw_caps.state.map(parse_state_capabilities);
5064
5065 let task_io = raw_caps
5066 .task_io
5067 .map(|raw_task_io| {
5068 raw_task_io
5069 .read
5070 .map(|read| TaskIoCapabilities { read })
5071 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5072 field: "capabilities.task_io.read".to_string(),
5073 message: "must be set explicitly to true or false — a capability is never \
5074 inferred"
5075 .to_string(),
5076 })
5077 })
5078 .transpose()?;
5079
5080 let conversation = raw_caps
5081 .conversation
5082 .map(|raw_conversation| {
5083 raw_conversation
5084 .read
5085 .map(|read| ConversationCapabilities { read })
5086 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5087 field: "capabilities.conversation.read".to_string(),
5088 message: "must be set explicitly to true or false — a capability is never \
5089 inferred"
5090 .to_string(),
5091 })
5092 })
5093 .transpose()?;
5094
5095 let plan = raw_caps
5096 .plan
5097 .map(|raw_plan| {
5098 raw_plan
5099 .submit
5100 .map(|submit| PlanCapabilities { submit })
5101 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5102 field: "capabilities.plan.submit".to_string(),
5103 message: "must be set explicitly to true or false — a capability is never \
5104 inferred"
5105 .to_string(),
5106 })
5107 })
5108 .transpose()?;
5109
5110 let install = raw_caps.install.map(parse_install).transpose()?;
5111
5112 let containment = raw_caps
5113 .containment
5114 .as_deref()
5115 .map(str::trim)
5116 .filter(|value| !value.is_empty())
5117 .map(|value| {
5118 let class = value.parse::<ContainmentClass>().map_err(|error| {
5119 RuntimeManifestError::InvalidCapabilities {
5120 field: "capabilities.containment".to_string(),
5121 message: error.to_string(),
5122 }
5123 })?;
5124 if class == ContainmentClass::Unconfined {
5125 return Err(RuntimeManifestError::InvalidCapabilities {
5126 field: "capabilities.containment".to_string(),
5127 message: UNCONFINED_NOT_DECLARABLE.to_string(),
5128 });
5129 }
5130 Ok(class)
5131 })
5132 .transpose()?;
5133
5134 Ok(Some(Capabilities {
5135 network,
5136 peer_fetch,
5137 filesystem,
5138 shell,
5139 spawn,
5140 env,
5141 limits,
5142 resources,
5143 state,
5144 task_io,
5145 conversation,
5146 plan,
5147 install,
5148 containment,
5149 }))
5150}
5151
5152fn parse_spawn_allow(
5156 allow: Vec<String>,
5157 workdir_snapshot: Vec<String>,
5158) -> Result<SpawnCapabilities, RuntimeManifestError> {
5159 let mut parsed: Vec<ArtifactName> = Vec::with_capacity(allow.len());
5160 for (index, entry) in allow.iter().enumerate() {
5161 let name = ArtifactName::parse(entry).map_err(|error| {
5162 RuntimeManifestError::InvalidArtifactName {
5163 field: format!("capabilities.spawn.allow[{index}]"),
5164 message: error.to_string(),
5165 hint: error.hint(),
5166 }
5167 })?;
5168 if let Some(earlier) = parsed
5169 .iter()
5170 .find(|earlier| earlier.name() == name.name() && **earlier != name)
5171 {
5172 return Err(RuntimeManifestError::InvalidArtifactName {
5173 field: format!("capabilities.spawn.allow[{index}]"),
5174 message: format!(
5175 "'{earlier}' and '{name}' share the name '{}', which is how delegate-task \
5176 offers a capsule to the model",
5177 name.name()
5178 ),
5179 hint: Some(format!(
5180 "keep one of '{earlier}' and '{name}' in capabilities.spawn.allow"
5181 )),
5182 });
5183 }
5184 parsed.push(name);
5185 }
5186 let mut snapshotted: Vec<ArtifactName> = Vec::with_capacity(workdir_snapshot.len());
5187 for (index, entry) in workdir_snapshot.iter().enumerate() {
5188 let field = format!("capabilities.spawn.workdir_snapshot[{index}]");
5189 let name = ArtifactName::parse(entry).map_err(|error| {
5190 RuntimeManifestError::InvalidArtifactName {
5191 field: field.clone(),
5192 message: error.to_string(),
5193 hint: error.hint(),
5194 }
5195 })?;
5196 if snapshotted.contains(&name) {
5197 return Err(RuntimeManifestError::InvalidArtifactName {
5198 field,
5199 message: format!("'{name}' is listed more than once"),
5200 hint: Some(format!(
5201 "keep one '{name}' in capabilities.spawn.workdir_snapshot"
5202 )),
5203 });
5204 }
5205 if !parsed.contains(&name) {
5206 return Err(RuntimeManifestError::InvalidArtifactName {
5207 field,
5208 message: format!(
5209 "'{name}' is not in capabilities.spawn.allow, so this capsule cannot \
5210 delegate to it"
5211 ),
5212 hint: Some(format!(
5213 "add '{name}' to capabilities.spawn.allow, or remove it from \
5214 capabilities.spawn.workdir_snapshot"
5215 )),
5216 });
5217 }
5218 snapshotted.push(name);
5219 }
5220 Ok(SpawnCapabilities {
5221 allow,
5222 workdir_snapshot,
5223 })
5224}
5225
5226fn check_entry_name(
5229 index: usize,
5230 name: &str,
5231 has_source: bool,
5232) -> Result<(), RuntimeManifestError> {
5233 let field = format!("artifacts[{index}].name");
5234 if !has_source {
5235 return ArtifactName::parse(name).map(|_| ()).map_err(|error| {
5236 RuntimeManifestError::InvalidArtifactName {
5237 field,
5238 message: error.to_string(),
5239 hint: error.hint(),
5240 }
5241 });
5242 }
5243 if let Some((_, bare)) = name.split_once('/') {
5244 return Err(RuntimeManifestError::InvalidArtifactName {
5245 field,
5246 message: format!(
5247 "'{name}' declares 'source:', so it is named by its bare name; a scope says \
5248 where a resolved artifact comes from, and a source: entry is never resolved"
5249 ),
5250 hint: Some(format!("drop the scope: name it '{bare}'")),
5251 });
5252 }
5253 match artifact_name_format_error(name) {
5254 Some(reason) => Err(RuntimeManifestError::InvalidArtifactName {
5255 field,
5256 message: format!("'{name}' is not a valid artifact name: it {reason}"),
5257 hint: None,
5258 }),
5259 None => Ok(()),
5260 }
5261}
5262
5263fn not_declared_message(artifacts: &[RuntimeArtifact], name: &str) -> String {
5267 let mut same_bare = artifacts
5268 .iter()
5269 .filter(|artifact| artifact.name != name && artifact.tool_name() == tool_name_of(name));
5270 match (same_bare.next(), same_bare.next()) {
5271 (Some(only), None) => format!(
5272 "artifact '{name}' is not declared in artifacts: (the declared '{}' has that name; \
5273 refer to it as '{}')",
5274 only.name, only.name
5275 ),
5276 _ => format!("artifact '{name}' is not declared in artifacts:"),
5277 }
5278}
5279
5280fn parse_install(raw: RawInstallCapabilities) -> Result<InstallCapabilities, RuntimeManifestError> {
5283 if raw.skill.is_empty() && raw.tool.is_empty() {
5284 return Err(RuntimeManifestError::InvalidCapabilities {
5285 field: "capabilities.install".to_string(),
5286 message: "must name at least one 'skill' or 'tool' entry — a capability is never \
5287 inferred; omit the block to grant no install at all"
5288 .to_string(),
5289 });
5290 }
5291 for (key, entries) in [("skill", &raw.skill), ("tool", &raw.tool)] {
5292 for (index, entry) in entries.iter().enumerate() {
5293 crate::install_pattern::validate(entry).map_err(|error| {
5294 RuntimeManifestError::InvalidArtifactName {
5295 field: format!("capabilities.install.{key}[{index}]"),
5296 message: format!(
5297 "entry {entry:?} is invalid ({}); expected {}",
5298 error.reason,
5299 crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
5300 ),
5301 hint: error.hint,
5302 }
5303 })?;
5304 }
5305 }
5306 let from = match raw.from {
5307 None => InstallFrom::Stores,
5308 Some(list) => parse_install_from(&list)?,
5309 };
5310 Ok(InstallCapabilities {
5311 skill: raw.skill,
5312 tool: raw.tool,
5313 from,
5314 })
5315}
5316
5317fn refuse_install_under_process_transport(
5320 capabilities: Option<&Capabilities>,
5321 inference: Option<&InferenceConfig>,
5322) -> Result<(), RuntimeManifestError> {
5323 let installs = capabilities.is_some_and(|capabilities| capabilities.install.is_some());
5324 let process = inference.is_some_and(|inference| inference.transport == "process");
5325 if installs && process {
5326 return Err(RuntimeManifestError::InvalidCapabilities {
5327 field: "capabilities.install".to_string(),
5328 message: "is not valid with transport: process; the harness owns its tool list, so a \
5329 pulled artifact could never reach it"
5330 .to_string(),
5331 });
5332 }
5333 Ok(())
5334}
5335
5336const INSTALL_FROM_ACCEPTED: &str = "[stores] or [stores, sources]";
5338
5339fn parse_install_from(list: &[String]) -> Result<InstallFrom, RuntimeManifestError> {
5342 let refuse = |message: String| RuntimeManifestError::InvalidCapabilities {
5343 field: "capabilities.install.from".to_string(),
5344 message: format!("{message}; expected {INSTALL_FROM_ACCEPTED}"),
5345 };
5346 if list.is_empty() {
5347 return Err(refuse("is empty".to_string()));
5348 }
5349 let (mut stores, mut sources) = (false, false);
5350 for entry in list {
5351 let seen = match entry.as_str() {
5352 "stores" => &mut stores,
5353 "sources" => &mut sources,
5354 other => return Err(refuse(format!("names {other:?}, which is not a place"))),
5355 };
5356 if *seen {
5357 return Err(refuse(format!("names {entry:?} twice")));
5358 }
5359 *seen = true;
5360 }
5361 if !stores {
5362 return Err(refuse(
5363 "does not name stores, which every pull is answered from first".to_string(),
5364 ));
5365 }
5366 Ok(if sources {
5367 InstallFrom::StoresAndSources
5368 } else {
5369 InstallFrom::Stores
5370 })
5371}
5372
5373fn parse_state_capabilities(raw: RawStateCapabilities) -> StateCapabilities {
5383 StateCapabilities {
5384 store: raw.store.map(|store| store.trim().to_string()),
5385 }
5386}
5387
5388fn shell_allow_entry_error(entry: &str) -> Option<&'static str> {
5392 if entry.is_empty() {
5393 Some("is empty")
5394 } else if entry.contains('\0') {
5395 Some("contains a NUL byte")
5396 } else if entry.contains('/') {
5397 Some("is a path")
5398 } else if entry == "." || entry == ".." {
5399 Some("names a directory")
5400 } else if entry.chars().any(char::is_whitespace) {
5401 Some("contains whitespace")
5402 } else {
5403 None
5404 }
5405}
5406
5407fn parse_interpreter_runtime(
5413 allow: &[String],
5414 raw_grants: Vec<RawInterpreterRuntimeGrant>,
5415) -> Result<Vec<InterpreterRuntimeGrant>, RuntimeManifestError> {
5416 let mut grants = Vec::with_capacity(raw_grants.len());
5417 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5418 let base = format!("capabilities.shell.interpreter_runtime[{index}]");
5419
5420 let binary = raw_grant
5421 .binary
5422 .filter(|b| !b.trim().is_empty())
5423 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5424 field: format!("{base}.binary"),
5425 message: "must name a binary".to_string(),
5426 })?;
5427
5428 if !allow.contains(&binary) {
5432 return Err(RuntimeManifestError::InvalidCapabilities {
5433 field: format!("{base}.binary"),
5434 message: format!(
5435 "'{binary}' is not in capabilities.shell.allow — interpreter_runtime can \
5436 only narrow filesystem access for an already-allowlisted binary, never \
5437 grant exec"
5438 ),
5439 });
5440 }
5441
5442 if raw_grant.dirs.is_empty() {
5443 return Err(RuntimeManifestError::InvalidCapabilities {
5444 field: format!("{base}.dirs"),
5445 message: "must name at least one directory".to_string(),
5446 });
5447 }
5448
5449 let mut dirs = Vec::with_capacity(raw_grant.dirs.len());
5450 for (dir_index, raw_dir) in raw_grant.dirs.into_iter().enumerate() {
5451 let dir_base = format!("{base}.dirs[{dir_index}]");
5452
5453 let path = raw_dir
5454 .path
5455 .filter(|p| !p.trim().is_empty())
5456 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5457 field: format!("{dir_base}.path"),
5458 message: "must name a host directory".to_string(),
5459 })?;
5460 if !path.starts_with('/') {
5461 return Err(RuntimeManifestError::InvalidCapabilities {
5462 field: format!("{dir_base}.path"),
5463 message: format!(
5464 "'{path}' must be an absolute host path (start with '/') — these are \
5465 filesystem paths outside the workdir"
5466 ),
5467 });
5468 }
5469
5470 let list_dir =
5471 raw_dir
5472 .list_dir
5473 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5474 field: format!("{dir_base}.list_dir"),
5475 message: format!(
5476 "'{path}' must set list_dir explicitly to true or false — \
5477 enumerability is never inferred"
5478 ),
5479 })?;
5480
5481 dirs.push(InterpreterRuntimeDir { path, list_dir });
5482 }
5483
5484 grants.push(InterpreterRuntimeGrant { binary, dirs });
5485 }
5486
5487 Ok(grants)
5488}
5489
5490fn parse_staged_runtime(
5511 allow: &[String],
5512 interpreter_runtime: &[InterpreterRuntimeGrant],
5513 raw_grants: Vec<RawStagedRuntimeGrant>,
5514) -> Result<Vec<StagedRuntimeGrant>, RuntimeManifestError> {
5515 let mut grants = Vec::with_capacity(raw_grants.len());
5516 for (index, raw_grant) in raw_grants.into_iter().enumerate() {
5517 let base = format!("capabilities.shell.staged_runtime[{index}]");
5518
5519 let binary = raw_grant
5520 .binary
5521 .filter(|b| !b.trim().is_empty())
5522 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5523 field: format!("{base}.binary"),
5524 message: "must name a binary".to_string(),
5525 })?;
5526
5527 if !allow.contains(&binary) {
5528 return Err(RuntimeManifestError::InvalidCapabilities {
5529 field: format!("{base}.binary"),
5530 message: format!(
5531 "'{binary}' is not in capabilities.shell.allow — staged_runtime can only \
5532 stage the runtime tree behind an already-allowlisted binary, never grant exec"
5533 ),
5534 });
5535 }
5536
5537 if interpreter_runtime
5538 .iter()
5539 .any(|grant| grant.binary == binary)
5540 {
5541 return Err(RuntimeManifestError::InvalidCapabilities {
5542 field: format!("{base}.binary"),
5543 message: format!(
5544 "'{binary}' also has a capabilities.shell.interpreter_runtime grant — the two \
5545 are mutually exclusive per binary: staged_runtime bind-mounts the runtime \
5546 tree into the capsule's own root, which is what makes widening the capsule's \
5547 host filesystem scope unnecessary rather than something to add to it. Remove \
5548 the interpreter_runtime grant for '{binary}'"
5549 ),
5550 });
5551 }
5552
5553 let source_path = raw_grant
5554 .source_path
5555 .filter(|p| !p.trim().is_empty())
5556 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5557 field: format!("{base}.source_path"),
5558 message: "must name the host directory holding the pinned runtime tree".to_string(),
5559 })?;
5560 if !source_path.starts_with('/') {
5561 return Err(RuntimeManifestError::InvalidCapabilities {
5562 field: format!("{base}.source_path"),
5563 message: format!(
5564 "'{source_path}' must be an absolute host path (start with '/') — \
5565 staged-runtime sources are host filesystem paths outside the workdir"
5566 ),
5567 });
5568 }
5569
5570 let pin = raw_grant
5571 .pin
5572 .map(|p| p.trim().to_string())
5573 .filter(|p| !p.is_empty())
5574 .ok_or_else(|| RuntimeManifestError::InvalidCapabilities {
5575 field: format!("{base}.pin"),
5576 message: format!(
5577 "'{source_path}' must declare a non-empty pin identifying which build this \
5578 tree is — it is never inferred, because it is the value a human compares \
5579 across two hosts to confirm the same runtime shipped to both"
5580 ),
5581 })?;
5582
5583 grants.push(StagedRuntimeGrant {
5584 binary,
5585 source_path,
5586 pin,
5587 });
5588 }
5589
5590 Ok(grants)
5591}
5592
5593fn parse_resource_limits(raw: RawResourceLimits) -> Result<ResourceLimits, RuntimeManifestError> {
5598 let reject_zero_usize =
5599 |value: Option<usize>, field: &str| -> Result<(), RuntimeManifestError> {
5600 match value {
5601 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5602 field: format!("capabilities.limits.{field}"),
5603 message: "must be greater than zero".to_string(),
5604 }),
5605 _ => Ok(()),
5606 }
5607 };
5608
5609 reject_zero_usize(raw.memory_bytes, "memory_bytes")?;
5610 reject_zero_usize(raw.table_elements, "table_elements")?;
5611 reject_zero_usize(raw.instances, "instances")?;
5612 if raw.deadline_seconds == Some(0) {
5613 return Err(RuntimeManifestError::InvalidCapabilities {
5614 field: "capabilities.limits.deadline_seconds".to_string(),
5615 message: "must be greater than zero".to_string(),
5616 });
5617 }
5618
5619 Ok(ResourceLimits {
5620 memory_bytes: raw.memory_bytes,
5621 table_elements: raw.table_elements,
5622 instances: raw.instances,
5623 deadline_seconds: raw.deadline_seconds,
5624 })
5625}
5626
5627fn parse_resource_capabilities(
5633 raw: RawResourceCapabilities,
5634) -> Result<ResourceCapabilities, RuntimeManifestError> {
5635 let reject_zero = |value: Option<u64>, field: &str| -> Result<(), RuntimeManifestError> {
5636 match value {
5637 Some(0) => Err(RuntimeManifestError::InvalidCapabilities {
5638 field: format!("capabilities.resources.{field}"),
5639 message: "must be greater than zero".to_string(),
5640 }),
5641 _ => Ok(()),
5642 }
5643 };
5644
5645 reject_zero(raw.max_processes, "max_processes")?;
5646 reject_zero(raw.max_open_files, "max_open_files")?;
5647 reject_zero(raw.max_file_size_bytes, "max_file_size_bytes")?;
5648 reject_zero(raw.cpu_seconds, "cpu_seconds")?;
5649 reject_zero(raw.memory_bytes, "memory_bytes")?;
5650 reject_zero(raw.cgroup_memory_bytes, "cgroup_memory_bytes")?;
5651 reject_zero(raw.cgroup_pids_max, "cgroup_pids_max")?;
5652 reject_zero(raw.cgroup_cpu_percent.map(u64::from), "cgroup_cpu_percent")?;
5653 reject_zero(raw.cgroup_io_bytes_per_sec, "cgroup_io_bytes_per_sec")?;
5654 reject_zero(raw.workdir_max_bytes, "workdir_max_bytes")?;
5655
5656 Ok(ResourceCapabilities {
5657 max_processes: raw.max_processes,
5658 max_open_files: raw.max_open_files,
5659 max_file_size_bytes: raw.max_file_size_bytes,
5660 cpu_seconds: raw.cpu_seconds,
5661 memory_bytes: raw.memory_bytes,
5662 cgroup_memory_bytes: raw.cgroup_memory_bytes,
5663 cgroup_pids_max: raw.cgroup_pids_max,
5664 cgroup_cpu_percent: raw.cgroup_cpu_percent,
5665 cgroup_io_bytes_per_sec: raw.cgroup_io_bytes_per_sec,
5666 workdir_max_bytes: raw.workdir_max_bytes,
5667 })
5668}
5669
5670const PROCESS_DRIVER_REQUIRED: &str =
5674 "missing required field; a transport: process capsule must name the process driver that \
5675 knows how to drive its harness. inference.command no longer selects a harness — it only \
5676 overrides the binary the driver names. Add inference.driver.artifact naming a process \
5677 driver, for example murmur/driver-claude-code";
5678
5679fn parse_inference(
5680 raw: Option<RawInferenceConfig>,
5681) -> Result<Option<InferenceConfig>, RuntimeManifestError> {
5682 let Some(raw) = raw else {
5683 return Ok(None);
5684 };
5685
5686 let transport = raw
5687 .transport
5688 .map(|s| s.trim().to_string())
5689 .filter(|s| !s.is_empty())
5690 .unwrap_or_else(|| "http".to_string());
5691
5692 let compaction = parse_compaction(raw.compaction)?;
5693 let system_prompt = optional_trimmed_string(raw.system_prompt);
5694 let system_prompt_file = optional_trimmed_string(raw.system_prompt_file);
5695 let system_prompt_artifact = optional_trimmed_string(raw.system_prompt_artifact);
5696 let instruction_files = raw
5697 .instruction_files
5698 .map(parse_instruction_files)
5699 .transpose()?;
5700
5701 let prompt_sources_set = [
5702 system_prompt.is_some(),
5703 system_prompt_file.is_some(),
5704 system_prompt_artifact.is_some(),
5705 ]
5706 .iter()
5707 .filter(|&&b| b)
5708 .count();
5709 if prompt_sources_set > 1 {
5710 return Err(RuntimeManifestError::InvalidInferenceConfig {
5711 field: "inference.system_prompt".to_string(),
5712 message: "at most one of system_prompt, system_prompt_file, system_prompt_artifact \
5713 may be set"
5714 .to_string(),
5715 });
5716 }
5717
5718 if raw.max_turns == Some(0) {
5719 return Err(RuntimeManifestError::InvalidInferenceConfig {
5720 field: "inference.max_turns".to_string(),
5721 message: "must be greater than 0".to_string(),
5722 });
5723 }
5724 let max_turns = raw.max_turns;
5725
5726 if raw.max_task_reopens.is_some() {
5730 return Err(RuntimeManifestError::InvalidInferenceConfig {
5731 field: "inference.max_task_reopens".to_string(),
5732 message: "is not a valid inference field; set lifecycle.max_task_reopens instead"
5733 .to_string(),
5734 });
5735 }
5736
5737 for (present, removed, moved_to) in [
5741 (raw.endpoint.is_some(), "endpoint", "gateway.endpoint"),
5742 (raw.api_key.is_some(), "api_key", "gateway.api_key"),
5743 ] {
5744 if !present {
5745 continue;
5746 }
5747 let entry = raw
5748 .driver
5749 .as_ref()
5750 .and_then(|driver| driver.artifact.as_deref())
5751 .map(str::trim)
5752 .filter(|name| !name.is_empty())
5753 .map(|name| format!("on the artifacts: entry '{name}'"))
5754 .unwrap_or_else(|| "on the artifacts: entry inference.driver.artifact names".into());
5755 return Err(RuntimeManifestError::InvalidInferenceConfig {
5756 field: format!("inference.{removed}"),
5757 message: format!("was removed; set {moved_to} {entry} instead"),
5758 });
5759 }
5760
5761 match transport.as_str() {
5762 "http" => {
5763 let model = required_inference_field(raw.model, "model")?;
5764
5765 let driver_raw =
5766 raw.driver
5767 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
5768 field: "inference.driver".to_string(),
5769 message: "missing required field".to_string(),
5770 })?;
5771 let driver = parse_inference_driver(driver_raw)?;
5772
5773 if raw.max_tokens == Some(0) {
5777 return Err(RuntimeManifestError::InvalidInferenceConfig {
5778 field: "inference.max_tokens".to_string(),
5779 message: "must be greater than 0".to_string(),
5780 });
5781 }
5782 if raw.max_session_tokens == Some(0) {
5783 return Err(RuntimeManifestError::InvalidInferenceConfig {
5784 field: "inference.max_session_tokens".to_string(),
5785 message: "must be greater than 0".to_string(),
5786 });
5787 }
5788 let tool_refresh = parse_tool_refresh(raw.tool_refresh.as_deref())?;
5789 let alternates = parse_alternates(raw.alternates, &driver.artifact, &model)?;
5790 let retry = parse_inference_retry(raw.retry)?;
5791
5792 Ok(Some(InferenceConfig {
5793 transport,
5794 model,
5795 driver: Some(driver),
5796 command: None,
5797 compaction,
5798 system_prompt,
5799 system_prompt_file,
5800 system_prompt_artifact,
5801 instruction_files,
5802 max_turns,
5803 max_tokens: raw.max_tokens,
5804 max_session_tokens: raw.max_session_tokens,
5805 tool_refresh,
5806 alternates,
5807 retry,
5808 }))
5809 }
5810 "process" => {
5811 if raw.max_tokens.is_some() {
5814 return Err(RuntimeManifestError::InvalidInferenceConfig {
5815 field: "inference.max_tokens".to_string(),
5816 message: "is not valid with transport: process".to_string(),
5817 });
5818 }
5819 if raw.tool_refresh.is_some() {
5822 return Err(RuntimeManifestError::InvalidInferenceConfig {
5823 field: "inference.tool_refresh".to_string(),
5824 message: "is not valid with transport: process".to_string(),
5825 });
5826 }
5827 for (present, key) in [
5830 (
5831 compaction.as_ref().is_some_and(|c| c.max_tokens.is_some()),
5832 "max_tokens",
5833 ),
5834 (
5835 compaction
5836 .as_ref()
5837 .is_some_and(|c| c.driver_config.is_some()),
5838 "driver_config",
5839 ),
5840 ] {
5841 if present {
5842 return Err(RuntimeManifestError::InvalidInferenceConfig {
5843 field: format!("inference.compaction.{key}"),
5844 message: "is not valid with transport: process".to_string(),
5845 });
5846 }
5847 }
5848 if raw.alternates.is_some() {
5851 return Err(RuntimeManifestError::InvalidInferenceConfig {
5852 field: "inference.alternates".to_string(),
5853 message: "is not valid with transport: process; the harness owns the \
5854 conversation and the model it runs"
5855 .to_string(),
5856 });
5857 }
5858 if raw.retry.is_some() {
5859 return Err(RuntimeManifestError::InvalidInferenceConfig {
5860 field: "inference.retry".to_string(),
5861 message: "is not valid with transport: process; the harness retries its own \
5862 provider calls"
5863 .to_string(),
5864 });
5865 }
5866
5867 if raw.max_session_tokens == Some(0) {
5871 return Err(RuntimeManifestError::InvalidInferenceConfig {
5872 field: "inference.max_session_tokens".to_string(),
5873 message: "must be greater than 0".to_string(),
5874 });
5875 }
5876
5877 let driver = raw.driver.map(parse_inference_driver).transpose()?;
5878 let command = optional_trimmed_string(raw.command);
5879 if driver.is_none() {
5883 return Err(RuntimeManifestError::InvalidInferenceConfig {
5884 field: "inference.driver.artifact".to_string(),
5885 message: PROCESS_DRIVER_REQUIRED.to_string(),
5886 });
5887 }
5888 let model = optional_trimmed_string(raw.model).unwrap_or_default();
5892
5893 Ok(Some(InferenceConfig {
5894 transport,
5895 model,
5896 driver,
5897 command,
5898 compaction,
5899 system_prompt,
5900 system_prompt_file,
5901 system_prompt_artifact,
5902 instruction_files,
5903 max_turns,
5904 max_tokens: None,
5905 max_session_tokens: raw.max_session_tokens,
5906 tool_refresh: ToolRefresh::default(),
5907 alternates: Vec::new(),
5908 retry: InferenceRetry::default(),
5909 }))
5910 }
5911 other => Err(RuntimeManifestError::InvalidInferenceConfig {
5912 field: "inference.transport".to_string(),
5913 message: format!("unknown value '{other}'"),
5914 }),
5915 }
5916}
5917
5918fn parse_inference_retry(
5922 raw: Option<RawInferenceRetry>,
5923) -> Result<InferenceRetry, RuntimeManifestError> {
5924 let defaults = InferenceRetry::default();
5925 let Some(raw) = raw else {
5926 return Ok(defaults);
5927 };
5928 let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5929 field: format!("inference.retry.{key}"),
5930 message,
5931 };
5932 let retry = InferenceRetry {
5933 max_retries: raw.max_retries.unwrap_or(defaults.max_retries),
5934 initial_delay_ms: raw.initial_delay_ms.unwrap_or(defaults.initial_delay_ms),
5935 max_delay_ms: raw.max_delay_ms.unwrap_or(defaults.max_delay_ms),
5936 };
5937 if retry.max_retries > InferenceRetry::MAX_RETRIES {
5938 return Err(refuse(
5939 "max_retries",
5940 format!("must be at most {}", InferenceRetry::MAX_RETRIES),
5941 ));
5942 }
5943 if !(1..=InferenceRetry::MAX_INITIAL_DELAY_MS).contains(&retry.initial_delay_ms) {
5944 return Err(refuse(
5945 "initial_delay_ms",
5946 format!(
5947 "must be between 1 and {}",
5948 InferenceRetry::MAX_INITIAL_DELAY_MS
5949 ),
5950 ));
5951 }
5952 if !(1..=InferenceRetry::MAX_DELAY_MS).contains(&retry.max_delay_ms) {
5953 return Err(refuse(
5954 "max_delay_ms",
5955 format!("must be between 1 and {}", InferenceRetry::MAX_DELAY_MS),
5956 ));
5957 }
5958 if retry.max_delay_ms < retry.initial_delay_ms {
5959 return Err(refuse(
5960 "max_delay_ms",
5961 format!(
5962 "must be at least inference.retry.initial_delay_ms ({})",
5963 retry.initial_delay_ms
5964 ),
5965 ));
5966 }
5967 Ok(retry)
5968}
5969
5970fn is_valid_driver_choice_name(name: &str) -> bool {
5973 let mut chars = name.chars();
5974 chars.next().is_some_and(|first| first.is_ascii_lowercase())
5975 && name.len() <= 63
5976 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
5977}
5978
5979fn parse_alternates(
5985 raw: Option<Vec<RawInferenceAlternate>>,
5986 primary_driver: &str,
5987 primary_model: &str,
5988) -> Result<Vec<InferenceAlternate>, RuntimeManifestError> {
5989 let mut alternates: Vec<InferenceAlternate> = Vec::new();
5990 for (index, entry) in raw.unwrap_or_default().into_iter().enumerate() {
5991 let refuse = |key: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
5992 field: if key.is_empty() {
5993 format!("inference.alternates[{index}]")
5994 } else {
5995 format!("inference.alternates[{index}].{key}")
5996 },
5997 message,
5998 };
5999 let name = optional_trimmed_string(entry.name)
6000 .ok_or_else(|| refuse("name", "missing required field".to_string()))?;
6001 if name == PRIMARY_DRIVER_CHOICE {
6002 return Err(refuse(
6003 "name",
6004 format!(
6005 "'{PRIMARY_DRIVER_CHOICE}' is reserved for inference.driver with \
6006 inference.model; name the alternate something else"
6007 ),
6008 ));
6009 }
6010 if !is_valid_driver_choice_name(&name) {
6011 return Err(refuse(
6012 "name",
6013 format!(
6014 "'{name}' is not a driver choice name; a name is a lowercase letter followed \
6015 by up to 62 lowercase letters, digits and hyphens"
6016 ),
6017 ));
6018 }
6019 if alternates.iter().any(|alternate| alternate.name == name) {
6020 return Err(refuse(
6021 "name",
6022 format!("'{name}' names more than one alternate"),
6023 ));
6024 }
6025 let model = optional_trimmed_string(entry.model)
6026 .ok_or_else(|| refuse("model", "missing required field".to_string()))?;
6027 let Some(driver) = entry.driver else {
6028 return Err(refuse(
6029 "driver.artifact",
6030 "missing required field".to_string(),
6031 ));
6032 };
6033 if driver
6034 .config
6035 .as_ref()
6036 .is_some_and(|config| !matches!(config, serde_norway::Value::Null))
6037 {
6038 return Err(refuse(
6039 "driver.config",
6040 "is not valid on an alternate; inference.driver.config applies to every \
6041 declared driver, and per-driver settings belong in that driver's \
6042 artifacts[].config"
6043 .to_string(),
6044 ));
6045 }
6046 let driver = optional_trimmed_string(driver.artifact)
6047 .ok_or_else(|| refuse("driver.artifact", "missing required field".to_string()))?;
6048 if driver == primary_driver && model == primary_model {
6049 return Err(refuse(
6050 "",
6051 format!(
6052 "'{name}' is driver '{driver}' with model '{model}', which is the primary \
6053 choice; an alternate names a different pair"
6054 ),
6055 ));
6056 }
6057 if let Some(twin) = alternates
6058 .iter()
6059 .find(|alternate| alternate.driver == driver && alternate.model == model)
6060 {
6061 return Err(refuse(
6062 "",
6063 format!(
6064 "'{name}' is driver '{driver}' with model '{model}', which alternate '{}' \
6065 already is; an alternate names a different pair",
6066 twin.name
6067 ),
6068 ));
6069 }
6070 alternates.push(InferenceAlternate {
6071 name,
6072 model,
6073 driver,
6074 });
6075 }
6076 Ok(alternates)
6077}
6078
6079fn parse_tool_refresh(raw: Option<&str>) -> Result<ToolRefresh, RuntimeManifestError> {
6081 let Some(value) = raw else {
6082 return Ok(ToolRefresh::default());
6083 };
6084 ToolRefresh::ALL
6085 .into_iter()
6086 .find(|refresh| refresh.wire_name() == value)
6087 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6088 field: "inference.tool_refresh".to_string(),
6089 message: format!(
6090 "must be one of: {}; got '{value}'",
6091 ToolRefresh::ALL.map(ToolRefresh::wire_name).join(", ")
6092 ),
6093 })
6094}
6095
6096fn parse_context(
6097 raw: Option<RawContextConfig>,
6098) -> Result<Option<ContextConfig>, RuntimeManifestError> {
6099 let Some(raw) = raw else {
6100 return Ok(None);
6101 };
6102
6103 if let Some(max_tokens) = raw.max_tokens {
6104 if max_tokens == 0 {
6105 return Err(RuntimeManifestError::InvalidInferenceConfig {
6106 field: "context.max_tokens".to_string(),
6107 message: "must be greater than 0".to_string(),
6108 });
6109 }
6110 }
6111
6112 let fraction = |field: &str, value: Option<f32>, default: f32| match value {
6113 None => Ok(default),
6114 Some(v) if (0.0..=1.0).contains(&v) => Ok(v),
6115 Some(_) => Err(RuntimeManifestError::InvalidInferenceConfig {
6116 field: field.to_string(),
6117 message: "must be between 0.0 and 1.0".to_string(),
6118 }),
6119 };
6120
6121 let record = match raw.record.as_deref().map(str::trim) {
6122 None => true,
6123 Some("on") => true,
6124 Some("off") => false,
6125 Some(other) => {
6126 return Err(RuntimeManifestError::InvalidInferenceConfig {
6127 field: "context.record".to_string(),
6128 message: format!("unknown value '{other}'; expected: on, off"),
6129 })
6130 }
6131 };
6132
6133 Ok(Some(ContextConfig {
6134 max_tokens: raw.max_tokens,
6135 record,
6136 record_store: raw.record_store.map(|store| store.trim().to_string()),
6137 seed_budget: fraction("context.seed_budget", raw.seed_budget, DEFAULT_SEED_BUDGET)?,
6138 seed_overflow_margin: fraction(
6139 "context.seed_overflow_margin",
6140 raw.seed_overflow_margin,
6141 DEFAULT_SEED_OVERFLOW_MARGIN,
6142 )?,
6143 retain: parse_context_retain(raw.retain)?,
6144 images: parse_context_images(raw.images)?,
6145 }))
6146}
6147
6148fn parse_context_images(
6150 raw: Option<RawContextImages>,
6151) -> Result<ContextImagesConfig, RuntimeManifestError> {
6152 let defaults = ContextImagesConfig::default();
6153 let Some(raw) = raw else {
6154 return Ok(defaults);
6155 };
6156 let invalid = |field: &str, message: String| RuntimeManifestError::InvalidInferenceConfig {
6157 field: field.to_string(),
6158 message,
6159 };
6160 let count = |field: &str, value: Option<serde_norway::Value>, default: u32| match value {
6161 None => Ok(default),
6162 Some(value) => value
6163 .as_u64()
6164 .and_then(|n| u32::try_from(n).ok())
6165 .filter(|n| *n >= 1)
6166 .ok_or_else(|| {
6167 let message = match scalar_text(&value) {
6168 Some(text) => format!("must be a whole number of at least 1, got '{text}'"),
6169 None => "must be a whole number of at least 1".to_string(),
6170 };
6171 invalid(field, message)
6172 }),
6173 };
6174 let max_bytes = match raw.max_bytes {
6175 None => defaults.max_bytes,
6176 Some(value) => {
6177 let field = "context.images.max_bytes";
6178 let text = scalar_text(&value)
6179 .ok_or_else(|| invalid(field, format!("'{value:?}' {BYTE_SIZE_ACCEPTED_FORM}")))?;
6180 let parsed = parse_byte_size(&text).map_err(|message| invalid(field, message))?;
6181 if parsed == 0 {
6182 return Err(invalid(
6183 field,
6184 format!("must be greater than zero; {BYTE_SIZE_ACCEPTED_FORM}"),
6185 ));
6186 }
6187 parsed
6188 }
6189 };
6190 Ok(ContextImagesConfig {
6191 keep: count("context.images.keep", raw.keep, defaults.keep)?,
6192 max_bytes,
6193 max_per_turn: count(
6194 "context.images.max_per_turn",
6195 raw.max_per_turn,
6196 defaults.max_per_turn,
6197 )?,
6198 })
6199}
6200
6201const TRACE_RETAIN_KEYS: [&str; 2] = ["max_sessions", "max_age"];
6203
6204const CONTEXT_RETAIN_KEYS: [&str; 2] = ["max_messages", "max_age"];
6206
6207const RETAIN_EMPTY_BLOCK: &str = "must declare at least one key — omit the block entirely to \
6210 keep everything, which is what no policy means";
6211
6212fn parse_retain_block(
6218 block: &str,
6219 count_key: &str,
6220 accepted: &[&str],
6221 raw: serde_norway::Value,
6222 invalid: &dyn Fn(String, String) -> RuntimeManifestError,
6223) -> Result<(Option<u32>, Option<u64>), RuntimeManifestError> {
6224 let mapping = match raw {
6225 serde_norway::Value::Mapping(mapping) => mapping,
6226 serde_norway::Value::Null => {
6228 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()))
6229 }
6230 other => {
6231 return Err(invalid(
6232 block.to_string(),
6233 format!(
6234 "must be a block of keys ({}), got '{other:?}'",
6235 accepted.join(", ")
6236 ),
6237 ))
6238 }
6239 };
6240 if mapping.is_empty() {
6241 return Err(invalid(block.to_string(), RETAIN_EMPTY_BLOCK.to_string()));
6242 }
6243
6244 let mut count: Option<u32> = None;
6245 let mut max_age_secs: Option<u64> = None;
6246 for (key, value) in &mapping {
6247 let key = key.as_str().unwrap_or_default();
6248 let field = format!("{block}.{key}");
6249 if key == count_key {
6250 let parsed = value
6251 .as_u64()
6252 .and_then(|n| u32::try_from(n).ok())
6253 .ok_or_else(|| {
6254 invalid(
6255 field.clone(),
6256 "must be a whole number of at least 1".to_string(),
6257 )
6258 })?;
6259 if parsed == 0 {
6260 return Err(invalid(
6261 field,
6262 "must be at least 1 — a limit of zero would delete what retention exists \
6263 to keep; remove the key to leave it unbounded"
6264 .to_string(),
6265 ));
6266 }
6267 count = Some(parsed);
6268 } else if key == "max_age" {
6269 let text = scalar_text(value).ok_or_else(|| {
6270 invalid(
6271 field.clone(),
6272 format!("'{value:?}' {DURATION_ACCEPTED_FORM}"),
6273 )
6274 })?;
6275 let parsed =
6276 parse_duration_secs(&text).map_err(|message| invalid(field.clone(), message))?;
6277 if parsed == 0 {
6278 return Err(invalid(
6279 field,
6280 format!("must be greater than zero; {DURATION_ACCEPTED_FORM}"),
6281 ));
6282 }
6283 max_age_secs = Some(parsed);
6284 } else {
6285 return Err(invalid(
6286 block.to_string(),
6287 format!("unknown key '{key}'; expected: {}", accepted.join(", ")),
6288 ));
6289 }
6290 }
6291 Ok((count, max_age_secs))
6292}
6293
6294fn parse_trace_retain(
6295 raw: Option<serde_norway::Value>,
6296) -> Result<Option<TraceRetainConfig>, RuntimeManifestError> {
6297 let Some(raw) = raw else {
6298 return Ok(None);
6299 };
6300 let (max_sessions, max_age_secs) = parse_retain_block(
6301 "trace.retain",
6302 "max_sessions",
6303 &TRACE_RETAIN_KEYS,
6304 raw,
6305 &|field, message| RuntimeManifestError::InvalidTraceConfig { field, message },
6306 )?;
6307 Ok(Some(TraceRetainConfig {
6308 max_sessions,
6309 max_age_secs,
6310 }))
6311}
6312
6313fn parse_context_retain(
6314 raw: Option<serde_norway::Value>,
6315) -> Result<Option<ContextRetainConfig>, RuntimeManifestError> {
6316 let Some(raw) = raw else {
6317 return Ok(None);
6318 };
6319 let (max_messages, max_age_secs) = parse_retain_block(
6320 "context.retain",
6321 "max_messages",
6322 &CONTEXT_RETAIN_KEYS,
6323 raw,
6324 &|field, message| RuntimeManifestError::InvalidInferenceConfig { field, message },
6325 )?;
6326 Ok(Some(ContextRetainConfig {
6327 max_messages,
6328 max_age_secs,
6329 }))
6330}
6331
6332fn parse_observability(raw: Option<RawObservabilityConfig>) -> Option<ObservabilityConfig> {
6333 raw.map(|raw| ObservabilityConfig {
6334 otel_endpoint: raw
6335 .otel_endpoint
6336 .map(|value| value.trim().to_string())
6337 .filter(|value| !value.is_empty()),
6338 eval: raw.eval.map(parse_eval_config),
6339 })
6340}
6341
6342fn parse_eval_config(raw: RawEvalConfig) -> EvalConfig {
6343 let scorers = raw
6344 .scorers
6345 .into_iter()
6346 .filter_map(|raw| {
6347 let name = raw.name.unwrap_or_else(|| raw.scorer_type.clone());
6348 match raw.scorer_type.as_str() {
6349 "exit_ok" => Some(ScorerConfig::ExitOk { name }),
6350 "max_turns" => Some(ScorerConfig::MaxTurns {
6351 name,
6352 max: raw.max.unwrap_or(10) as u32,
6353 }),
6354 "max_tokens" => Some(ScorerConfig::MaxTokens {
6355 name,
6356 max: raw.max.unwrap_or(100_000),
6357 }),
6358 "tool_sequence" => Some(ScorerConfig::ToolSequence {
6359 name,
6360 expected: raw.expected.unwrap_or_default(),
6361 }),
6362 "llm_judge" => Some(ScorerConfig::LlmJudge { name }),
6363 other => {
6364 crate::warn_line(format_args!(
6365 "[murmur-artifact] unknown scorer type '{other}' — skipping"
6366 ));
6367 None
6368 }
6369 }
6370 })
6371 .collect();
6372
6373 EvalConfig {
6374 dataset_id: raw.dataset_id,
6375 scorers,
6376 }
6377}
6378
6379fn parse_compaction(
6380 raw: Option<RawCompactionConfig>,
6381) -> Result<Option<CompactionConfig>, RuntimeManifestError> {
6382 let Some(raw) = raw else {
6383 return Ok(None);
6384 };
6385
6386 if let Some(threshold) = raw.threshold {
6387 if threshold <= 0.0 || threshold > 1.0 {
6388 return Err(RuntimeManifestError::InvalidInferenceConfig {
6389 field: "inference.compaction.threshold".to_string(),
6390 message: format!("must be in (0.0, 1.0] but got {threshold}"),
6391 });
6392 }
6393 }
6394
6395 if raw.system_prompt.is_some() && raw.system_prompt_file.is_some() {
6396 return Err(RuntimeManifestError::InvalidInferenceConfig {
6397 field: "inference.compaction.system_prompt".to_string(),
6398 message: "at most one of inference.compaction.system_prompt, \
6399 inference.compaction.system_prompt_file may be set"
6400 .to_string(),
6401 });
6402 }
6403
6404 if raw.max_tokens == Some(0) {
6405 return Err(RuntimeManifestError::InvalidInferenceConfig {
6406 field: "inference.compaction.max_tokens".to_string(),
6407 message: "must be greater than 0".to_string(),
6408 });
6409 }
6410 let driver_config =
6411 driver_config_json(raw.driver_config, "inference.compaction.driver_config")?;
6412
6413 Ok(Some(CompactionConfig {
6414 threshold: raw.threshold,
6415 model: raw.model,
6416 system_prompt: raw.system_prompt,
6417 system_prompt_file: optional_trimmed_string(raw.system_prompt_file),
6418 dump_summaries: raw.dump_summaries,
6421 max_tokens: raw.max_tokens,
6422 driver_config,
6423 }))
6424}
6425
6426fn optional_trimmed_string(value: Option<String>) -> Option<String> {
6427 value
6428 .map(|s| s.trim().to_string())
6429 .filter(|s| !s.is_empty())
6430}
6431
6432fn required_inference_field(
6433 value: Option<String>,
6434 key: &str,
6435) -> Result<String, RuntimeManifestError> {
6436 value
6437 .map(|value| value.trim().to_string())
6438 .filter(|value| !value.is_empty())
6439 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6440 field: format!("inference.{key}"),
6441 message: "missing required field".to_string(),
6442 })
6443}
6444
6445fn parse_artifact_gateway(
6453 index: usize,
6454 name: &str,
6455 raw: Option<RawArtifactGateway>,
6456 secrets: SecretResolution,
6457) -> Result<ArtifactGateway, RuntimeManifestError> {
6458 let (endpoint, api_key, keyless) = match raw {
6459 Some(raw) => (
6460 optional_trimmed_string(raw.endpoint),
6461 raw.api_key,
6462 raw.keyless,
6463 ),
6464 None => (None, None, None),
6465 };
6466 let endpoint = endpoint.ok_or_else(|| RuntimeManifestError::InvalidArtifact {
6467 index,
6468 message: format!(
6469 "artifact '{name}' declares 'gateway:' without 'gateway.endpoint'; the block needs \
6470 the upstream URL the runtime sends this artifact's keyed requests to"
6471 ),
6472 })?;
6473 validate_gateway_endpoint(name, &endpoint)
6474 .map_err(|message| RuntimeManifestError::InvalidArtifact { index, message })?;
6475
6476 let keyless = match keyless {
6477 None | Some(serde_norway::Value::Null) | Some(serde_norway::Value::Bool(false)) => false,
6478 Some(serde_norway::Value::Bool(true)) => true,
6479 Some(_) => {
6480 return Err(RuntimeManifestError::InvalidArtifact {
6481 index,
6482 message: format!(
6483 "artifact '{name}' declares gateway.keyless with a value that is not a \
6484 boolean; write keyless: true for an upstream that takes no key"
6485 ),
6486 })
6487 }
6488 };
6489 if keyless && api_key.is_some() {
6490 return Err(RuntimeManifestError::InvalidArtifact {
6491 index,
6492 message: format!(
6493 "artifact '{name}' declares both gateway.api_key and gateway.keyless: true; a \
6494 gateway either presents a key or is keyless"
6495 ),
6496 });
6497 }
6498 if keyless {
6499 return Ok(ArtifactGateway {
6500 endpoint,
6501 api_key: None,
6502 keyless: true,
6503 });
6504 }
6505
6506 let written = api_key
6507 .flatten()
6508 .map(|value| value.trim().to_string())
6509 .filter(|value| !value.is_empty());
6510 let Some(written) = written else {
6511 return Err(RuntimeManifestError::GatewayWithoutCredential {
6512 index,
6513 name: name.to_string(),
6514 endpoint,
6515 });
6516 };
6517 let api_key = match secrets {
6518 SecretResolution::Skip => None,
6519 SecretResolution::Resolve => Some(match parse_env_reference(&written) {
6520 Some(variable) => ApiKeyReference::Environment(variable.to_string()),
6521 None => ApiKeyReference::Literal(written),
6522 }),
6523 };
6524
6525 Ok(ArtifactGateway {
6526 endpoint,
6527 api_key,
6528 keyless: false,
6529 })
6530}
6531
6532fn validate_gateway_endpoint(artifact: &str, endpoint: &str) -> Result<(), String> {
6559 let shown = mask_endpoint_userinfo(endpoint);
6560 let refuse =
6561 |why: String| format!("artifact '{artifact}' declares gateway.endpoint '{shown}', {why}");
6562 if endpoint.contains("${") {
6563 return Err(refuse(
6564 "which contains '${' — gateway.endpoint is not interpolated; write the upstream \
6565 literally"
6566 .to_string(),
6567 ));
6568 }
6569 let url = Url::parse(endpoint)
6570 .map_err(|source| refuse(format!("which does not parse as a URL: {source}")))?;
6571 let host = match url.scheme() {
6572 "http" | "https" => url
6573 .host_str()
6574 .filter(|host| !host.is_empty())
6575 .ok_or_else(|| refuse("which has no host".to_string()))?,
6576 other => {
6577 return Err(refuse(format!(
6578 "whose scheme '{other}' is unsupported — expected http or https"
6579 )))
6580 }
6581 };
6582 if !url.username().is_empty() || url.password().is_some() {
6583 return Err(refuse(format!(
6584 "which carries userinfo before '@' — the gateway never sends it, and this URL \
6585 addresses host '{host}'; write the upstream as scheme://host[:port][/path] and give \
6586 the key through gateway.api_key"
6587 )));
6588 }
6589 if url.query().is_some() {
6590 return Err(refuse(
6591 "which carries a query — the gateway keeps each request's own query, so write the \
6592 upstream without one"
6593 .to_string(),
6594 ));
6595 }
6596 if url.fragment().is_some() {
6597 return Err(refuse(
6598 "which carries a fragment — write the upstream without one".to_string(),
6599 ));
6600 }
6601 if url.scheme() == "http" {
6602 let bare = host.trim_start_matches('[').trim_end_matches(']');
6603 let is_loopback = bare == "localhost"
6604 || bare
6605 .parse::<IpAddr>()
6606 .map(|ip| ip.is_loopback())
6607 .unwrap_or(false);
6608 if !is_loopback {
6609 return Err(refuse(format!(
6610 "which uses plain http:// with non-loopback host '{host}' — use https:// for a \
6611 remote upstream, or http://localhost or a loopback IP literal for a local one"
6612 )));
6613 }
6614 }
6615 Ok(())
6616}
6617
6618fn mask_endpoint_userinfo(endpoint: &str) -> std::borrow::Cow<'_, str> {
6622 let authority_start = endpoint.find("://").map_or(0, |at| at + 3);
6623 let rest = &endpoint[authority_start..];
6624 let authority_len = rest.find(['/', '?', '#']).unwrap_or(rest.len());
6625 match rest[..authority_len].rfind('@') {
6626 Some(at) => format!(
6627 "{}***{}",
6628 &endpoint[..authority_start],
6629 &endpoint[authority_start + at..]
6630 )
6631 .into(),
6632 None => endpoint.into(),
6633 }
6634}
6635
6636fn validate_driver_gateways(
6643 artifacts: &[RuntimeArtifact],
6644 inference: Option<&InferenceConfig>,
6645) -> Result<(), RuntimeManifestError> {
6646 let configured_driver = inference
6647 .filter(|inference| inference.transport == "http")
6648 .and_then(|inference| inference.driver.as_ref())
6649 .map(|driver| driver.artifact.as_str());
6650 let alternate_drivers: Vec<&str> = inference
6651 .map(|inference| {
6652 inference
6653 .alternates
6654 .iter()
6655 .map(|alternate| alternate.driver.as_str())
6656 .collect()
6657 })
6658 .unwrap_or_default();
6659 let under_process = inference.is_some_and(|inference| inference.transport == "process");
6660
6661 if let Some(driver) = configured_driver {
6664 if !artifacts.iter().any(|artifact| artifact.name == driver) {
6665 return Err(RuntimeManifestError::InvalidInferenceConfig {
6666 field: "inference.driver.artifact".to_string(),
6667 message: not_declared_message(artifacts, driver),
6668 });
6669 }
6670 }
6671
6672 for (index, artifact) in artifacts.iter().enumerate() {
6673 if artifact.runtime != ArtifactRuntime::Driver || artifact.gateway.is_none() {
6674 continue;
6675 }
6676 if under_process {
6677 return Err(RuntimeManifestError::InvalidArtifact {
6678 index,
6679 message: format!(
6680 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry, which is not \
6681 valid with transport: process — the CLI subprocess holds its own \
6682 credentials and a process driver is granted nothing",
6683 artifact.name
6684 ),
6685 });
6686 }
6687 if configured_driver != Some(artifact.name.as_str())
6688 && !alternate_drivers.contains(&artifact.name.as_str())
6689 {
6690 return Err(RuntimeManifestError::InvalidArtifact {
6691 index,
6692 message: format!(
6693 "artifact '{}' declares 'gateway:' on a 'runtime: driver' entry that is not \
6694 the configured inference driver; a driver's gateway is accepted only on the \
6695 entry inference.driver.artifact names under transport: http, or on one an \
6696 inference.alternates entry names",
6697 artifact.name
6698 ),
6699 });
6700 }
6701 }
6702
6703 if let Some(driver) = configured_driver {
6704 let Some((index, entry)) = artifacts
6705 .iter()
6706 .enumerate()
6707 .find(|(_, artifact)| artifact.name == driver)
6708 else {
6709 return Err(RuntimeManifestError::InvalidInferenceConfig {
6710 field: "inference.driver.artifact".to_string(),
6711 message: not_declared_message(artifacts, driver),
6712 });
6713 };
6714 if entry.gateway.is_none() {
6715 return Err(RuntimeManifestError::InvalidArtifact {
6716 index,
6717 message: format!(
6718 "artifact '{driver}' is the transport: http inference driver but declares no \
6719 gateway.endpoint; set 'gateway: {{ endpoint: <provider URL>, api_key: ... }}' \
6720 on its entry"
6721 ),
6722 });
6723 }
6724 }
6725 Ok(())
6726}
6727
6728fn validate_driver_choices(
6734 artifacts: &[RuntimeArtifact],
6735 inference: Option<&InferenceConfig>,
6736) -> Result<(), RuntimeManifestError> {
6737 let Some(inference) = inference else {
6738 return Ok(());
6739 };
6740 let primary_driver = inference
6741 .driver
6742 .as_ref()
6743 .map(|driver| driver.artifact.as_str());
6744 for (index, alternate) in inference.alternates.iter().enumerate() {
6745 let refuse = |message: String| RuntimeManifestError::InvalidInferenceConfig {
6746 field: format!("inference.alternates[{index}].driver.artifact"),
6747 message,
6748 };
6749 let driver = alternate.driver.as_str();
6750 let Some(entry) = artifacts.iter().find(|artifact| artifact.name == driver) else {
6751 return Err(refuse(not_declared_message(artifacts, driver)));
6752 };
6753 if entry.runtime != ArtifactRuntime::Driver {
6754 return Err(refuse(format!(
6755 "artifact '{driver}' is declared with runtime: {}; an alternate's driver is a \
6756 runtime: driver entry",
6757 entry.runtime.as_str()
6758 )));
6759 }
6760 if entry.gateway.is_none() && primary_driver != Some(driver) {
6761 return Err(refuse(format!(
6762 "artifact '{driver}' declares no gateway.endpoint; an alternate's driver reaches \
6763 its provider through its own gateway, so set 'gateway: {{ endpoint: <provider \
6764 URL>, api_key: ... }}' on its entry"
6765 )));
6766 }
6767 }
6768 Ok(())
6769}
6770
6771fn validate_alternates_selectable(
6774 inference: Option<&InferenceConfig>,
6775 control: Option<&ControlConfig>,
6776) -> Result<(), RuntimeManifestError> {
6777 if inference.is_none_or(|inference| inference.alternates.is_empty()) {
6778 return Ok(());
6779 }
6780 let selectable = control.is_some_and(|control| {
6781 control.controller_may_set(ControllableSetting::InferenceDriver)
6782 || control.agent_may_set(ControllableSetting::InferenceDriver)
6783 });
6784 if selectable {
6785 return Ok(());
6786 }
6787 Err(RuntimeManifestError::InvalidInferenceConfig {
6788 field: "inference.alternates".to_string(),
6789 message: "declares alternates that nothing could ever select; list inference.driver in \
6790 control.settings for a controller to switch, or in control.agent_settings for \
6791 the agent to"
6792 .to_string(),
6793 })
6794}
6795
6796fn validate_process_driver_entry(
6804 artifacts: &[RuntimeArtifact],
6805 inference: Option<&InferenceConfig>,
6806) -> Result<(), RuntimeManifestError> {
6807 let Some(driver) = inference
6808 .filter(|inference| inference.transport == "process")
6809 .and_then(|inference| inference.driver.as_ref())
6810 .map(|driver| driver.artifact.as_str())
6811 else {
6812 return Ok(());
6813 };
6814 let Some((index, entry)) = artifacts
6815 .iter()
6816 .enumerate()
6817 .find(|(_, artifact)| artifact.name == driver)
6818 else {
6819 return Err(RuntimeManifestError::InvalidInferenceConfig {
6820 field: "inference.driver.artifact".to_string(),
6821 message: not_declared_message(artifacts, driver),
6822 });
6823 };
6824 if entry.runtime != ArtifactRuntime::Driver {
6825 return Err(RuntimeManifestError::InvalidArtifact {
6826 index,
6827 message: format!(
6828 "artifact '{driver}' is the transport: process driver and must be declared with \
6829 runtime: driver"
6830 ),
6831 });
6832 }
6833 if entry.capabilities.is_some() || entry.config.is_some() {
6834 return Err(RuntimeManifestError::InvalidArtifact {
6835 index,
6836 message: format!(
6837 "artifact '{driver}' is the transport: process driver, which is granted nothing; \
6838 remove capabilities:/config: from its entry — driver settings go in \
6839 inference.driver.config"
6840 ),
6841 });
6842 }
6843 Ok(())
6844}
6845
6846fn parse_inference_driver(
6847 raw: RawInferenceDriver,
6848) -> Result<InferenceDriver, RuntimeManifestError> {
6849 let artifact = raw
6850 .artifact
6851 .map(|value| value.trim().to_string())
6852 .filter(|value| !value.is_empty())
6853 .ok_or_else(|| RuntimeManifestError::InvalidInferenceConfig {
6854 field: "inference.driver.artifact".to_string(),
6855 message: "missing required field".to_string(),
6856 })?;
6857
6858 let config = driver_config_json(raw.config, "inference.driver.config")?;
6859
6860 Ok(InferenceDriver { artifact, config })
6861}
6862
6863fn driver_config_json(
6866 raw: Option<serde_norway::Value>,
6867 field: &str,
6868) -> Result<Option<String>, RuntimeManifestError> {
6869 let Some(raw) = raw else {
6870 return Ok(None);
6871 };
6872
6873 if matches!(raw, serde_norway::Value::Null) {
6874 return Ok(None);
6875 }
6876
6877 if !matches!(raw, serde_norway::Value::Mapping(_)) {
6878 return Err(RuntimeManifestError::InvalidInferenceConfig {
6879 field: field.to_string(),
6880 message: "expected mapping/object".to_string(),
6881 });
6882 }
6883
6884 let value =
6885 serde_json::to_value(&raw).map_err(|err| RuntimeManifestError::InvalidInferenceConfig {
6886 field: field.to_string(),
6887 message: format!("failed to convert to JSON: {err}"),
6888 })?;
6889
6890 serde_json::to_string(&value).map(Some).map_err(|err| {
6891 RuntimeManifestError::InvalidInferenceConfig {
6892 field: field.to_string(),
6893 message: format!("failed to encode JSON: {err}"),
6894 }
6895 })
6896}
6897
6898fn parse_env_reference(value: &str) -> Option<&str> {
6899 if !value.starts_with("${") || !value.ends_with('}') {
6900 return None;
6901 }
6902
6903 let variable = &value[2..value.len() - 1];
6904 is_valid_env_variable(variable).then_some(variable)
6905}
6906
6907fn is_valid_env_variable(value: &str) -> bool {
6908 let mut chars = value.chars();
6909 let Some(first) = chars.next() else {
6910 return false;
6911 };
6912
6913 if !(first == '_' || first.is_ascii_uppercase()) {
6914 return false;
6915 }
6916
6917 chars.all(|ch| ch == '_' || ch.is_ascii_uppercase() || ch.is_ascii_digit())
6918}
6919
6920#[cfg(test)]
6921mod tests {
6922 use super::*;
6923 use crate::trace_capture::TRACE_CAPTURE_ACCEPTED_VALUES;
6924
6925 fn manifest_with_trace(block: &str) -> String {
6926 format!("name: cap\nversion: 0.0.1\ntrace:\n{block}")
6927 }
6928
6929 fn lifecycle(task_acceptance: TaskAcceptance, queue_depth: usize) -> LifecycleConfig {
6930 LifecycleConfig {
6931 task_acceptance,
6932 queue_depth,
6933 ..Default::default()
6934 }
6935 }
6936
6937 #[test]
6938 fn tasks_held_at_once_under_queue_is_one_running_plus_queue_depth_waiting() {
6939 assert_eq!(lifecycle(TaskAcceptance::Queue, 1).tasks_held_at_once(), 2);
6940 assert_eq!(lifecycle(TaskAcceptance::Queue, 9).tasks_held_at_once(), 10);
6941 }
6942
6943 #[test]
6944 fn queue_has_room_is_false_only_for_a_queue_at_depth_zero() {
6945 assert!(!lifecycle(TaskAcceptance::Queue, 0).queue_has_room());
6946 assert!(lifecycle(TaskAcceptance::Queue, 1).queue_has_room());
6947 assert!(lifecycle(TaskAcceptance::Single, 0).queue_has_room());
6948 assert!(lifecycle(TaskAcceptance::None, 0).queue_has_room());
6949 assert!(lifecycle(TaskAcceptance::Single, 3).queue_has_room());
6950 }
6951
6952 fn parse_lifecycle(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
6953 RuntimeManifest::from_yaml_str(&format!(
6954 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n{block}"
6955 ))
6956 }
6957
6958 #[test]
6959 fn queue_depth_zero_under_queue_is_refused_at_load() {
6960 let error = parse_lifecycle(" task_acceptance: queue\n queue_depth: 0\n").unwrap_err();
6961 assert!(
6962 matches!(error, RuntimeManifestError::QueueWithoutRoom),
6963 "{error:?}"
6964 );
6965 assert_eq!(
6966 error.to_string(),
6967 "murmur.yaml: invalid lifecycle config for 'lifecycle.queue_depth': 0 leaves \
6968 task_acceptance: queue no room for a waiting task"
6969 );
6970 }
6971
6972 #[test]
6974 fn queue_depth_zero_outside_queue_still_parses() {
6975 for block in [
6976 " task_acceptance: single\n queue_depth: 0\n",
6977 " task_acceptance: none\n queue_depth: 0\n",
6978 " queue_depth: 0\n",
6979 ] {
6980 let lifecycle = parse_lifecycle(block)
6981 .unwrap_or_else(|error| panic!("{block:?}: {error}"))
6982 .effective_lifecycle();
6983 assert_eq!(lifecycle.queue_depth, 0, "{block:?}");
6984 }
6985 let queue = parse_lifecycle(" task_acceptance: queue\n queue_depth: 1\n")
6986 .unwrap()
6987 .effective_lifecycle();
6988 assert_eq!(queue.task_acceptance, TaskAcceptance::Queue);
6989 assert_eq!(queue.queue_depth, 1);
6990 }
6991
6992 #[test]
6993 fn tasks_held_at_once_under_single_is_one_whatever_the_depth() {
6994 assert_eq!(lifecycle(TaskAcceptance::Single, 1).tasks_held_at_once(), 1);
6995 assert_eq!(lifecycle(TaskAcceptance::Single, 5).tasks_held_at_once(), 1);
6996 }
6997
6998 #[test]
6999 fn tasks_held_at_once_under_none_is_zero_whatever_the_depth() {
7000 assert_eq!(lifecycle(TaskAcceptance::None, 1).tasks_held_at_once(), 0);
7001 assert_eq!(lifecycle(TaskAcceptance::None, 5).tasks_held_at_once(), 0);
7002 }
7003
7004 #[test]
7007 fn tasks_held_at_once_of_the_default_lifecycle_and_default_depth() {
7008 assert_eq!(LifecycleConfig::default().tasks_held_at_once(), 1);
7009 let queue = RuntimeManifest::from_yaml_str(
7010 "name: cap\nversion: 0.0.1\nlifecycle:\n task_acceptance: queue\n",
7011 )
7012 .unwrap()
7013 .effective_lifecycle();
7014 assert_eq!(queue.queue_depth, 1);
7015 assert_eq!(queue.tasks_held_at_once(), 2);
7016 }
7017
7018 #[test]
7021 fn absent_trace_block_stays_absent_and_defaults_to_meta() {
7022 let manifest = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\n").unwrap();
7023 assert_eq!(manifest.trace, None);
7024 assert_eq!(TraceCapture::default(), TraceCapture::Meta);
7025 }
7026
7027 #[test]
7028 fn each_capture_mode_parses_from_the_trace_block() {
7029 for (yaml, expected) in [
7030 ("none", TraceCapture::None),
7031 ("meta", TraceCapture::Meta),
7032 ("content", TraceCapture::Content),
7033 ] {
7034 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_trace(&format!(
7035 " capture: {yaml}\n"
7036 )))
7037 .unwrap_or_else(|e| panic!("'{yaml}' must parse: {e}"));
7038 assert_eq!(
7039 manifest.trace,
7040 Some(TraceConfig {
7041 capture: expected,
7042 retain: None
7043 })
7044 );
7045 }
7046 }
7047
7048 #[test]
7054 fn no_retain_block_anywhere_parses_as_no_policy() {
7055 let manifest = RuntimeManifest::from_yaml_str(
7056 "name: cap\nversion: 0.0.1\ntrace:\n capture: content\ncontext:\n record: on\n",
7057 )
7058 .unwrap();
7059 assert_eq!(manifest.trace.unwrap().retain, None);
7060 assert_eq!(manifest.context.unwrap().retain, None);
7061 }
7062
7063 #[test]
7064 fn trace_retain_parses_both_keys_and_the_day_suffix() {
7065 let manifest = RuntimeManifest::from_yaml_str(
7066 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 50\n max_age: 14d\n",
7067 )
7068 .unwrap();
7069 assert_eq!(
7070 manifest.trace.unwrap().retain,
7071 Some(TraceRetainConfig {
7072 max_sessions: Some(50),
7073 max_age_secs: Some(14 * 86_400),
7074 })
7075 );
7076 }
7077
7078 fn context_images_of(block: &str) -> Result<ContextImagesConfig, RuntimeManifestError> {
7079 RuntimeManifest::from_yaml_str(&format!(
7080 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n{block}"
7081 ))
7082 .map(|manifest| manifest.context.unwrap().images)
7083 }
7084
7085 #[test]
7086 fn context_images_defaults_apply_without_the_block_and_to_each_absent_key() {
7087 let defaults = ContextImagesConfig {
7088 keep: 5,
7089 max_bytes: 5 * 1024 * 1024,
7090 max_per_turn: 5,
7091 };
7092 assert_eq!(ContextImagesConfig::default(), defaults);
7093 assert_eq!(context_images_of("").unwrap(), defaults);
7094 assert_eq!(
7095 context_images_of(" images:\n keep: 2\n").unwrap(),
7096 ContextImagesConfig {
7097 keep: 2,
7098 ..defaults
7099 }
7100 );
7101 }
7102
7103 #[test]
7104 fn context_images_parses_every_key() {
7105 assert_eq!(
7106 context_images_of(" images:\n keep: 1\n max_bytes: 1Ki\n max_per_turn: 9\n")
7107 .unwrap(),
7108 ContextImagesConfig {
7109 keep: 1,
7110 max_bytes: 1024,
7111 max_per_turn: 9,
7112 }
7113 );
7114 assert_eq!(
7115 context_images_of(" images:\n max_bytes: 4096\n")
7116 .unwrap()
7117 .max_bytes,
7118 4096
7119 );
7120 }
7121
7122 #[test]
7123 fn context_images_refuses_zero_and_a_decimal_byte_suffix() {
7124 for (block, field) in [
7125 (" images:\n keep: 0\n", "context.images.keep"),
7126 (
7127 " images:\n max_per_turn: 0\n",
7128 "context.images.max_per_turn",
7129 ),
7130 (" images:\n max_bytes: 0\n", "context.images.max_bytes"),
7131 (
7132 " images:\n max_bytes: 10MB\n",
7133 "context.images.max_bytes",
7134 ),
7135 (" images:\n keep: many\n", "context.images.keep"),
7136 ] {
7137 match context_images_of(block) {
7138 Err(RuntimeManifestError::InvalidInferenceConfig { field: got, .. }) => {
7139 assert_eq!(got, field, "{block}")
7140 }
7141 other => panic!("{block}: expected a refusal naming {field}, got {other:?}"),
7142 }
7143 }
7144 let error = context_images_of(" images:\n keep: 0\n").unwrap_err();
7145 assert!(
7146 error.to_string().contains("got '0'"),
7147 "the value is named as written: {error}"
7148 );
7149 }
7150
7151 #[test]
7152 fn context_images_unknown_key_is_suggested() {
7153 assert_eq!(
7154 unknown_keys_of("name: cap\nversion: 0.1.0\ncontext:\n images:\n kep: 3\n"),
7155 vec![(
7156 "kep".to_string(),
7157 "context.images".to_string(),
7158 Some("keep".to_string())
7159 )]
7160 );
7161 }
7162
7163 #[test]
7164 fn context_retain_parses_both_keys_and_either_alone() {
7165 let both = RuntimeManifest::from_yaml_str(
7166 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n max_messages: 2000\n max_age: 90d\n",
7167 )
7168 .unwrap();
7169 assert_eq!(
7170 both.context.unwrap().retain,
7171 Some(ContextRetainConfig {
7172 max_messages: Some(2000),
7173 max_age_secs: Some(90 * 86_400),
7174 })
7175 );
7176
7177 let count_only = RuntimeManifest::from_yaml_str(
7178 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 10\n",
7179 )
7180 .unwrap();
7181 assert_eq!(
7182 count_only.context.unwrap().retain,
7183 Some(ContextRetainConfig {
7184 max_messages: Some(10),
7185 max_age_secs: None,
7186 })
7187 );
7188 }
7189
7190 #[test]
7193 fn an_empty_retain_block_is_refused_naming_the_block() {
7194 for (yaml, expect_trace) in [
7195 (
7196 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain: {}\n",
7197 true,
7198 ),
7199 (
7200 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n",
7201 true,
7202 ),
7203 (
7204 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain: {}\n",
7205 false,
7206 ),
7207 (
7208 "name: cap\nversion: 0.0.1\ncontext:\n record: on\n retain:\n",
7209 false,
7210 ),
7211 ] {
7212 let err = RuntimeManifest::from_yaml_str(yaml)
7213 .expect_err("an empty retain block must be refused");
7214 let (field, message) = match (&err, expect_trace) {
7215 (RuntimeManifestError::InvalidTraceConfig { field, message }, true) => {
7216 (field, message)
7217 }
7218 (RuntimeManifestError::InvalidInferenceConfig { field, message }, false) => {
7219 (field, message)
7220 }
7221 _ => panic!("wrong variant for {yaml:?}: {err:?}"),
7222 };
7223 assert_eq!(
7224 field,
7225 if expect_trace {
7226 "trace.retain"
7227 } else {
7228 "context.retain"
7229 }
7230 );
7231 assert!(message.contains("at least one key"), "{message}");
7232 }
7233 }
7234
7235 #[test]
7238 fn a_zero_retain_limit_is_refused_naming_the_key() {
7239 let trace = RuntimeManifest::from_yaml_str(
7240 "name: cap\nversion: 0.0.1\ntrace:\n capture: meta\n retain:\n max_sessions: 0\n",
7241 )
7242 .expect_err("max_sessions: 0 must be refused");
7243 assert!(
7244 matches!(&trace, RuntimeManifestError::InvalidTraceConfig { field, .. }
7245 if field == "trace.retain.max_sessions"),
7246 "{trace:?}"
7247 );
7248
7249 let context = RuntimeManifest::from_yaml_str(
7250 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_messages: 0\n",
7251 )
7252 .expect_err("max_messages: 0 must be refused");
7253 assert!(
7254 matches!(&context, RuntimeManifestError::InvalidInferenceConfig { field, .. }
7255 if field == "context.retain.max_messages"),
7256 "{context:?}"
7257 );
7258
7259 let age = RuntimeManifest::from_yaml_str(
7260 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_age: 0\n",
7261 )
7262 .expect_err("max_age: 0 must be refused");
7263 assert!(
7264 matches!(&age, RuntimeManifestError::InvalidTraceConfig { field, .. }
7265 if field == "trace.retain.max_age"),
7266 "{age:?}"
7267 );
7268 }
7269
7270 #[test]
7271 fn an_unknown_retain_key_is_refused_listing_the_accepted_ones() {
7272 let err = RuntimeManifest::from_yaml_str(
7273 "name: cap\nversion: 0.0.1\ntrace:\n retain:\n max_traces: 3\n",
7274 )
7275 .expect_err("an unknown key must be refused");
7276 match err {
7277 RuntimeManifestError::InvalidTraceConfig { field, message } => {
7278 assert_eq!(field, "trace.retain");
7279 assert!(message.contains("max_traces"), "{message}");
7280 assert!(message.contains("max_sessions"), "{message}");
7281 }
7282 other => panic!("expected InvalidTraceConfig, got {other:?}"),
7283 }
7284 }
7285
7286 #[test]
7287 fn an_unparseable_retain_duration_reports_the_accepted_form() {
7288 let err = RuntimeManifest::from_yaml_str(
7289 "name: cap\nversion: 0.0.1\ncontext:\n retain:\n max_age: 5 weeks\n",
7290 )
7291 .expect_err("'5 weeks' must be refused");
7292 match err {
7293 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
7294 assert_eq!(field, "context.retain.max_age");
7295 assert!(message.contains(DURATION_ACCEPTED_FORM), "{message}");
7296 }
7297 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
7298 }
7299 }
7300
7301 #[test]
7302 fn unparseable_capture_value_names_the_field_and_accepted_values() {
7303 let err = RuntimeManifest::from_yaml_str(&manifest_with_trace(" capture: verbose\n"))
7304 .expect_err("'verbose' is not a capture mode");
7305 let rendered = err.to_string();
7306 assert!(rendered.contains("trace.capture"), "{rendered}");
7307 for accepted in TRACE_CAPTURE_ACCEPTED_VALUES {
7308 assert!(rendered.contains(accepted), "{rendered}");
7309 }
7310 }
7311
7312 #[test]
7313 fn resources_block_is_optional_and_absent_stays_absent() {
7314 let manifest = RuntimeManifest::from_yaml_str(
7315 r#"
7316name: cap
7317version: 0.0.1
7318capabilities:
7319 shell:
7320 allow: [bash]
7321"#,
7322 )
7323 .unwrap();
7324
7325 let caps = manifest.capabilities.expect("shell block must parse");
7329 assert_eq!(caps.resources, None);
7330 }
7331
7332 #[test]
7333 fn resources_fields_parse_and_each_stays_independently_optional() {
7334 let manifest = RuntimeManifest::from_yaml_str(
7335 r#"
7336name: cap
7337version: 0.0.1
7338capabilities:
7339 resources:
7340 max_processes: 32
7341 max_open_files: 16
7342 max_file_size_bytes: 1048576
7343 cpu_seconds: 30
7344 memory_bytes: 268435456
7345 cgroup_memory_bytes: 536870912
7346 cgroup_pids_max: 64
7347 cgroup_cpu_percent: 50
7348 cgroup_io_bytes_per_sec: 1048576
7349 workdir_max_bytes: 2097152
7350"#,
7351 )
7352 .unwrap();
7353
7354 let resources = manifest
7355 .capabilities
7356 .and_then(|caps| caps.resources)
7357 .expect("resources block must parse");
7358 assert_eq!(resources.max_processes, Some(32));
7359 assert_eq!(resources.max_open_files, Some(16));
7360 assert_eq!(resources.max_file_size_bytes, Some(1_048_576));
7361 assert_eq!(resources.cpu_seconds, Some(30));
7362 assert_eq!(resources.memory_bytes, Some(268_435_456));
7363 assert_eq!(resources.cgroup_memory_bytes, Some(536_870_912));
7364 assert_eq!(resources.cgroup_pids_max, Some(64));
7365 assert_eq!(resources.cgroup_cpu_percent, Some(50));
7366 assert_eq!(resources.cgroup_io_bytes_per_sec, Some(1_048_576));
7367 assert_eq!(resources.workdir_max_bytes, Some(2_097_152));
7368 }
7369
7370 #[test]
7371 fn state_block_is_optional_and_absent_stays_absent() {
7372 let manifest = RuntimeManifest::from_yaml_str(
7373 r#"
7374name: cap
7375version: 0.0.1
7376artifacts:
7377 - name: local/notes-tool
7378 version: 1.0.0
7379 runtime: tool
7380 capabilities:
7381 filesystem:
7382 scope: cache
7383"#,
7384 )
7385 .unwrap();
7386
7387 let caps = manifest.artifacts[0]
7389 .capabilities
7390 .as_ref()
7391 .expect("filesystem block must parse");
7392 assert_eq!(caps.state, None);
7393 }
7394
7395 #[test]
7399 fn state_block_declared_empty_grants_the_capsule_named_store() {
7400 let manifest = RuntimeManifest::from_yaml_str(
7401 r#"
7402name: cap
7403version: 0.0.1
7404artifacts:
7405 - name: local/notes-tool
7406 version: 1.0.0
7407 runtime: tool
7408 capabilities:
7409 state: {}
7410"#,
7411 )
7412 .unwrap();
7413
7414 let state = manifest.artifacts[0]
7415 .capabilities
7416 .as_ref()
7417 .and_then(|caps| caps.state.as_ref())
7418 .expect("state block must parse");
7419 assert_eq!(state.store, None);
7420 }
7421
7422 #[test]
7423 fn state_store_name_parses_and_stays_independently_optional() {
7424 let manifest = RuntimeManifest::from_yaml_str(
7425 r#"
7426name: cap
7427version: 0.0.1
7428artifacts:
7429 - name: local/notes-tool
7430 version: 1.0.0
7431 runtime: tool
7432 capabilities:
7433 state:
7434 store: " shey "
7435 network:
7436 allow: [https://api.example.com]
7437"#,
7438 )
7439 .unwrap();
7440
7441 let caps = manifest.artifacts[0]
7442 .capabilities
7443 .as_ref()
7444 .expect("capabilities must parse");
7445 assert_eq!(
7448 caps.state.as_ref().and_then(|state| state.store.as_deref()),
7449 Some("shey")
7450 );
7451 assert_eq!(
7453 caps.network
7454 .as_ref()
7455 .map(|network| network.allow.as_slice()),
7456 Some(["https://api.example.com".to_string()].as_slice())
7457 );
7458 assert_eq!(caps.filesystem, None);
7459 }
7460
7461 #[test]
7465 fn state_store_name_declared_empty_is_kept_for_the_runtime_to_refuse() {
7466 let manifest = RuntimeManifest::from_yaml_str(
7467 r#"
7468name: cap
7469version: 0.0.1
7470artifacts:
7471 - name: local/notes-tool
7472 version: 1.0.0
7473 runtime: tool
7474 capabilities:
7475 state:
7476 store: ""
7477"#,
7478 )
7479 .unwrap();
7480
7481 assert_eq!(
7482 manifest.artifacts[0]
7483 .capabilities
7484 .as_ref()
7485 .and_then(|caps| caps.state.as_ref())
7486 .and_then(|state| state.store.as_deref()),
7487 Some("")
7488 );
7489 }
7490
7491 #[test]
7492 fn resources_partial_block_leaves_undeclared_fields_none() {
7493 let manifest = RuntimeManifest::from_yaml_str(
7494 r#"
7495name: cap
7496version: 0.0.1
7497capabilities:
7498 resources:
7499 max_open_files: 16
7500"#,
7501 )
7502 .unwrap();
7503
7504 let resources = manifest
7505 .capabilities
7506 .and_then(|caps| caps.resources)
7507 .expect("resources block must parse");
7508 assert_eq!(resources.max_open_files, Some(16));
7509 assert_eq!(resources.max_processes, None);
7510 assert_eq!(resources.workdir_max_bytes, None);
7511 }
7512
7513 #[test]
7517 fn resources_zero_is_rejected_on_every_field() {
7518 for field in [
7519 "max_processes",
7520 "max_open_files",
7521 "max_file_size_bytes",
7522 "cpu_seconds",
7523 "memory_bytes",
7524 "cgroup_memory_bytes",
7525 "cgroup_pids_max",
7526 "cgroup_cpu_percent",
7527 "cgroup_io_bytes_per_sec",
7528 "workdir_max_bytes",
7529 ] {
7530 let yaml =
7531 format!("name: cap\nversion: 0.0.1\ncapabilities:\n resources:\n {field}: 0\n");
7532 let error =
7533 RuntimeManifest::from_yaml_str(&yaml).expect_err("a zero {field} must not parse");
7534
7535 match error {
7536 RuntimeManifestError::InvalidCapabilities {
7537 field: reported,
7538 message,
7539 } => {
7540 assert_eq!(reported, format!("capabilities.resources.{field}"));
7541 assert_eq!(message, "must be greater than zero");
7542 }
7543 other => panic!("expected InvalidCapabilities for {field}, got {other:?}"),
7544 }
7545 }
7546 }
7547
7548 #[test]
7549 fn artifact_runtime_defaults_to_tool() {
7550 let manifest = RuntimeManifest::from_yaml_str(
7551 r#"
7552name: cap
7553version: 0.0.1
7554artifacts:
7555 - name: local/echo
7556 version: 1.2.3
7557"#,
7558 )
7559 .unwrap();
7560
7561 assert_eq!(manifest.artifacts.len(), 1);
7562 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7563 }
7564
7565 #[test]
7566 fn artifact_runtime_tool_explicit() {
7567 let manifest = RuntimeManifest::from_yaml_str(
7568 r#"
7569name: cap
7570version: 0.0.1
7571artifacts:
7572 - name: local/echo
7573 version: 1.2.3
7574 runtime: tool
7575"#,
7576 )
7577 .unwrap();
7578
7579 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Tool);
7580 }
7581
7582 #[test]
7583 fn artifact_runtime_wasm_is_error() {
7584 let err = RuntimeManifest::from_yaml_str(
7585 r#"
7586name: cap
7587version: 0.0.1
7588artifacts:
7589 - name: local/echo
7590 version: 1.2.3
7591 runtime: wasm
7592"#,
7593 )
7594 .unwrap_err();
7595
7596 let msg = err.to_string();
7597 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7598 assert!(msg.contains("wasm"), "error was: {msg}");
7599 }
7600
7601 #[test]
7602 fn artifact_runtime_native_is_error() {
7603 let err = RuntimeManifest::from_yaml_str(
7604 r#"
7605name: cap
7606version: 0.0.1
7607artifacts:
7608 - name: local/echo
7609 version: 1.2.3
7610 runtime: native
7611"#,
7612 )
7613 .unwrap_err();
7614
7615 let msg = err.to_string();
7616 assert!(msg.contains("use 'runtime: tool'"), "error was: {msg}");
7617 assert!(msg.contains("native"), "error was: {msg}");
7618 }
7619
7620 #[test]
7621 fn invalid_artifact_reports_index() {
7622 let err = RuntimeManifest::from_yaml_str(
7623 r#"
7624name: cap
7625version: 0.0.1
7626artifacts:
7627 - name: ""
7628 version: 1.2.3
7629"#,
7630 )
7631 .unwrap_err();
7632
7633 let msg = err.to_string();
7634 assert!(msg.contains("index 0"));
7635 assert!(msg.contains("missing required field 'name'"));
7636 }
7637
7638 #[test]
7639 fn capabilities_parse_when_all_fields_present() {
7640 let manifest = RuntimeManifest::from_yaml_str(
7641 r#"
7642name: cap
7643version: 0.0.1
7644artifacts:
7645 - name: local/echo
7646 version: 1.2.3
7647capabilities:
7648 network:
7649 allow:
7650 - https://api.anthropic.com
7651 - http://127.0.0.1:8080
7652 filesystem:
7653 scope: ./sandbox
7654"#,
7655 )
7656 .unwrap();
7657
7658 let capabilities = manifest.capabilities.expect("capabilities should exist");
7659 assert_eq!(
7660 capabilities.network.unwrap().allow,
7661 vec![
7662 "https://api.anthropic.com".to_string(),
7663 "http://127.0.0.1:8080".to_string()
7664 ]
7665 );
7666 assert_eq!(
7667 capabilities.filesystem.unwrap().scope,
7668 Some("./sandbox".to_string())
7669 );
7670 }
7671
7672 #[test]
7673 fn env_capabilities_parse_allowlist() {
7674 let manifest = RuntimeManifest::from_yaml_str(
7675 r#"
7676name: cap
7677version: 0.0.1
7678artifacts:
7679 - name: local/echo
7680 version: 1.2.3
7681capabilities:
7682 env:
7683 allow:
7684 - MY_APP_REGION
7685 - GITHUB_TOKEN
7686"#,
7687 )
7688 .unwrap();
7689
7690 let capabilities = manifest.capabilities.expect("capabilities should exist");
7691 assert_eq!(
7692 capabilities.env.unwrap().allow,
7693 vec!["MY_APP_REGION".to_string(), "GITHUB_TOKEN".to_string()]
7694 );
7695 }
7696
7697 #[test]
7698 fn env_capabilities_absent_when_block_omitted() {
7699 let manifest = RuntimeManifest::from_yaml_str(
7700 r#"
7701name: cap
7702version: 0.0.1
7703artifacts:
7704 - name: local/echo
7705 version: 1.2.3
7706capabilities:
7707 network:
7708 allow:
7709 - https://api.anthropic.com
7710"#,
7711 )
7712 .unwrap();
7713
7714 assert!(manifest.capabilities.unwrap().env.is_none());
7715 }
7716
7717 #[test]
7720 fn env_capabilities_allow_empty_list_is_accepted() {
7721 let manifest = RuntimeManifest::from_yaml_str(
7722 r#"
7723name: cap
7724version: 0.0.1
7725artifacts:
7726 - name: local/echo
7727 version: 1.2.3
7728capabilities:
7729 env: {}
7730"#,
7731 )
7732 .unwrap();
7733
7734 assert_eq!(
7735 manifest.capabilities.unwrap().env,
7736 Some(EnvCapabilities { allow: vec![] })
7737 );
7738 }
7739
7740 #[test]
7741 fn missing_capabilities_block_is_backward_compatible() {
7742 let manifest = RuntimeManifest::from_yaml_str(
7743 r#"
7744name: cap
7745version: 0.0.1
7746artifacts:
7747 - name: local/echo
7748 version: 1.2.3
7749"#,
7750 )
7751 .unwrap();
7752
7753 assert!(manifest.capabilities.is_none());
7754 }
7755
7756 #[test]
7757 fn network_only_capabilities_parse() {
7758 let manifest = RuntimeManifest::from_yaml_str(
7759 r#"
7760name: cap
7761version: 0.0.1
7762artifacts:
7763 - name: local/echo
7764 version: 1.2.3
7765capabilities:
7766 network:
7767 allow:
7768 - https://allowed.example.com
7769"#,
7770 )
7771 .unwrap();
7772
7773 let capabilities = manifest.capabilities.unwrap();
7774 assert_eq!(
7775 capabilities.network.unwrap().allow,
7776 vec!["https://allowed.example.com".to_string()]
7777 );
7778 assert!(capabilities.filesystem.is_none());
7779 }
7780
7781 #[test]
7782 fn network_allow_defaults_to_empty_when_present_without_allow_entries() {
7783 let manifest = RuntimeManifest::from_yaml_str(
7784 r#"
7785name: cap
7786version: 0.0.1
7787artifacts:
7788 - name: local/echo
7789 version: 1.2.3
7790capabilities:
7791 network: {}
7792"#,
7793 )
7794 .unwrap();
7795
7796 let capabilities = manifest.capabilities.unwrap();
7797 assert_eq!(capabilities.network.unwrap().allow, Vec::<String>::new());
7798 }
7799
7800 #[test]
7803 fn network_unix_sockets_defaults_to_false_when_key_is_absent() {
7804 let manifest = RuntimeManifest::from_yaml_str(
7805 r#"
7806name: cap
7807version: 0.0.1
7808artifacts:
7809 - name: local/echo
7810 version: 1.2.3
7811capabilities:
7812 network:
7813 allow:
7814 - https://api.anthropic.com
7815"#,
7816 )
7817 .unwrap();
7818
7819 let network = manifest.capabilities.unwrap().network.unwrap();
7820 assert_eq!(network.allow, vec!["https://api.anthropic.com".to_string()]);
7821 assert!(!network.unix_sockets);
7822 }
7823
7824 #[test]
7825 fn network_unix_sockets_parses_explicit_false() {
7826 let manifest = RuntimeManifest::from_yaml_str(
7827 r#"
7828name: cap
7829version: 0.0.1
7830artifacts:
7831 - name: local/echo
7832 version: 1.2.3
7833capabilities:
7834 network:
7835 unix_sockets: false
7836"#,
7837 )
7838 .unwrap();
7839
7840 assert!(!manifest.capabilities.unwrap().network.unwrap().unix_sockets);
7841 }
7842
7843 #[test]
7844 fn network_unix_sockets_parses_explicit_true() {
7845 let manifest = RuntimeManifest::from_yaml_str(
7846 r#"
7847name: cap
7848version: 0.0.1
7849artifacts:
7850 - name: local/echo
7851 version: 1.2.3
7852capabilities:
7853 network:
7854 unix_sockets: true
7855"#,
7856 )
7857 .unwrap();
7858
7859 let network = manifest.capabilities.unwrap().network.unwrap();
7860 assert!(network.unix_sockets);
7861 assert_eq!(network.allow, Vec::<String>::new());
7863 }
7864
7865 #[test]
7869 fn filesystem_read_only_is_trimmed_and_empty_entries_are_dropped() {
7870 let manifest = RuntimeManifest::from_yaml_str(
7871 r#"
7872name: cap
7873version: 0.0.1
7874capabilities:
7875 filesystem:
7876 read_only:
7877 - tests
7878 - " bench/fixtures "
7879 - ""
7880 - " "
7881"#,
7882 )
7883 .unwrap();
7884
7885 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7886 assert_eq!(filesystem.read_only, vec!["tests", "bench/fixtures"]);
7887 }
7888
7889 #[test]
7891 fn filesystem_read_only_defaults_to_empty_when_the_key_is_absent() {
7892 let manifest = RuntimeManifest::from_yaml_str(
7893 r#"
7894name: cap
7895version: 0.0.1
7896capabilities:
7897 filesystem:
7898 scope: workdir
7899"#,
7900 )
7901 .unwrap();
7902
7903 assert!(manifest
7904 .capabilities
7905 .unwrap()
7906 .filesystem
7907 .unwrap()
7908 .read_only
7909 .is_empty());
7910 }
7911
7912 #[test]
7915 fn filesystem_read_only_does_not_judge_path_shape() {
7916 let manifest = RuntimeManifest::from_yaml_str(
7917 r#"
7918name: cap
7919version: 0.0.1
7920capabilities:
7921 filesystem:
7922 read_only:
7923 - /etc
7924 - ../outside
7925"#,
7926 )
7927 .expect("shape is judged by the runtime, not the parser");
7928
7929 assert_eq!(
7930 manifest.capabilities.unwrap().filesystem.unwrap().read_only,
7931 vec!["/etc", "../outside"]
7932 );
7933 }
7934
7935 #[test]
7939 fn filesystem_workdir_exec_defaults_to_false_when_key_is_absent() {
7940 let manifest = RuntimeManifest::from_yaml_str(
7941 r#"
7942name: cap
7943version: 0.0.1
7944artifacts:
7945 - name: local/echo
7946 version: 1.2.3
7947capabilities:
7948 filesystem:
7949 scope: workdir
7950"#,
7951 )
7952 .unwrap();
7953
7954 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
7955 assert_eq!(filesystem.scope.as_deref(), Some("workdir"));
7956 assert!(!filesystem.workdir_exec);
7957 }
7958
7959 #[test]
7964 fn filesystem_block_stays_absent_when_undeclared() {
7965 let manifest = RuntimeManifest::from_yaml_str(
7966 r#"
7967name: cap
7968version: 0.0.1
7969artifacts:
7970 - name: local/echo
7971 version: 1.2.3
7972capabilities:
7973 network:
7974 allow:
7975 - https://api.anthropic.com
7976"#,
7977 )
7978 .unwrap();
7979
7980 assert!(manifest.capabilities.unwrap().filesystem.is_none());
7981 }
7982
7983 #[test]
7984 fn filesystem_workdir_exec_parses_explicit_false() {
7985 let manifest = RuntimeManifest::from_yaml_str(
7986 r#"
7987name: cap
7988version: 0.0.1
7989artifacts:
7990 - name: local/echo
7991 version: 1.2.3
7992capabilities:
7993 filesystem:
7994 workdir_exec: false
7995"#,
7996 )
7997 .unwrap();
7998
7999 assert!(
8000 !manifest
8001 .capabilities
8002 .unwrap()
8003 .filesystem
8004 .unwrap()
8005 .workdir_exec
8006 );
8007 }
8008
8009 #[test]
8010 fn filesystem_workdir_exec_parses_explicit_true() {
8011 let manifest = RuntimeManifest::from_yaml_str(
8012 r#"
8013name: cap
8014version: 0.0.1
8015artifacts:
8016 - name: local/echo
8017 version: 1.2.3
8018capabilities:
8019 filesystem:
8020 workdir_exec: true
8021"#,
8022 )
8023 .unwrap();
8024
8025 let filesystem = manifest.capabilities.unwrap().filesystem.unwrap();
8026 assert!(filesystem.workdir_exec);
8027 assert_eq!(filesystem.scope, None);
8030 }
8031
8032 #[test]
8033 fn network_internal_port_parses() {
8034 let manifest = RuntimeManifest::from_yaml_str(
8035 r#"
8036name: worker
8037version: 0.1.0
8038artifacts: []
8039
8040network:
8041 internal_port: 58172
8042"#,
8043 )
8044 .unwrap();
8045
8046 assert_eq!(
8047 manifest.network.unwrap().internal_port,
8048 Some(58172),
8049 "network.internal_port should parse to Some(58172)"
8050 );
8051 }
8052
8053 #[test]
8054 fn network_internal_port_absent_when_omitted() {
8055 let manifest = RuntimeManifest::from_yaml_str(
8056 r#"
8057name: worker
8058version: 0.1.0
8059artifacts: []
8060"#,
8061 )
8062 .unwrap();
8063
8064 assert!(manifest.network.is_none());
8065 }
8066
8067 #[test]
8068 fn shell_capabilities_parse_with_allow_and_env_fields() {
8069 let manifest = RuntimeManifest::from_yaml_str(
8070 r#"
8071name: cap
8072version: 0.0.1
8073artifacts: []
8074capabilities:
8075 shell:
8076 allow:
8077 - bash
8078 - git
8079 strip_env:
8080 - "*_TOKEN"
8081 - SECRET_KEY
8082 baseline_env:
8083 - SSH_AUTH_SOCK
8084"#,
8085 )
8086 .unwrap();
8087
8088 let shell = manifest
8089 .capabilities
8090 .unwrap()
8091 .shell
8092 .expect("shell capabilities should be present");
8093 assert_eq!(shell.allow, vec!["bash".to_string(), "git".to_string()]);
8094 assert_eq!(
8095 shell.strip_env,
8096 Some(vec!["*_TOKEN".to_string(), "SECRET_KEY".to_string()])
8097 );
8098 assert_eq!(shell.baseline_env, Some(vec!["SSH_AUTH_SOCK".to_string()]));
8099 }
8100
8101 #[test]
8102 fn shell_capabilities_require_non_empty_allowlist() {
8103 let err = RuntimeManifest::from_yaml_str(
8104 r#"
8105name: cap
8106version: 0.0.1
8107artifacts: []
8108capabilities:
8109 shell:
8110 allow: []
8111"#,
8112 )
8113 .unwrap_err();
8114
8115 let msg = err.to_string();
8116 assert!(msg.contains("capabilities.shell.allow"));
8117 assert!(msg.contains("at least one"));
8118 }
8119
8120 fn shell_allow_error(entry: &str) -> String {
8121 let yaml = format!(
8122 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n shell:\n allow: [bash, {}]\n",
8123 serde_json::to_string(entry).unwrap()
8124 );
8125 RuntimeManifest::from_yaml_str(&yaml)
8126 .expect_err(&format!("{entry:?} must be refused"))
8127 .to_string()
8128 }
8129
8130 #[test]
8131 fn shell_allow_refuses_an_entry_that_is_not_a_bare_name() {
8132 for (entry, reason) in [
8133 ("/tmp/uv-tool/bin/uv", "is a path"),
8134 ("bin/uv", "is a path"),
8135 ("..", "names a directory"),
8136 (".", "names a directory"),
8137 ("", "is empty"),
8138 ("a b", "contains whitespace"),
8139 ("uv\t", "contains whitespace"),
8140 ("uv\0", "contains a NUL byte"),
8141 ] {
8142 let message = shell_allow_error(entry);
8143 assert!(
8144 message.contains("'capabilities.shell.allow[1]'"),
8145 "{entry:?}: {message}"
8146 );
8147 assert!(
8148 message.contains(&format!(
8149 "'{}' {reason}; an entry is a bare binary name",
8150 entry.escape_debug()
8151 )),
8152 "{entry:?}: {message}"
8153 );
8154 }
8155 }
8156
8157 #[test]
8158 fn shell_allow_accepts_bare_names() {
8159 let manifest = RuntimeManifest::from_yaml_str(
8160 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n shell:\n allow: [bash, python3.12, git-lfs, \"c++\"]\n",
8161 )
8162 .unwrap();
8163 assert_eq!(
8164 manifest.capabilities.unwrap().shell.unwrap().allow,
8165 ["bash", "python3.12", "git-lfs", "c++"]
8166 );
8167 }
8168
8169 #[test]
8170 fn interpreter_runtime_parses_accepted_shape() {
8171 let manifest = RuntimeManifest::from_yaml_str(
8172 r#"
8173name: cap
8174version: 0.0.1
8175artifacts: []
8176capabilities:
8177 shell:
8178 allow:
8179 - python3
8180 interpreter_runtime:
8181 - binary: python3
8182 dirs:
8183 - path: /usr/lib/python3.11
8184 list_dir: true
8185 - path: /usr/lib/python3.11/lib-dynload
8186 list_dir: false
8187"#,
8188 )
8189 .unwrap();
8190
8191 let shell = manifest.capabilities.unwrap().shell.unwrap();
8192 assert_eq!(
8193 shell.interpreter_runtime,
8194 vec![InterpreterRuntimeGrant {
8195 binary: "python3".to_string(),
8196 dirs: vec![
8197 InterpreterRuntimeDir {
8198 path: "/usr/lib/python3.11".to_string(),
8199 list_dir: true,
8200 },
8201 InterpreterRuntimeDir {
8202 path: "/usr/lib/python3.11/lib-dynload".to_string(),
8203 list_dir: false,
8204 },
8205 ],
8206 }]
8207 );
8208 }
8209
8210 #[test]
8211 fn interpreter_runtime_defaults_to_empty_when_absent() {
8212 let manifest = RuntimeManifest::from_yaml_str(
8213 r#"
8214name: cap
8215version: 0.0.1
8216artifacts: []
8217capabilities:
8218 shell:
8219 allow:
8220 - python3
8221"#,
8222 )
8223 .unwrap();
8224
8225 let shell = manifest.capabilities.unwrap().shell.unwrap();
8226 assert!(shell.interpreter_runtime.is_empty());
8227 }
8228
8229 fn interpreter_runtime_reject(grant_yaml: &str) -> String {
8232 let yaml = format!(
8233 r#"
8234name: cap
8235version: 0.0.1
8236artifacts: []
8237capabilities:
8238 shell:
8239 allow:
8240 - python3
8241 interpreter_runtime:
8242{grant_yaml}
8243"#,
8244 );
8245 RuntimeManifest::from_yaml_str(&yaml)
8246 .expect_err("grant should be rejected")
8247 .to_string()
8248 }
8249
8250 #[test]
8251 fn interpreter_runtime_rejects_binary_not_in_allow() {
8252 let msg = interpreter_runtime_reject(
8253 " - binary: ruby\n dirs:\n - path: /usr/lib/ruby\n list_dir: true\n",
8254 );
8255 assert!(
8256 msg.contains("capabilities.shell.interpreter_runtime[0].binary"),
8257 "{msg}"
8258 );
8259 assert!(msg.contains("ruby"), "{msg}");
8260 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
8261 }
8262
8263 #[test]
8264 fn interpreter_runtime_rejects_relative_path() {
8265 let msg = interpreter_runtime_reject(
8266 " - binary: python3\n dirs:\n - path: usr/lib/python3.11\n list_dir: true\n",
8267 );
8268 assert!(
8269 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].path"),
8270 "{msg}"
8271 );
8272 assert!(msg.contains("absolute"), "{msg}");
8273 }
8274
8275 fn manifest_with_plan(block: &str) -> String {
8279 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n{block}")
8280 }
8281
8282 #[test]
8283 fn plan_submit_true_grants_the_capsule() {
8284 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n"))
8285 .expect("a plan block parses");
8286 assert_eq!(
8287 manifest.capabilities.unwrap().plan,
8288 Some(PlanCapabilities { submit: true })
8289 );
8290 }
8291
8292 #[test]
8295 fn plan_submit_false_and_an_absent_block_both_leave_the_capsule_ungranted() {
8296 let explicit = RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: false\n"))
8297 .expect("an explicit denial parses");
8298 assert_eq!(
8299 explicit.capabilities.unwrap().plan,
8300 Some(PlanCapabilities { submit: false })
8301 );
8302
8303 let absent = RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n")
8304 .expect("no capabilities block parses");
8305 assert!(absent.capabilities.is_none());
8306 }
8307
8308 #[test]
8311 fn plan_without_submit_is_rejected() {
8312 let err = RuntimeManifest::from_yaml_str(&manifest_with_plan(" {}\n"))
8313 .expect_err("an empty plan block is refused");
8314 let msg = err.to_string();
8315 assert!(msg.contains("capabilities.plan.submit"), "{msg}");
8316 assert!(msg.contains("never"), "{msg}");
8317 }
8318
8319 #[test]
8321 fn an_unknown_plan_key_is_reported_by_path() {
8322 let manifest =
8323 RuntimeManifest::from_yaml_str(&manifest_with_plan(" submit: true\n depth: 3\n"))
8324 .expect("an unknown key is a warning, not a parse error");
8325 assert!(
8326 manifest
8327 .unknown_keys
8328 .iter()
8329 .any(|key| key.block_path == "capabilities.plan" && key.key == "depth"),
8330 "{:?}",
8331 manifest.unknown_keys
8332 );
8333 }
8334
8335 fn manifest_with_install(block: &str) -> String {
8339 format!("name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n install:\n{block}")
8340 }
8341
8342 #[test]
8343 fn capabilities_install_parses_both_lists_verbatim_in_declaration_order() {
8344 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8345 " skill: [ana/code-review, \"ana/style-*\"]\n tool: [\"local/b-*\", local/a, \"murmur/*\", \"*\"]\n",
8346 ))
8347 .expect("an install block parses");
8348 assert_eq!(
8349 manifest.capabilities.unwrap().install,
8350 Some(InstallCapabilities {
8351 skill: vec!["ana/code-review".into(), "ana/style-*".into()],
8352 tool: vec![
8353 "local/b-*".into(),
8354 "local/a".into(),
8355 "murmur/*".into(),
8356 "*".into()
8357 ],
8358 from: InstallFrom::Stores,
8359 })
8360 );
8361 }
8362
8363 #[test]
8366 fn capabilities_install_accepts_either_list_alone() {
8367 let skill_only =
8368 RuntimeManifest::from_yaml_str(&manifest_with_install(" skill: [local/a]\n"))
8369 .expect("skill alone parses");
8370 assert_eq!(
8371 skill_only.capabilities.unwrap().install,
8372 Some(InstallCapabilities {
8373 skill: vec!["local/a".into()],
8374 tool: vec![],
8375 from: InstallFrom::Stores,
8376 })
8377 );
8378 let tool_only =
8379 RuntimeManifest::from_yaml_str(&manifest_with_install(" tool: [\"local/b-*\"]\n"))
8380 .expect("tool alone parses");
8381 assert_eq!(
8382 tool_only.capabilities.unwrap().install,
8383 Some(InstallCapabilities {
8384 skill: vec![],
8385 tool: vec!["local/b-*".into()],
8386 from: InstallFrom::Stores,
8387 })
8388 );
8389 }
8390
8391 #[test]
8392 fn capabilities_install_absent_is_none() {
8393 let manifest = RuntimeManifest::from_yaml_str(
8394 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n plan:\n submit: true\n",
8395 )
8396 .expect("a capabilities block without install parses");
8397 assert_eq!(manifest.capabilities.unwrap().install, None);
8398 }
8399
8400 fn install_from(list: &str) -> Result<InstallFrom, RuntimeManifestError> {
8401 RuntimeManifest::from_yaml_str(&manifest_with_install(&format!(
8402 " tool: [\"murmur/tool-*\"]\n from: {list}\n"
8403 )))
8404 .map(|manifest| manifest.capabilities.unwrap().install.unwrap().from)
8405 }
8406
8407 #[test]
8408 fn install_from_absent_is_stores() {
8409 let manifest =
8410 RuntimeManifest::from_yaml_str(&manifest_with_install(" tool: [local/a]\n"))
8411 .unwrap();
8412 assert_eq!(
8413 manifest.capabilities.unwrap().install.unwrap().from,
8414 InstallFrom::Stores
8415 );
8416 }
8417
8418 #[test]
8419 fn install_from_accepts_stores_alone_and_with_sources_in_either_order() {
8420 assert_eq!(install_from("[stores]").unwrap(), InstallFrom::Stores);
8421 assert_eq!(
8422 install_from("[stores, sources]").unwrap(),
8423 InstallFrom::StoresAndSources
8424 );
8425 assert_eq!(
8426 install_from("[sources, stores]").unwrap(),
8427 InstallFrom::StoresAndSources
8428 );
8429 assert_eq!(InstallFrom::StoresAndSources.as_list(), "stores, sources");
8430 assert!(InstallFrom::StoresAndSources.sources());
8431 assert!(!InstallFrom::Stores.sources());
8432 }
8433
8434 #[test]
8435 fn install_from_refuses_empty_sourceless_duplicate_and_unknown_lists() {
8436 for (list, why) in [
8437 ("[]", "is empty"),
8438 ("[sources]", "does not name stores"),
8439 ("[stores, stores]", "names \"stores\" twice"),
8440 ("[github]", "names \"github\", which is not a place"),
8441 ] {
8442 match install_from(list) {
8443 Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8444 assert_eq!(field, "capabilities.install.from", "{list}");
8445 assert!(message.contains(why), "{list}: {message}");
8446 assert!(
8447 message.contains("expected [stores] or [stores, sources]"),
8448 "{list}: {message}"
8449 );
8450 }
8451 other => panic!("{list}: expected InvalidCapabilities, got {other:?}"),
8452 }
8453 }
8454 }
8455
8456 #[test]
8457 fn install_from_is_no_unknown_key() {
8458 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8459 " tool: [local/a]\n from: [stores]\n",
8460 ))
8461 .unwrap();
8462 assert!(
8463 manifest
8464 .unknown_keys
8465 .iter()
8466 .all(|key| key.block_path != "capabilities.install"),
8467 "{:?}",
8468 manifest.unknown_keys
8469 );
8470 }
8471
8472 #[test]
8473 fn install_from_capabilities_install_is_refused_under_transport_process() {
8474 let yaml = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n install:\n \
8475 tool: [\"murmur/tool-*\"]\ninference:\n transport: process\n \
8476 command: claude\n model: claude-haiku-4-5-20251001\n driver:\n \
8477 artifact: murmur/driver-claude-code\n";
8478 match RuntimeManifest::from_yaml_str(yaml) {
8479 Err(RuntimeManifestError::InvalidCapabilities { field, message }) => {
8480 assert_eq!(field, "capabilities.install");
8481 assert_eq!(
8482 message,
8483 "is not valid with transport: process; the harness owns its tool list, so \
8484 a pulled artifact could never reach it"
8485 );
8486 }
8487 other => panic!("expected InvalidCapabilities, got {other:?}"),
8488 }
8489 }
8490
8491 #[test]
8494 fn capabilities_install_that_names_nothing_is_refused() {
8495 for block in [
8496 " {}\n",
8497 " skill: []\n",
8498 " skill: []\n tool: []\n",
8499 " driver: [x]\n",
8500 ] {
8501 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(block))
8502 .expect_err("an install block naming nothing is refused");
8503 match &err {
8504 RuntimeManifestError::InvalidCapabilities { field, message } => {
8505 assert_eq!(field, "capabilities.install", "{block:?}");
8506 assert!(message.contains("at least one"), "{message}");
8507 assert!(message.contains("omit the block"), "{message}");
8508 }
8509 other => panic!("{block:?}: expected InvalidCapabilities, got {other:?}"),
8510 }
8511 }
8512 }
8513
8514 #[test]
8515 fn capabilities_install_refuses_an_entry_outside_the_grammar_naming_it() {
8516 for entry in [
8517 "a*b", "*x", "**", "Foo", "-a", "a/b/c", " ", "*/x", "a/*x", "a/x*y", "A/x",
8518 ] {
8519 for key in ["skill", "tool"] {
8520 let block = format!(" {key}: [{entry:?}]\n");
8521 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8522 .expect_err("a bad entry is refused");
8523 match &err {
8524 RuntimeManifestError::InvalidArtifactName {
8525 field,
8526 message,
8527 hint,
8528 } => {
8529 assert_eq!(field, &format!("capabilities.install.{key}[0]"));
8530 assert!(message.contains(&format!("{entry:?}")), "{message}");
8531 assert!(
8532 message.contains(crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM),
8533 "{message}"
8534 );
8535 assert_eq!(hint, &None, "{entry:?}");
8536 }
8537 other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8538 }
8539 }
8540 }
8541 }
8542
8543 #[test]
8545 fn capabilities_install_refuses_an_unscoped_entry_with_the_scoped_hint() {
8546 for (entry, scoped) in [
8547 ("tool-*", "local/tool-*"),
8548 ("mur*", "local/mur*"),
8549 ("tool-x", "local/tool-x"),
8550 ("murmur-tool-editor", "murmur/tool-editor"),
8551 ] {
8552 let block = format!(" tool: [\"local/ok\", {entry:?}]\n");
8553 let err = RuntimeManifest::from_yaml_str(&manifest_with_install(&block))
8554 .expect_err("an unscoped entry is refused");
8555 match &err {
8556 RuntimeManifestError::InvalidArtifactName {
8557 field,
8558 message,
8559 hint,
8560 } => {
8561 assert_eq!(field, "capabilities.install.tool[1]");
8562 assert_eq!(
8563 message,
8564 &format!(
8565 "entry {entry:?} is invalid ('{entry}' names no scope); expected {}",
8566 crate::install_pattern::INSTALL_ENTRY_ACCEPTED_FORM
8567 )
8568 );
8569 let hint = hint.as_deref().expect("an unscoped entry carries a hint");
8570 assert!(hint.contains(scoped), "{entry:?}: {hint}");
8571 }
8572 other => panic!("{entry:?}: expected InvalidArtifactName, got {other:?}"),
8573 }
8574 assert!(
8575 err.to_string().contains("'capabilities.install.tool[1]'"),
8576 "{err}"
8577 );
8578 }
8579 }
8580
8581 #[test]
8583 fn capabilities_install_on_an_artifact_entry_is_refused_on_every_role() {
8584 for runtime in ["tool", "hook", "driver", "skill"] {
8585 let yaml = format!(
8586 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/a\n version: 0.1.0\n \
8587 runtime: {runtime}\n capabilities:\n install:\n skill: [x]\n"
8588 );
8589 let err = RuntimeManifest::from_yaml_str(&yaml)
8590 .expect_err("a per-artifact install block is refused");
8591 match &err {
8592 RuntimeManifestError::InvalidArtifact { index, message } => {
8593 assert_eq!(*index, 0);
8594 assert!(message.contains("capabilities.install"), "{message}");
8595 assert!(message.contains("capsule-wide"), "{message}");
8596 }
8597 other => panic!("{runtime}: expected InvalidArtifact, got {other:?}"),
8598 }
8599 }
8600 }
8601
8602 #[test]
8604 fn capabilities_install_unknown_key_is_reported_by_path_and_grants_nothing() {
8605 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_install(
8606 " skill: [local/a]\n driver: [x]\n",
8607 ))
8608 .expect("an unknown key beside a real entry is a warning, not a parse error");
8609 assert!(
8610 manifest
8611 .unknown_keys
8612 .iter()
8613 .any(|key| key.block_path == "capabilities.install" && key.key == "driver"),
8614 "{:?}",
8615 manifest.unknown_keys
8616 );
8617 assert_eq!(
8618 manifest.capabilities.unwrap().install,
8619 Some(InstallCapabilities {
8620 skill: vec!["local/a".into()],
8621 tool: vec![],
8622 from: InstallFrom::Stores,
8623 })
8624 );
8625 }
8626
8627 fn manifest_with_task_io(runtime: &str, block: &str) -> String {
8631 format!(
8632 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/gate\n version: 1.2.3\n \
8633 runtime: {runtime}\n capabilities:\n task_io:\n{block}"
8634 )
8635 }
8636
8637 #[test]
8638 fn task_io_read_true_grants_the_hook() {
8639 let manifest =
8640 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: true\n"))
8641 .expect("task_io on a hook entry parses");
8642 assert_eq!(
8643 manifest.artifacts[0].capabilities.as_ref().unwrap().task_io,
8644 Some(TaskIoCapabilities { read: true })
8645 );
8646 }
8647
8648 #[test]
8651 fn task_io_read_false_and_an_absent_block_both_leave_the_hook_ungranted() {
8652 let explicit =
8653 RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " read: false\n"))
8654 .expect("an explicit denial parses");
8655 assert_eq!(
8656 explicit.artifacts[0].capabilities.as_ref().unwrap().task_io,
8657 Some(TaskIoCapabilities { read: false })
8658 );
8659
8660 let absent = RuntimeManifest::from_yaml_str(
8661 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/gate\n version: 1.2.3\n \
8662 runtime: hook\n",
8663 )
8664 .expect("no capabilities block parses");
8665 assert!(absent.artifacts[0].capabilities.is_none());
8666 }
8667
8668 #[test]
8671 fn task_io_without_read_is_rejected() {
8672 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io("hook", " {}\n"))
8673 .unwrap_err();
8674 let msg = err.to_string();
8675 assert!(
8676 msg.contains("capabilities.task_io.read"),
8677 "error was: {msg}"
8678 );
8679 assert!(msg.contains("explicitly"), "error was: {msg}");
8680 }
8681
8682 #[test]
8685 fn task_io_outside_a_hook_entry_is_rejected() {
8686 for runtime in ["tool", "driver", "skill"] {
8687 let err = RuntimeManifest::from_yaml_str(&manifest_with_task_io(
8688 runtime,
8689 " read: true\n",
8690 ))
8691 .unwrap_err();
8692 let msg = err.to_string();
8693 assert!(
8694 msg.contains("capabilities") && msg.contains("runtime: hook"),
8695 "{runtime} entry: error was: {msg}"
8696 );
8697 }
8698 }
8699
8700 fn manifest_with_filesystem(runtime: &str, block: &str) -> String {
8704 format!(
8705 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/thing\n version: 0.1.0\n \
8706 runtime: {runtime}\n capabilities:\n filesystem:\n{block}"
8707 )
8708 }
8709
8710 #[test]
8712 fn filesystem_records_on_a_hook_entry_parses() {
8713 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8714 "hook",
8715 " records: true\n",
8716 ))
8717 .expect("records on a hook entry parses");
8718 let filesystem = manifest.artifacts[0]
8719 .capabilities
8720 .as_ref()
8721 .unwrap()
8722 .filesystem
8723 .as_ref()
8724 .unwrap();
8725 assert!(filesystem.records);
8726 assert_eq!(filesystem.scope, None);
8727
8728 let scoped =
8729 RuntimeManifest::from_yaml_str(&manifest_with_filesystem("hook", " scope: .\n"))
8730 .unwrap();
8731 let filesystem = scoped.artifacts[0]
8732 .capabilities
8733 .as_ref()
8734 .unwrap()
8735 .filesystem
8736 .as_ref()
8737 .unwrap();
8738 assert!(!filesystem.records);
8739 assert!(scoped.unknown_keys.is_empty(), "{:?}", scoped.unknown_keys);
8740 }
8741
8742 #[test]
8745 fn filesystem_records_is_refused_beside_a_scope_off_a_hook_and_capsule_wide() {
8746 let beside = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8747 "hook",
8748 " records: true\n scope: .\n",
8749 ))
8750 .unwrap_err()
8751 .to_string();
8752 assert!(
8753 beside.contains("capabilities.filesystem.records"),
8754 "{beside}"
8755 );
8756 assert!(beside.contains("capabilities.filesystem.scope"), "{beside}");
8757 for runtime in ["tool", "driver"] {
8758 let off_hook = RuntimeManifest::from_yaml_str(&manifest_with_filesystem(
8759 runtime,
8760 " records: true\n",
8761 ))
8762 .unwrap_err()
8763 .to_string();
8764 assert!(
8765 off_hook.contains("capabilities.filesystem.records"),
8766 "{off_hook}"
8767 );
8768 assert!(off_hook.contains("runtime: hook"), "{off_hook}");
8769 }
8770 let capsule_wide = RuntimeManifest::from_yaml_str(
8771 "name: cap\nversion: 0.0.1\ncapabilities:\n filesystem:\n records: true\n",
8772 )
8773 .unwrap_err()
8774 .to_string();
8775 assert!(
8776 capsule_wide.contains("capabilities.filesystem.records"),
8777 "{capsule_wide}"
8778 );
8779 }
8780
8781 #[test]
8784 fn task_io_in_the_capsule_wide_block_is_rejected() {
8785 let err = RuntimeManifest::from_yaml_str(
8786 "name: cap\nversion: 0.0.1\ncapabilities:\n task_io:\n read: true\n",
8787 )
8788 .unwrap_err();
8789 let msg = err.to_string();
8790 assert!(msg.contains("capabilities.task_io"), "error was: {msg}");
8791 assert!(msg.contains("runtime: hook"), "error was: {msg}");
8792 }
8793
8794 fn manifest_with_conversation(runtime: &str, block: &str) -> String {
8798 format!(
8799 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/memory\n version: 1.2.3\n \
8800 runtime: {runtime}\n capabilities:\n conversation:\n{block}"
8801 )
8802 }
8803
8804 #[test]
8805 fn conversation_read_lowers_onto_the_hook_entry() {
8806 for (declared, expected) in [("true", true), ("false", false)] {
8807 let manifest = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8808 "hook",
8809 &format!(" read: {declared}\n"),
8810 ))
8811 .expect("conversation on a hook entry parses");
8812 assert_eq!(
8813 manifest.artifacts[0]
8814 .capabilities
8815 .as_ref()
8816 .unwrap()
8817 .conversation,
8818 Some(ConversationCapabilities { read: expected })
8819 );
8820 }
8821 }
8822
8823 #[test]
8826 fn conversation_without_read_is_rejected() {
8827 let err =
8828 RuntimeManifest::from_yaml_str(&manifest_with_conversation("hook", " {}\n"))
8829 .unwrap_err();
8830 let msg = err.to_string();
8831 assert!(msg.contains("capabilities.conversation.read"), "was: {msg}");
8832 assert!(msg.contains("explicitly"), "was: {msg}");
8833 }
8834
8835 #[test]
8838 fn conversation_outside_a_hook_entry_is_rejected() {
8839 for runtime in ["tool", "driver", "skill"] {
8840 let err = RuntimeManifest::from_yaml_str(&manifest_with_conversation(
8841 runtime,
8842 " read: true\n",
8843 ))
8844 .unwrap_err();
8845 let msg = err.to_string();
8846 assert!(
8847 msg.contains("capabilities") && msg.contains("runtime: hook"),
8848 "{runtime} entry: error was: {msg}"
8849 );
8850 }
8851 }
8852
8853 #[test]
8856 fn conversation_in_the_capsule_wide_block_parses_and_is_inert() {
8857 let manifest = RuntimeManifest::from_yaml_str(
8858 "name: cap\nversion: 0.0.1\ncapabilities:\n conversation:\n read: true\n",
8859 )
8860 .expect("a capsule-wide block is accepted");
8861 assert_eq!(
8862 manifest.capabilities.unwrap().conversation,
8863 Some(ConversationCapabilities { read: true })
8864 );
8865 }
8866
8867 #[test]
8872 fn a_context_block_records_by_default() {
8873 let context = context_of("context:\n max_tokens: 1000\n").unwrap();
8874 assert!(context.record);
8875 assert_eq!(context.record_store, None);
8876 }
8877
8878 #[test]
8879 fn record_off_and_on_are_the_two_accepted_values() {
8880 assert!(!context_of("context:\n record: off\n").unwrap().record);
8881 assert!(context_of("context:\n record: on\n").unwrap().record);
8882
8883 let err = RuntimeManifest::from_yaml_str(
8884 "name: cap\nversion: 0.0.1\ncontext:\n record: sometimes\n",
8885 )
8886 .unwrap_err();
8887 let msg = err.to_string();
8888 assert!(msg.contains("context.record"), "was: {msg}");
8889 assert!(msg.contains("sometimes"), "was: {msg}");
8890 }
8891
8892 #[test]
8895 fn record_store_is_kept_verbatim_and_trimmed() {
8896 assert_eq!(
8897 context_of("context:\n record_store: ' shey '\n")
8898 .unwrap()
8899 .record_store,
8900 Some("shey".to_string())
8901 );
8902 assert_eq!(
8903 context_of("context:\n record: off\n record_store: shey\n")
8904 .unwrap()
8905 .record_store,
8906 Some("shey".to_string()),
8907 "a store declared beside record: off is kept and simply never used"
8908 );
8909 }
8910
8911 #[test]
8912 fn interpreter_runtime_rejects_missing_list_dir() {
8913 let msg = interpreter_runtime_reject(
8914 " - binary: python3\n dirs:\n - path: /usr/lib/python3.11\n",
8915 );
8916 assert!(
8917 msg.contains("capabilities.shell.interpreter_runtime[0].dirs[0].list_dir"),
8918 "{msg}"
8919 );
8920 assert!(msg.contains("explicitly"), "{msg}");
8921 }
8922
8923 #[test]
8924 fn interpreter_runtime_rejects_empty_dirs() {
8925 let msg = interpreter_runtime_reject(" - binary: python3\n dirs: []\n");
8926 assert!(
8927 msg.contains("capabilities.shell.interpreter_runtime[0].dirs"),
8928 "{msg}"
8929 );
8930 assert!(msg.contains("at least one"), "{msg}");
8931 }
8932
8933 #[test]
8934 fn staged_runtime_parses_accepted_shape() {
8935 let manifest = RuntimeManifest::from_yaml_str(
8936 r#"
8937name: cap
8938version: 0.0.1
8939artifacts: []
8940capabilities:
8941 containment: sealed
8942 shell:
8943 allow:
8944 - python3
8945 - bash
8946 staged_runtime:
8947 - binary: python3
8948 source_path: /opt/testbed/conda/envs/django__django
8949 pin: conda-4.10.3/python-3.9.19/testbed-2024-05-01
8950"#,
8951 )
8952 .unwrap();
8953
8954 let shell = manifest.capabilities.unwrap().shell.unwrap();
8955 assert_eq!(
8956 shell.staged_runtime,
8957 vec![StagedRuntimeGrant {
8958 binary: "python3".to_string(),
8959 source_path: "/opt/testbed/conda/envs/django__django".to_string(),
8960 pin: "conda-4.10.3/python-3.9.19/testbed-2024-05-01".to_string(),
8961 }]
8962 );
8963 assert!(shell.interpreter_runtime.is_empty());
8966 }
8967
8968 #[test]
8969 fn staged_runtime_defaults_to_empty_when_absent() {
8970 let manifest = RuntimeManifest::from_yaml_str(
8971 r#"
8972name: cap
8973version: 0.0.1
8974artifacts: []
8975capabilities:
8976 shell:
8977 allow:
8978 - python3
8979"#,
8980 )
8981 .unwrap();
8982
8983 let shell = manifest.capabilities.unwrap().shell.unwrap();
8984 assert!(shell.staged_runtime.is_empty());
8985 }
8986
8987 fn staged_runtime_reject(grant_yaml: &str, extra: &str) -> String {
8991 let yaml = format!(
8992 r#"
8993name: cap
8994version: 0.0.1
8995artifacts: []
8996capabilities:
8997 shell:
8998 allow:
8999 - python3
9000{extra} staged_runtime:
9001{grant_yaml}
9002"#,
9003 );
9004 RuntimeManifest::from_yaml_str(&yaml)
9005 .expect_err("grant should be rejected")
9006 .to_string()
9007 }
9008
9009 #[test]
9010 fn staged_runtime_rejects_binary_not_in_allow() {
9011 let msg = staged_runtime_reject(
9012 " - binary: ruby\n source_path: /opt/ruby\n pin: ruby-3.2.2\n",
9013 "",
9014 );
9015 assert!(
9016 msg.contains("capabilities.shell.staged_runtime[0].binary"),
9017 "{msg}"
9018 );
9019 assert!(msg.contains("ruby"), "{msg}");
9020 assert!(msg.contains("capabilities.shell.allow"), "{msg}");
9021 }
9022
9023 #[test]
9024 fn staged_runtime_rejects_binary_also_in_interpreter_runtime() {
9025 let msg = staged_runtime_reject(
9026 " - binary: python3\n source_path: /opt/py\n pin: cpython-3.9.19\n",
9027 " interpreter_runtime:\n - binary: python3\n dirs:\n - path: /usr/lib/python3.9\n list_dir: true\n",
9028 );
9029 assert!(
9030 msg.contains("capabilities.shell.staged_runtime[0].binary"),
9031 "{msg}"
9032 );
9033 assert!(msg.contains("python3"), "{msg}");
9034 assert!(msg.contains("interpreter_runtime"), "{msg}");
9035 assert!(msg.contains("mutually exclusive"), "{msg}");
9036 }
9037
9038 #[test]
9039 fn staged_runtime_rejects_relative_source_path() {
9040 let msg = staged_runtime_reject(
9041 " - binary: python3\n source_path: opt/testbed/conda\n pin: cpython-3.9.19\n",
9042 "",
9043 );
9044 assert!(
9045 msg.contains("capabilities.shell.staged_runtime[0].source_path"),
9046 "{msg}"
9047 );
9048 assert!(msg.contains("absolute"), "{msg}");
9049 }
9050
9051 #[test]
9052 fn staged_runtime_rejects_missing_or_empty_pin() {
9053 for grant in [
9056 " - binary: python3\n source_path: /opt/testbed/conda\n",
9057 " - binary: python3\n source_path: /opt/testbed/conda\n pin: \" \"\n",
9058 ] {
9059 let msg = staged_runtime_reject(grant, "");
9060 assert!(
9061 msg.contains("capabilities.shell.staged_runtime[0].pin"),
9062 "{msg}"
9063 );
9064 assert!(msg.contains("never inferred"), "{msg}");
9065 }
9066 }
9067
9068 #[test]
9069 fn parses_inference_block() {
9070 let manifest = RuntimeManifest::from_yaml_str(
9071 r#"
9072name: cap
9073version: 0.0.1
9074artifacts:
9075 - name: murmur/driver-anthropic
9076 version: 0.1.0
9077 runtime: driver
9078 gateway:
9079 endpoint: http://127.0.0.1:8080
9080 api_key: literal-key
9081inference:
9082 transport: http
9083 model: test-model
9084 driver:
9085 artifact: murmur/driver-anthropic
9086 config:
9087 temperature: 0.2
9088 headers:
9089 x-test: true
9090"#,
9091 )
9092 .unwrap();
9093
9094 assert_eq!(
9095 manifest.artifacts[0].gateway,
9096 Some(ArtifactGateway {
9097 endpoint: "http://127.0.0.1:8080".to_string(),
9098 api_key: Some(ApiKeyReference::Literal("literal-key".to_string())),
9099 keyless: false,
9100 })
9101 );
9102 let inference = manifest.inference.expect("inference should exist");
9103 assert_eq!(inference.transport, "http");
9104 assert_eq!(inference.model, "test-model");
9105 let driver = inference.driver.as_ref().expect("driver should be present");
9106 assert_eq!(driver.artifact, "murmur/driver-anthropic");
9107 assert_eq!(
9108 driver.config,
9109 Some("{\"headers\":{\"x-test\":true},\"temperature\":0.2}".to_string())
9110 );
9111 assert!(inference.system_prompt.is_none());
9112 assert!(inference.system_prompt_file.is_none());
9113 }
9114
9115 fn tool_gateway_manifest(block: &str) -> String {
9118 format!(
9119 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/web-search\n version: 0.1.0\n runtime: tool\n gateway:{block}\n"
9120 )
9121 }
9122
9123 fn gateway_endpoint_error(endpoint: &str) -> String {
9124 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(&format!(
9125 "\n endpoint: \"{endpoint}\""
9126 )))
9127 .unwrap_err()
9128 .to_string()
9129 }
9130
9131 #[test]
9132 fn gateway_endpoint_https_is_accepted() {
9133 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9134 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
9135 ))
9136 .unwrap();
9137 assert_eq!(
9138 manifest.artifacts[0].gateway,
9139 Some(ArtifactGateway {
9140 endpoint: "https://api.tavily.com".to_string(),
9141 api_key: Some(ApiKeyReference::Environment("TAVILY_API_KEY".to_string())),
9142 keyless: false,
9143 })
9144 );
9145 }
9146
9147 #[test]
9148 fn gateway_endpoint_http_localhost_is_accepted() {
9149 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9150 "\n endpoint: http://localhost:11434\n keyless: true",
9151 ))
9152 .unwrap();
9153 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
9154 assert_eq!(gateway.endpoint, "http://localhost:11434");
9155 assert_eq!(gateway.api_key, None);
9156 assert!(gateway.keyless);
9157 }
9158
9159 #[test]
9160 fn gateway_endpoint_http_loopback_ip_is_accepted() {
9161 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9162 "\n endpoint: http://127.0.0.1:8080\n api_key: ${LOCAL_KEY}",
9163 ))
9164 .unwrap();
9165 let gateway = manifest.artifacts[0].gateway.clone().unwrap();
9166 assert_eq!(gateway.endpoint, "http://127.0.0.1:8080");
9167 assert_eq!(
9168 gateway.api_key,
9169 Some(ApiKeyReference::Environment("LOCAL_KEY".to_string()))
9170 );
9171 }
9172
9173 #[test]
9174 fn gateway_endpoint_without_scheme_is_refused() {
9175 let msg = gateway_endpoint_error("api.tavily.com");
9176 assert!(msg.contains("gateway.endpoint"), "{msg}");
9177 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9178 assert!(msg.contains("does not parse as a URL"), "{msg}");
9179 }
9180
9181 #[test]
9182 fn gateway_endpoint_ftp_scheme_is_refused() {
9183 let msg = gateway_endpoint_error("ftp://example.com");
9184 assert!(msg.contains("gateway.endpoint"), "{msg}");
9185 assert!(msg.contains("scheme 'ftp' is unsupported"), "{msg}");
9186 }
9187
9188 #[test]
9189 fn gateway_endpoint_plain_http_to_a_remote_host_is_refused() {
9190 let msg = gateway_endpoint_error("http://api.attacker.example.com");
9191 assert!(msg.contains("gateway.endpoint"), "{msg}");
9192 assert!(msg.contains("api.attacker.example.com"), "{msg}");
9193 assert!(msg.contains("non-loopback"), "{msg}");
9194 }
9195
9196 #[test]
9197 fn gateway_endpoint_with_a_query_is_refused() {
9198 let msg = gateway_endpoint_error("https://api.tavily.com/v1?key=x");
9199 assert!(msg.contains("gateway.endpoint"), "{msg}");
9200 assert!(msg.contains("carries a query"), "{msg}");
9201 }
9202
9203 #[test]
9204 fn gateway_endpoint_with_a_fragment_is_refused() {
9205 let msg = gateway_endpoint_error("https://api.tavily.com/v1#frag");
9206 assert!(msg.contains("gateway.endpoint"), "{msg}");
9207 assert!(msg.contains("carries a fragment"), "{msg}");
9208 }
9209
9210 #[test]
9211 fn gateway_endpoint_with_userinfo_is_refused() {
9212 let msg = gateway_endpoint_error("https://user:sk-in-url@api.example.com");
9213 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9214 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
9215 assert!(msg.contains("host 'api.example.com'"), "{msg}");
9216 assert!(msg.contains("gateway.api_key"), "{msg}");
9217 assert!(msg.contains("'https://***@api.example.com'"), "{msg}");
9218 assert!(
9219 !msg.contains("sk-in-url"),
9220 "a password in the URL is never echoed: {msg}"
9221 );
9222 }
9223
9224 #[test]
9225 fn gateway_endpoint_refusal_masks_userinfo_the_parser_rejects() {
9226 let msg = gateway_endpoint_error("https://user:sk-in-url@");
9227 assert!(msg.contains("does not parse as a URL"), "{msg}");
9228 assert!(
9229 !msg.contains("sk-in-url"),
9230 "a password in the URL is never echoed: {msg}"
9231 );
9232 }
9233
9234 #[test]
9235 fn gateway_endpoint_with_a_masquerading_host_is_refused() {
9236 let msg = gateway_endpoint_error("https://api.example.com@127.0.0.1:8443");
9237 assert!(msg.contains("carries userinfo before '@'"), "{msg}");
9238 assert!(
9239 msg.contains("addresses host '127.0.0.1'"),
9240 "the message names the host the URL really addresses: {msg}"
9241 );
9242 }
9243
9244 #[test]
9245 fn gateway_endpoint_with_interpolation_is_refused() {
9246 let msg = gateway_endpoint_error("https://${PROVIDER_HOST}/v1");
9247 assert!(msg.contains("artifact 'local/web-search'"), "{msg}");
9248 assert!(msg.contains("contains '${'"), "{msg}");
9249 assert!(msg.contains("not interpolated"), "{msg}");
9250 }
9251
9252 #[test]
9253 fn gateway_endpoint_http_unspecified_address_is_refused() {
9254 let msg = gateway_endpoint_error("http://0.0.0.0:8080");
9255 assert!(msg.contains("non-loopback host '0.0.0.0'"), "{msg}");
9256 }
9257
9258 #[test]
9259 fn gateway_endpoint_http_ipv6_loopback_is_accepted() {
9260 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9261 "\n endpoint: http://[::1]:8080\n api_key: ${LOCAL_KEY}",
9262 ))
9263 .unwrap();
9264 assert_eq!(
9265 manifest.artifacts[0].gateway.as_ref().unwrap().endpoint,
9266 "http://[::1]:8080"
9267 );
9268 }
9269
9270 fn gateway_credential_error(block: &str) -> RuntimeManifestError {
9272 RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block)).unwrap_err()
9273 }
9274
9275 fn assert_without_credential(error: RuntimeManifestError, endpoint: &str) {
9276 match &error {
9277 RuntimeManifestError::GatewayWithoutCredential {
9278 index,
9279 name,
9280 endpoint: refused,
9281 } => {
9282 assert_eq!(*index, 0);
9283 assert_eq!(name, "local/web-search");
9284 assert_eq!(refused, endpoint);
9285 }
9286 other => panic!("expected GatewayWithoutCredential, got {other:?}"),
9287 }
9288 let msg = error.to_string();
9289 assert!(
9290 msg.contains(&format!(
9291 "artifact 'local/web-search' declares gateway.endpoint '{endpoint}' but binds no \
9292 credential"
9293 )),
9294 "{msg}"
9295 );
9296 }
9297
9298 #[test]
9299 fn gateway_credential_absent_is_refused() {
9300 assert_without_credential(
9301 gateway_credential_error("\n endpoint: https://api.tavily.com"),
9302 "https://api.tavily.com",
9303 );
9304 }
9305
9306 #[test]
9307 fn gateway_credential_blank_is_refused() {
9308 for key in ["\"\"", "\" \"", "''"] {
9309 assert_without_credential(
9310 gateway_credential_error(&format!(
9311 "\n endpoint: https://api.tavily.com\n api_key: {key}"
9312 )),
9313 "https://api.tavily.com",
9314 );
9315 }
9316 }
9317
9318 #[test]
9319 fn gateway_credential_null_is_refused() {
9320 for key in ["~", "null", ""] {
9321 assert_without_credential(
9322 gateway_credential_error(&format!(
9323 "\n endpoint: https://api.tavily.com\n api_key: {key}"
9324 )),
9325 "https://api.tavily.com",
9326 );
9327 }
9328 }
9329
9330 #[test]
9331 fn gateway_credential_keyless_false_is_refused() {
9332 assert_without_credential(
9333 gateway_credential_error(
9334 "\n endpoint: https://api.tavily.com\n keyless: false",
9335 ),
9336 "https://api.tavily.com",
9337 );
9338 }
9339
9340 #[test]
9341 fn gateway_credential_keyless_is_accepted() {
9342 for (parse, mode) in [
9343 (
9344 RuntimeManifest::from_yaml_str as fn(&str) -> _,
9345 "with secrets",
9346 ),
9347 (
9348 RuntimeManifest::from_yaml_str_without_secrets,
9349 "without secrets",
9350 ),
9351 ] {
9352 let manifest = parse(&tool_gateway_manifest(
9353 "\n endpoint: https://api.example.com\n keyless: true",
9354 ))
9355 .unwrap();
9356 assert_eq!(
9357 manifest.artifacts[0].gateway,
9358 Some(ArtifactGateway {
9359 endpoint: "https://api.example.com".to_string(),
9360 api_key: None,
9361 keyless: true,
9362 }),
9363 "{mode}"
9364 );
9365 }
9366 }
9367
9368 #[test]
9369 fn gateway_credential_keyless_not_a_boolean_is_refused() {
9370 for value in ["\"true\"", "yes please", "1"] {
9371 let error = gateway_credential_error(&format!(
9372 "\n endpoint: https://api.tavily.com\n keyless: {value}"
9373 ));
9374 assert!(
9375 matches!(
9376 error,
9377 RuntimeManifestError::InvalidArtifact { index: 0, .. }
9378 ),
9379 "{value}: {error:?}"
9380 );
9381 assert!(
9382 error
9383 .to_string()
9384 .contains("gateway.keyless with a value that is not a boolean"),
9385 "{error}"
9386 );
9387 }
9388 }
9389
9390 #[test]
9391 fn gateway_credential_and_keyless_together_are_refused() {
9392 for key in ["${TAVILY_API_KEY}", "\"\"", "~"] {
9393 let error = gateway_credential_error(&format!(
9394 "\n endpoint: https://api.tavily.com\n api_key: {key}\n keyless: true"
9395 ));
9396 assert!(
9397 matches!(
9398 error,
9399 RuntimeManifestError::InvalidArtifact { index: 0, .. }
9400 ),
9401 "{error:?}"
9402 );
9403 assert!(
9404 error.to_string().contains(
9405 "artifact 'local/web-search' declares both gateway.api_key and gateway.keyless: \
9406 true; a gateway either presents a key or is keyless"
9407 ),
9408 "{error}"
9409 );
9410 }
9411 }
9412
9413 #[test]
9414 fn gateway_credential_absent_is_refused_without_secrets() {
9415 for block in [
9416 "\n endpoint: https://api.tavily.com",
9417 "\n endpoint: https://api.tavily.com\n api_key: \"\"",
9418 "\n endpoint: https://api.tavily.com\n keyless: false",
9419 ] {
9420 assert_without_credential(
9421 RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(block))
9422 .unwrap_err(),
9423 "https://api.tavily.com",
9424 );
9425 }
9426 let keyed = RuntimeManifest::from_yaml_str_without_secrets(&tool_gateway_manifest(
9427 "\n endpoint: https://api.tavily.com\n api_key: ${TAVILY_API_KEY}",
9428 ))
9429 .unwrap();
9430 let gateway = keyed.artifacts[0].gateway.as_ref().unwrap();
9431 assert_eq!(gateway.api_key, None, "the key reference is left unread");
9432 assert!(!gateway.keyless, "a keyed gateway is never keyless");
9433 }
9434
9435 #[test]
9438 fn gateway_credential_loopback_endpoint_is_not_assumed_keyless() {
9439 for endpoint in [
9440 "http://127.0.0.1:11434",
9441 "http://localhost:11434",
9442 "http://[::1]:11434",
9443 "https://10.0.0.5",
9444 "https://192.168.1.10:8443",
9445 ] {
9446 assert_without_credential(
9447 gateway_credential_error(&format!("\n endpoint: {endpoint}")),
9448 endpoint,
9449 );
9450 }
9451 }
9452
9453 #[test]
9454 fn gateway_endpoint_missing_from_the_block_is_refused() {
9455 for block in [
9456 "\n api_key: ${TAVILY_API_KEY}",
9457 "",
9458 " {}",
9459 "\n endpoint: \" \"",
9460 ] {
9461 let msg = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(block))
9462 .unwrap_err()
9463 .to_string();
9464 assert!(
9465 msg.contains("without 'gateway.endpoint'"),
9466 "{block:?}: {msg}"
9467 );
9468 assert!(
9469 msg.contains("artifact 'local/web-search'"),
9470 "{block:?}: {msg}"
9471 );
9472 }
9473 }
9474
9475 #[test]
9476 fn gateway_block_unknown_key_is_reported() {
9477 let manifest = RuntimeManifest::from_yaml_str(&tool_gateway_manifest(
9478 "\n endpoint: https://api.tavily.com\n keyless: true\n api_kee: x",
9479 ))
9480 .unwrap();
9481 let unknown = manifest.unknown_keys;
9482 assert_eq!(unknown.len(), 1, "{unknown:?}");
9483 assert_eq!(unknown[0].key, "api_kee");
9484 assert_eq!(unknown[0].nearest_known.as_deref(), Some("api_key"));
9485 }
9486
9487 #[test]
9488 fn gateway_on_skill_entry_is_refused() {
9489 let msg = RuntimeManifest::from_yaml_str(
9490 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/notes\n version: 0.1.0\n runtime: skill\n gateway:\n endpoint: https://api.tavily.com\n api_key: test-key\n",
9491 )
9492 .unwrap_err()
9493 .to_string();
9494 assert!(
9495 msg.contains("artifact 'local/notes' declares 'gateway:'"),
9496 "{msg}"
9497 );
9498 assert!(msg.contains("runtime: skill"), "{msg}");
9499 }
9500
9501 #[test]
9502 fn gateway_on_hook_entry_is_accepted() {
9503 let manifest = RuntimeManifest::from_yaml_str(
9504 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/grafana\n version: 0.1.0\n runtime: hook\n gateway:\n endpoint: https://grafana.example.com/otlp\n api_key: glc_literal\n",
9505 )
9506 .unwrap();
9507 assert!(manifest.artifacts[0].gateway.is_some());
9508 }
9509
9510 fn http_driver_manifest(artifacts: &str) -> String {
9513 format!(
9514 "name: cap\nversion: 0.0.1\nartifacts:{artifacts}\ninference:\n transport: http\n model: test-model\n driver:\n artifact: murmur/driver-anthropic\n"
9515 )
9516 }
9517
9518 #[test]
9519 fn http_driver_entry_without_gateway_endpoint_is_refused() {
9520 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9521 "\n - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver",
9522 ))
9523 .unwrap_err()
9524 .to_string();
9525 assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9526 assert!(msg.contains("gateway.endpoint"), "{msg}");
9527 }
9528
9529 #[test]
9530 fn http_driver_absent_from_artifacts_is_refused() {
9531 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(" []"))
9532 .unwrap_err()
9533 .to_string();
9534 assert!(msg.contains("inference.driver.artifact"), "{msg}");
9535 assert!(msg.contains("not declared in artifacts:"), "{msg}");
9536 }
9537
9538 #[test]
9539 fn gateway_on_unconfigured_driver_entry_is_refused() {
9540 let msg = RuntimeManifest::from_yaml_str(&http_driver_manifest(
9541 "\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",
9542 ))
9543 .unwrap_err()
9544 .to_string();
9545 assert!(msg.contains("'murmur/driver-openai'"), "{msg}");
9546 assert!(
9547 msg.contains("accepted only on the entry inference.driver.artifact names"),
9548 "{msg}"
9549 );
9550
9551 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";
9552 let msg = RuntimeManifest::from_yaml_str(no_inference)
9553 .unwrap_err()
9554 .to_string();
9555 assert!(msg.contains("not the configured inference driver"), "{msg}");
9556 }
9557
9558 #[test]
9559 fn gateway_on_driver_entry_under_process_transport_is_refused() {
9560 let msg = RuntimeManifest::from_yaml_str(
9561 "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",
9562 )
9563 .unwrap_err()
9564 .to_string();
9565 assert!(msg.contains("'murmur/driver-anthropic'"), "{msg}");
9566 assert!(msg.contains("transport: process"), "{msg}");
9567 }
9568
9569 #[test]
9570 fn inference_api_key_is_refused_naming_gateway_api_key() {
9571 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";
9572 for value in ["${ANTHROPIC_API_KEY}", "sk-literal", "\"\""] {
9573 let manifest = http_driver_manifest(entry)
9574 .replace(" model:", &format!(" api_key: {value}\n model:"));
9575 let err = RuntimeManifest::from_yaml_str(&manifest).unwrap_err();
9576 let msg = err.to_string();
9577 assert!(msg.contains("'inference.api_key'"), "{msg}");
9578 assert!(msg.contains("was removed"), "{msg}");
9579 assert!(
9580 msg.contains(
9581 "set gateway.api_key on the artifacts: entry 'murmur/driver-anthropic'"
9582 ),
9583 "{msg}"
9584 );
9585 assert!(!msg.contains("sk-literal"), "{msg}");
9586 }
9587
9588 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n api_key: ${KEY}\n";
9589 let msg = RuntimeManifest::from_yaml_str(process)
9590 .unwrap_err()
9591 .to_string();
9592 assert!(msg.contains("'inference.api_key'"), "{msg}");
9593 assert!(
9594 msg.contains("on the artifacts: entry inference.driver.artifact names"),
9595 "{msg}"
9596 );
9597 assert!(
9598 RuntimeManifest::from_yaml_str_without_secrets(process).is_err(),
9599 "the refusal does not depend on resolving secrets"
9600 );
9601 }
9602
9603 #[test]
9604 fn inference_endpoint_is_refused_naming_gateway_endpoint() {
9605 let manifest = http_driver_manifest(
9606 "\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",
9607 )
9608 .replace(" model:", " endpoint: https://api.anthropic.com\n model:");
9609 let msg = RuntimeManifest::from_yaml_str(&manifest)
9610 .unwrap_err()
9611 .to_string();
9612 assert!(msg.contains("'inference.endpoint'"), "{msg}");
9613 assert!(
9614 msg.contains("set gateway.endpoint on the artifacts: entry 'murmur/driver-anthropic'"),
9615 "{msg}"
9616 );
9617
9618 let process = "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n endpoint: http://localhost:8080\n";
9619 let msg = RuntimeManifest::from_yaml_str(process)
9620 .unwrap_err()
9621 .to_string();
9622 assert!(msg.contains("'inference.endpoint'"), "{msg}");
9623 assert!(msg.contains("was removed"), "{msg}");
9624 }
9625
9626 #[test]
9627 fn without_secrets_leaves_gateway_api_key_unread() {
9628 let manifest = tool_gateway_manifest(
9629 "\n endpoint: https://api.tavily.com\n api_key: tvly-literal",
9630 );
9631 let resolved = RuntimeManifest::from_yaml_str(&manifest).unwrap();
9632 assert_eq!(
9633 resolved.artifacts[0].gateway.as_ref().unwrap().api_key,
9634 Some(ApiKeyReference::Literal("tvly-literal".to_string()))
9635 );
9636 let skipped = RuntimeManifest::from_yaml_str_without_secrets(&manifest).unwrap();
9637 let gateway = skipped.artifacts[0].gateway.as_ref().unwrap();
9638 assert_eq!(gateway.endpoint, "https://api.tavily.com");
9639 assert_eq!(gateway.api_key, None);
9640 }
9641
9642 #[test]
9643 fn referenced_env_variables_reads_gateway_api_key() {
9644 let manifest = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/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: local/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";
9645 let referenced = referenced_env_variables(manifest);
9646 assert_eq!(
9647 referenced,
9648 vec![
9649 ReferencedEnvVariable {
9650 field: "artifacts.murmur/driver-anthropic.gateway.api_key".to_string(),
9651 variable: "ANTHROPIC_API_KEY".to_string(),
9652 },
9653 ReferencedEnvVariable {
9654 field: "artifacts.local/web-search.gateway.api_key".to_string(),
9655 variable: "TAVILY_API_KEY".to_string(),
9656 },
9657 ]
9658 );
9659 assert!(referenced
9660 .iter()
9661 .all(ReferencedEnvVariable::is_gateway_credential));
9662 }
9663
9664 #[test]
9665 fn parses_inline_system_prompt() {
9666 let manifest = RuntimeManifest::from_yaml_str(
9667 r#"
9668name: cap
9669version: 0.0.1
9670artifacts:
9671 - name: murmur/driver-anthropic
9672 version: 0.1.0
9673 runtime: driver
9674 gateway:
9675 endpoint: http://127.0.0.1:8080
9676 keyless: true
9677inference:
9678 model: test-model
9679 system_prompt: "Always begin with CONFIRMED:"
9680 driver:
9681 artifact: murmur/driver-anthropic
9682"#,
9683 )
9684 .unwrap();
9685
9686 let inference = manifest.inference.unwrap();
9687 assert_eq!(
9688 inference.system_prompt,
9689 Some("Always begin with CONFIRMED:".to_string())
9690 );
9691 assert!(inference.system_prompt_file.is_none());
9692 }
9693
9694 #[test]
9695 fn parses_compaction_system_prompt() {
9696 let manifest = RuntimeManifest::from_yaml_str(
9697 r#"
9698name: cap
9699version: 0.0.1
9700artifacts:
9701 - name: murmur/driver-anthropic
9702 version: 0.1.0
9703 runtime: driver
9704 gateway:
9705 endpoint: http://127.0.0.1:8080
9706 keyless: true
9707inference:
9708 model: test-model
9709 compaction:
9710 model: compaction-model
9711 system_prompt: "task = X, currently editing Y, already tried Z."
9712 driver:
9713 artifact: murmur/driver-anthropic
9714"#,
9715 )
9716 .unwrap();
9717
9718 let compaction = manifest.inference.unwrap().compaction.unwrap();
9719 assert_eq!(
9720 compaction.system_prompt,
9721 Some("task = X, currently editing Y, already tried Z.".to_string())
9722 );
9723 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9724 }
9725
9726 #[test]
9729 fn compaction_system_prompt_absent_stays_none() {
9730 let manifest = RuntimeManifest::from_yaml_str(
9731 r#"
9732name: cap
9733version: 0.0.1
9734artifacts:
9735 - name: murmur/driver-anthropic
9736 version: 0.1.0
9737 runtime: driver
9738 gateway:
9739 endpoint: http://127.0.0.1:8080
9740 keyless: true
9741inference:
9742 model: test-model
9743 compaction:
9744 model: compaction-model
9745 driver:
9746 artifact: murmur/driver-anthropic
9747"#,
9748 )
9749 .unwrap();
9750
9751 let compaction = manifest.inference.unwrap().compaction.unwrap();
9752 assert!(compaction.system_prompt.is_none());
9753 assert!(compaction.system_prompt_file.is_none());
9754 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9755 }
9756
9757 #[test]
9761 fn parses_compaction_system_prompt_file() {
9762 let manifest = RuntimeManifest::from_yaml_str(
9763 r#"
9764name: cap
9765version: 0.0.1
9766artifacts:
9767 - name: murmur/driver-anthropic
9768 version: 0.1.0
9769 runtime: driver
9770 gateway:
9771 endpoint: http://127.0.0.1:8080
9772 keyless: true
9773inference:
9774 model: test-model
9775 compaction:
9776 model: compaction-model
9777 system_prompt_file: "compaction-instructions.md"
9778 driver:
9779 artifact: murmur/driver-anthropic
9780"#,
9781 )
9782 .unwrap();
9783
9784 let compaction = manifest.inference.unwrap().compaction.unwrap();
9785 assert_eq!(
9786 compaction.system_prompt_file,
9787 Some("compaction-instructions.md".to_string())
9788 );
9789 assert!(compaction.system_prompt.is_none());
9790 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9791 }
9792
9793 #[test]
9796 fn compaction_system_prompt_file_independent_of_model() {
9797 let manifest = RuntimeManifest::from_yaml_str(
9798 r#"
9799name: cap
9800version: 0.0.1
9801artifacts:
9802 - name: murmur/driver-anthropic
9803 version: 0.1.0
9804 runtime: driver
9805 gateway:
9806 endpoint: http://127.0.0.1:8080
9807 keyless: true
9808inference:
9809 model: test-model
9810 compaction:
9811 threshold: 0.5
9812 system_prompt_file: "compaction-instructions.md"
9813 driver:
9814 artifact: murmur/driver-anthropic
9815"#,
9816 )
9817 .unwrap();
9818
9819 let compaction = manifest.inference.unwrap().compaction.unwrap();
9820 assert_eq!(
9821 compaction.system_prompt_file,
9822 Some("compaction-instructions.md".to_string())
9823 );
9824 assert!(compaction.model.is_none());
9825 assert_eq!(compaction.threshold, Some(0.5));
9826 }
9827
9828 #[test]
9832 fn parses_compaction_dump_summaries() {
9833 let parse = |line: &str| {
9834 RuntimeManifest::from_yaml_str(&format!(
9835 r#"
9836name: cap
9837version: 0.0.1
9838artifacts:
9839 - name: murmur/driver-anthropic
9840 version: 0.1.0
9841 runtime: driver
9842 gateway:
9843 endpoint: http://127.0.0.1:8080
9844 keyless: true
9845inference:
9846 model: test-model
9847 compaction:
9848 model: compaction-model
9849{line} driver:
9850 artifact: murmur/driver-anthropic
9851"#
9852 ))
9853 .unwrap()
9854 .inference
9855 .unwrap()
9856 .compaction
9857 .unwrap()
9858 };
9859
9860 assert_eq!(
9861 parse(" dump_summaries: true\n").dump_summaries,
9862 Some(true)
9863 );
9864 assert_eq!(
9865 parse(" dump_summaries: false\n").dump_summaries,
9866 Some(false)
9867 );
9868 assert_eq!(parse("").dump_summaries, None);
9869 }
9870
9871 #[test]
9875 fn compaction_dump_summaries_does_not_interact_with_other_fields() {
9876 let compaction = RuntimeManifest::from_yaml_str(
9877 r#"
9878name: cap
9879version: 0.0.1
9880artifacts:
9881 - name: murmur/driver-anthropic
9882 version: 0.1.0
9883 runtime: driver
9884 gateway:
9885 endpoint: http://127.0.0.1:8080
9886 keyless: true
9887inference:
9888 model: test-model
9889 compaction:
9890 threshold: 0.5
9891 model: compaction-model
9892 system_prompt_file: "compaction-instructions.md"
9893 dump_summaries: true
9894 driver:
9895 artifact: murmur/driver-anthropic
9896"#,
9897 )
9898 .unwrap()
9899 .inference
9900 .unwrap()
9901 .compaction
9902 .unwrap();
9903
9904 assert_eq!(compaction.dump_summaries, Some(true));
9905 assert_eq!(compaction.threshold, Some(0.5));
9906 assert_eq!(compaction.model, Some("compaction-model".to_string()));
9907 assert_eq!(
9908 compaction.system_prompt_file,
9909 Some("compaction-instructions.md".to_string())
9910 );
9911 assert!(compaction.system_prompt.is_none());
9912
9913 let err = RuntimeManifest::from_yaml_str(
9915 r#"
9916name: cap
9917version: 0.0.1
9918artifacts:
9919 - name: murmur/driver-anthropic
9920 version: 0.1.0
9921 runtime: driver
9922 gateway:
9923 endpoint: http://127.0.0.1:8080
9924 keyless: true
9925inference:
9926 model: test-model
9927 compaction:
9928 dump_summaries: true
9929 system_prompt: "inline"
9930 system_prompt_file: "compaction-instructions.md"
9931 driver:
9932 artifact: murmur/driver-anthropic
9933"#,
9934 )
9935 .expect_err("dump_summaries must not suppress the prompt-source exclusivity check");
9936 assert!(matches!(
9937 err,
9938 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9939 if field == "inference.compaction.system_prompt"
9940 ));
9941
9942 let err = RuntimeManifest::from_yaml_str(
9944 r#"
9945name: cap
9946version: 0.0.1
9947artifacts:
9948 - name: murmur/driver-anthropic
9949 version: 0.1.0
9950 runtime: driver
9951 gateway:
9952 endpoint: http://127.0.0.1:8080
9953 keyless: true
9954inference:
9955 model: test-model
9956 compaction:
9957 dump_summaries: true
9958 threshold: 1.5
9959 driver:
9960 artifact: murmur/driver-anthropic
9961"#,
9962 )
9963 .expect_err("dump_summaries must not suppress the threshold range check");
9964 assert!(matches!(
9965 err,
9966 RuntimeManifestError::InvalidInferenceConfig { ref field, .. }
9967 if field == "inference.compaction.threshold"
9968 ));
9969 }
9970
9971 fn compaction_call_manifest(lines: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
9972 RuntimeManifest::from_yaml_str(&format!(
9973 r#"
9974name: cap
9975version: 0.0.1
9976artifacts:
9977 - name: murmur/driver-anthropic
9978 version: 0.1.0
9979 runtime: driver
9980 gateway:
9981 endpoint: http://127.0.0.1:8080
9982 keyless: true
9983inference:
9984 model: test-model
9985 driver:
9986 artifact: murmur/driver-anthropic
9987 config:
9988 reasoning_effort: max
9989 compaction:
9990{lines}"#
9991 ))
9992 }
9993
9994 #[test]
9995 fn parses_compaction_max_tokens_and_driver_config() {
9996 let manifest = compaction_call_manifest(
9997 " max_tokens: 8000\n driver_config:\n reasoning_effort: low\n extra: [1, 2]\n",
9998 )
9999 .unwrap();
10000 assert!(
10001 manifest.unknown_keys.is_empty(),
10002 "neither key is unknown: {:?}",
10003 manifest.unknown_keys
10004 );
10005 let compaction = manifest.inference.unwrap().compaction.unwrap();
10006 assert_eq!(compaction.max_tokens, Some(8000));
10007 assert_eq!(
10008 compaction.driver_config.as_deref(),
10009 Some(r#"{"extra":[1,2],"reasoning_effort":"low"}"#)
10010 );
10011
10012 let compaction = compaction_call_manifest(" threshold: 0.5\n")
10013 .unwrap()
10014 .inference
10015 .unwrap()
10016 .compaction
10017 .unwrap();
10018 assert_eq!(compaction.max_tokens, None);
10019 assert_eq!(compaction.driver_config, None);
10020
10021 let compaction = compaction_call_manifest(" driver_config: {}\n")
10023 .unwrap()
10024 .inference
10025 .unwrap()
10026 .compaction
10027 .unwrap();
10028 assert_eq!(compaction.driver_config.as_deref(), Some("{}"));
10029 }
10030
10031 #[test]
10032 fn compaction_max_tokens_zero_is_refused() {
10033 match compaction_call_manifest(" max_tokens: 0\n").unwrap_err() {
10034 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10035 assert_eq!(field, "inference.compaction.max_tokens");
10036 assert_eq!(message, "must be greater than 0");
10037 }
10038 other => panic!("unexpected error: {other:?}"),
10039 }
10040 }
10041
10042 #[test]
10043 fn compaction_driver_config_must_be_a_mapping() {
10044 for value in ["low", "[reasoning_effort]", "3"] {
10045 match compaction_call_manifest(&format!(" driver_config: {value}\n")).unwrap_err() {
10046 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10047 assert_eq!(field, "inference.compaction.driver_config");
10048 assert_eq!(message, "expected mapping/object");
10049 }
10050 other => panic!("unexpected error for {value}: {other:?}"),
10051 }
10052 }
10053 }
10054
10055 #[test]
10058 fn process_transport_rejects_compaction_call_keys() {
10059 for (key, value) in [
10060 ("max_tokens", "8000"),
10061 ("driver_config", "{reasoning_effort: low}"),
10062 ] {
10063 let err = RuntimeManifest::from_yaml_str(&format!(
10064 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-claude-code\n \
10065 version: 0.1.0\n runtime: driver\ninference:\n transport: process\n \
10066 model: claude-haiku-4-5-20251001\n driver:\n artifact: murmur/driver-claude-code\n \
10067 compaction:\n {key}: {value}\n"
10068 ))
10069 .unwrap_err();
10070 match err {
10071 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10072 assert_eq!(field, format!("inference.compaction.{key}"));
10073 assert_eq!(message, "is not valid with transport: process");
10074 }
10075 other => panic!("unexpected error for {key}: {other:?}"),
10076 }
10077 }
10078 }
10079
10080 #[test]
10084 fn compaction_prompt_sources_are_mutually_exclusive() {
10085 let err = RuntimeManifest::from_yaml_str(
10086 r#"
10087name: cap
10088version: 0.0.1
10089artifacts:
10090 - name: murmur/driver-anthropic
10091 version: 0.1.0
10092 runtime: driver
10093 gateway:
10094 endpoint: http://127.0.0.1:8080
10095 keyless: true
10096inference:
10097 model: test-model
10098 compaction:
10099 system_prompt: "inline"
10100 system_prompt_file: "compaction-instructions.md"
10101 driver:
10102 artifact: murmur/driver-anthropic
10103"#,
10104 )
10105 .expect_err("both compaction prompt sources set must fail");
10106
10107 match err {
10108 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
10109 assert_eq!(field, "inference.compaction.system_prompt");
10110 assert_eq!(
10111 message,
10112 "at most one of inference.compaction.system_prompt, \
10113 inference.compaction.system_prompt_file may be set"
10114 );
10115 }
10116 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
10117 }
10118 }
10119
10120 #[test]
10124 fn top_level_and_compaction_prompt_sources_do_not_collide() {
10125 let manifest = RuntimeManifest::from_yaml_str(
10126 r#"
10127name: cap
10128version: 0.0.1
10129artifacts:
10130 - name: murmur/driver-anthropic
10131 version: 0.1.0
10132 runtime: driver
10133 gateway:
10134 endpoint: http://127.0.0.1:8080
10135 keyless: true
10136inference:
10137 model: test-model
10138 system_prompt: "be helpful"
10139 compaction:
10140 system_prompt_file: "compaction-instructions.md"
10141 driver:
10142 artifact: murmur/driver-anthropic
10143"#,
10144 )
10145 .unwrap();
10146
10147 let inference = manifest.inference.unwrap();
10148 assert_eq!(inference.system_prompt, Some("be helpful".to_string()));
10149 assert_eq!(
10150 inference.compaction.unwrap().system_prompt_file,
10151 Some("compaction-instructions.md".to_string())
10152 );
10153 }
10154
10155 #[test]
10156 fn parses_system_prompt_file() {
10157 let manifest = RuntimeManifest::from_yaml_str(
10158 r#"
10159name: cap
10160version: 0.0.1
10161artifacts:
10162 - name: murmur/driver-anthropic
10163 version: 0.1.0
10164 runtime: driver
10165 gateway:
10166 endpoint: http://127.0.0.1:8080
10167 keyless: true
10168inference:
10169 model: test-model
10170 system_prompt_file: "conventions.md"
10171 driver:
10172 artifact: murmur/driver-anthropic
10173"#,
10174 )
10175 .unwrap();
10176
10177 let inference = manifest.inference.unwrap();
10178 assert_eq!(
10179 inference.system_prompt_file,
10180 Some("conventions.md".to_string())
10181 );
10182 assert!(inference.system_prompt.is_none());
10183 }
10184
10185 #[test]
10186 fn rejects_both_inline_and_file_system_prompt() {
10187 let err = RuntimeManifest::from_yaml_str(
10188 r#"
10189name: cap
10190version: 0.0.1
10191artifacts:
10192 - name: murmur/driver-anthropic
10193 version: 0.1.0
10194 runtime: driver
10195 gateway:
10196 endpoint: http://127.0.0.1:8080
10197 keyless: true
10198inference:
10199 model: test-model
10200 system_prompt: "inline"
10201 system_prompt_file: "conventions.md"
10202 driver:
10203 artifact: murmur/driver-anthropic
10204"#,
10205 )
10206 .unwrap_err();
10207
10208 let msg = err.to_string();
10209 assert!(msg.contains("inference.system_prompt"));
10210 assert!(msg.contains("system_prompt_file"));
10211 }
10212
10213 fn instruction_files_manifest(transport: &str, entries: &str) -> String {
10214 let (artifact, inference) = if transport == "process" {
10215 (
10216 " - name: murmur/driver-claude-code\n version: 0.1.0\n runtime: driver\n",
10217 " transport: process\n driver:\n artifact: murmur/driver-claude-code\n",
10218 )
10219 } else {
10220 (
10221 " - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: driver\n \
10222 gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\n",
10223 " model: test-model\n driver:\n artifact: murmur/driver-anthropic\n",
10224 )
10225 };
10226 format!(
10227 "name: cap\nversion: 0.0.1\nartifacts:\n{artifact}inference:\n{inference} \
10228 instruction_files: {entries}\n"
10229 )
10230 }
10231
10232 #[test]
10233 fn instruction_files_absent_is_none() {
10234 let manifest = RuntimeManifest::from_yaml_str(
10235 r#"
10236name: cap
10237version: 0.0.1
10238artifacts:
10239 - name: murmur/driver-anthropic
10240 version: 0.1.0
10241 runtime: driver
10242 gateway:
10243 endpoint: http://127.0.0.1:8080
10244 keyless: true
10245inference:
10246 model: test-model
10247 driver:
10248 artifact: murmur/driver-anthropic
10249"#,
10250 )
10251 .unwrap();
10252 assert_eq!(manifest.inference.unwrap().instruction_files, None);
10253 }
10254
10255 #[test]
10258 fn instruction_files_are_kept_verbatim_in_order_on_both_transports() {
10259 for transport in ["http", "process"] {
10260 let manifest = RuntimeManifest::from_yaml_str(&instruction_files_manifest(
10261 transport,
10262 "[CLAUDE.md, ./AGENTS.md, docs/agents/RULES.md]",
10263 ))
10264 .unwrap();
10265 assert!(
10266 manifest.unknown_keys.is_empty(),
10267 "{transport}: {:?}",
10268 manifest.unknown_keys
10269 );
10270 assert_eq!(
10271 manifest.inference.unwrap().instruction_files,
10272 Some(vec![
10273 "CLAUDE.md".to_string(),
10274 "./AGENTS.md".to_string(),
10275 "docs/agents/RULES.md".to_string(),
10276 ]),
10277 "{transport}"
10278 );
10279 }
10280 }
10281
10282 #[test]
10283 fn instruction_files_empty_list_is_some_empty() {
10284 let manifest =
10285 RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", "[]")).unwrap();
10286 assert_eq!(manifest.inference.unwrap().instruction_files, Some(vec![]));
10287 }
10288
10289 #[test]
10292 fn instruction_files_refuse_entries_that_leave_the_work_folder() {
10293 for (entries, index, entry, problem) in [
10294 ("[/etc/passwd]", 0, "/etc/passwd", "is absolute"),
10295 (
10296 "[AGENTS.md, ../AGENTS.md]",
10297 1,
10298 "../AGENTS.md",
10299 "'..' component",
10300 ),
10301 ("[a/../../b]", 0, "a/../../b", "'..' component"),
10302 ("[\"\"]", 0, "", "is empty"),
10303 ("[AGENTS.md, \" \"]", 1, " ", "is empty"),
10304 ] {
10305 let err = RuntimeManifest::from_yaml_str(&instruction_files_manifest("http", entries))
10306 .unwrap_err();
10307 let RuntimeManifestError::InvalidInferenceConfig { field, message } = &err else {
10308 panic!("{entries}: expected InvalidInferenceConfig, got {err:?}");
10309 };
10310 assert_eq!(field, &format!("inference.instruction_files[{index}]"));
10311 assert!(message.contains(&format!("'{entry}'")), "{message}");
10312 assert!(message.contains(problem), "{message}");
10313 assert!(
10314 message.contains("must be a relative path inside the work folder"),
10315 "{message}"
10316 );
10317 }
10318 }
10319
10320 #[test]
10323 fn api_key_env_reference_parses_to_its_name_without_reading_the_environment() {
10324 let key = "MURMUR_TEST_INFERENCE_KEY_NEVER_SET";
10325 let yaml = format!(
10326 "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"
10327 );
10328
10329 assert_eq!(
10330 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
10331 .gateway
10332 .clone()
10333 .unwrap()
10334 .api_key,
10335 Some(ApiKeyReference::Environment(key.to_string()))
10336 );
10337 assert_eq!(
10338 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
10339 .unwrap()
10340 .artifacts[0]
10341 .gateway
10342 .clone()
10343 .unwrap()
10344 .api_key,
10345 None
10346 );
10347 }
10348
10349 #[test]
10350 fn api_key_reference_debug_names_a_variable_and_redacts_a_literal() {
10351 let literal = ["sk-", "ant-", "debug"].concat();
10352 let debug = format!(
10353 "{:?} {:?}",
10354 ApiKeyReference::Environment("SOME_KEY".to_string()),
10355 ApiKeyReference::Literal(literal.clone())
10356 );
10357 assert!(debug.contains("Environment(\"SOME_KEY\")"), "{debug}");
10358 assert!(debug.contains("Literal(\"<redacted>\")"), "{debug}");
10359 assert!(!debug.contains(&literal), "{debug}");
10360 }
10361
10362 #[test]
10363 fn parse_without_secrets_drops_a_literal_api_key() {
10364 let literal = ["sk-", "ant-", "literal"].concat();
10367 let yaml = format!(
10368 "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"
10369 );
10370
10371 assert_eq!(
10372 RuntimeManifest::from_yaml_str(&yaml).unwrap().artifacts[0]
10373 .gateway
10374 .clone()
10375 .unwrap()
10376 .api_key,
10377 Some(ApiKeyReference::Literal(literal))
10378 );
10379 assert_eq!(
10380 RuntimeManifest::from_yaml_str_without_secrets(&yaml)
10381 .unwrap()
10382 .artifacts[0]
10383 .gateway
10384 .clone()
10385 .unwrap()
10386 .api_key,
10387 None
10388 );
10389 }
10390
10391 #[test]
10394 fn parse_without_secrets_refuses_what_the_resolving_parse_refuses() {
10395 let process_with_key = format!(
10396 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n model: test-model\n api_key: {}\n",
10397 ["sk-", "ant-", "secret"].concat()
10398 );
10399 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";
10400
10401 for yaml in [process_with_key.as_str(), http_without_model] {
10402 let resolving = RuntimeManifest::from_yaml_str(yaml)
10403 .unwrap_err()
10404 .to_string();
10405 let without_secrets = RuntimeManifest::from_yaml_str_without_secrets(yaml)
10406 .unwrap_err()
10407 .to_string();
10408 assert_eq!(resolving, without_secrets);
10409 }
10410 }
10411
10412 #[test]
10416 fn referenced_env_variables_reports_a_reference_and_nothing_else() {
10417 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";
10418 let referenced = referenced_env_variables(referencing);
10419 assert_eq!(referenced.len(), 1);
10420 assert_eq!(
10421 referenced[0].field,
10422 "artifacts.murmur/driver-anthropic.gateway.api_key"
10423 );
10424 assert!(referenced[0].is_gateway_credential());
10425 assert_eq!(referenced[0].variable, "MURMUR_TEST_REFERENCED_KEY");
10426
10427 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";
10428 assert!(referenced_env_variables(literal).is_empty());
10429
10430 let no_inference = "name: cap\nversion: 0.0.1\nartifacts: []\n";
10431 assert!(referenced_env_variables(no_inference).is_empty());
10432
10433 let unparseable = "name: broken\nversion: [\n";
10434 assert!(referenced_env_variables(unparseable).is_empty());
10435 }
10436
10437 #[test]
10438 fn inference_requires_driver_artifact() {
10439 let err = RuntimeManifest::from_yaml_str(
10440 r#"
10441name: cap
10442version: 0.0.1
10443artifacts: []
10444inference:
10445 transport: http
10446 endpoint: http://127.0.0.1:8080
10447 model: test-model
10448"#,
10449 )
10450 .unwrap_err();
10451
10452 assert!(err.to_string().contains("inference.driver"));
10453 }
10454
10455 #[test]
10456 fn test_runtime_driver_deserializes() {
10457 let manifest = RuntimeManifest::from_yaml_str(
10458 r#"
10459name: cap
10460version: 0.0.1
10461artifacts:
10462 - name: local/my-provider
10463 version: 1.0.0
10464 runtime: driver
10465"#,
10466 )
10467 .unwrap();
10468
10469 assert_eq!(manifest.artifacts[0].runtime, ArtifactRuntime::Driver);
10470 }
10471
10472 #[test]
10473 fn test_runtime_unknown_is_error() {
10474 let err = RuntimeManifest::from_yaml_str(
10475 r#"
10476name: cap
10477version: 0.0.1
10478artifacts:
10479 - name: local/foo
10480 version: 1.0.0
10481 runtime: unknown_type
10482"#,
10483 )
10484 .unwrap_err();
10485
10486 let msg = err.to_string();
10487 assert!(msg.contains("runtime"), "error should mention 'runtime'");
10488 assert!(
10489 msg.contains("unknown_type"),
10490 "error should identify the bad value"
10491 );
10492 }
10493
10494 #[test]
10495 fn test_runtime_all_variants_roundtrip() {
10496 for variant in [
10497 ArtifactRuntime::Tool,
10498 ArtifactRuntime::Driver,
10499 ArtifactRuntime::Hook,
10500 ArtifactRuntime::Skill,
10501 ] {
10502 let json = serde_json::to_string(&variant).unwrap();
10503 let back: ArtifactRuntime = serde_json::from_str(&json).unwrap();
10504 assert_eq!(variant, back);
10505 }
10506 }
10507
10508 #[test]
10509 fn skill_source_without_version_defaults_to_local() {
10510 let manifest = RuntimeManifest::from_yaml_str(
10511 r#"
10512name: cap
10513version: 0.0.1
10514artifacts:
10515 - name: my-skill
10516 source: ./skills/my-skill/skill.md
10517 runtime: skill
10518"#,
10519 )
10520 .unwrap();
10521
10522 assert_eq!(manifest.artifacts.len(), 1);
10523 let artifact = &manifest.artifacts[0];
10524 assert_eq!(artifact.runtime, ArtifactRuntime::Skill);
10525 assert_eq!(
10526 artifact.source.as_deref(),
10527 Some("./skills/my-skill/skill.md")
10528 );
10529 assert_eq!(artifact.version, "local");
10530 }
10531
10532 #[test]
10533 fn skill_source_directory_path_is_accepted() {
10534 let manifest = RuntimeManifest::from_yaml_str(
10535 r#"
10536name: cap
10537version: 0.0.1
10538artifacts:
10539 - name: my-skill
10540 source: ./skills/my-skill/
10541 runtime: skill
10542"#,
10543 )
10544 .unwrap();
10545
10546 assert_eq!(
10547 manifest.artifacts[0].source.as_deref(),
10548 Some("./skills/my-skill/")
10549 );
10550 assert_eq!(manifest.artifacts[0].version, "local");
10551 }
10552
10553 #[test]
10554 fn skill_source_with_explicit_version_substitutes_local() {
10555 let manifest = RuntimeManifest::from_yaml_str(
10557 r#"
10558name: cap
10559version: 0.0.1
10560artifacts:
10561 - name: my-skill
10562 source: ./skills/my-skill/skill.md
10563 version: 9.9.9
10564 runtime: skill
10565"#,
10566 )
10567 .unwrap();
10568
10569 assert_eq!(manifest.artifacts[0].version, "local");
10570 }
10571
10572 #[test]
10573 fn local_source_true_allows_source_on_non_skill_runtime() {
10574 for runtime in ["tool", "driver", "hook"] {
10575 let yaml = format!(
10576 r#"
10577name: cap
10578version: 0.0.1
10579artifacts:
10580 - name: my-thing
10581 source: ./local/path
10582 local_source: true
10583 runtime: {runtime}
10584"#
10585 );
10586 let manifest = RuntimeManifest::from_yaml_str(&yaml)
10587 .unwrap_or_else(|err| panic!("runtime {runtime}: {err}"));
10588 let artifact = &manifest.artifacts[0];
10589 assert!(artifact.local_source, "runtime {runtime}");
10590 assert_eq!(artifact.source.as_deref(), Some("./local/path"));
10591 assert_eq!(artifact.version, "local", "runtime {runtime}");
10592 }
10593 }
10594
10595 #[test]
10596 fn local_source_false_on_skill_with_source_is_error() {
10597 let err = RuntimeManifest::from_yaml_str(
10600 r#"
10601name: cap
10602version: 0.0.1
10603artifacts:
10604 - name: my-skill
10605 source: ./skills/my-skill
10606 local_source: false
10607 runtime: skill
10608"#,
10609 )
10610 .unwrap_err();
10611 let msg = err.to_string();
10612 assert!(msg.contains("'local_source: true'"), "error was: {msg}");
10613 assert!(msg.contains("my-skill"), "error was: {msg}");
10614 }
10615
10616 #[test]
10617 fn local_source_defaults_from_role_when_absent() {
10618 let manifest = RuntimeManifest::from_yaml_str(
10619 r#"
10620name: cap
10621version: 0.0.1
10622artifacts:
10623 - name: my-skill
10624 source: ./skills/my-skill
10625 runtime: skill
10626 - name: local/my-tool
10627 version: 1.0.0
10628 runtime: tool
10629"#,
10630 )
10631 .unwrap();
10632 assert!(manifest.artifacts[0].local_source, "skill defaults to true");
10633 assert!(
10634 !manifest.artifacts[1].local_source,
10635 "tool defaults to false"
10636 );
10637 }
10638
10639 #[test]
10640 fn prompt_payload_true_allows_non_skill_system_prompt_artifact() {
10641 let manifest = RuntimeManifest::from_yaml_str(
10642 r#"
10643name: cap
10644version: 0.0.1
10645artifacts:
10646 - name: local/my-tool
10647 version: 1.0.0
10648 runtime: tool
10649 prompt_payload: true
10650 - name: murmur/driver-anthropic
10651 version: 0.1.0
10652 runtime: driver
10653 gateway:
10654 endpoint: http://127.0.0.1:8080
10655 keyless: true
10656inference:
10657 model: test-model
10658 system_prompt_artifact: local/my-tool
10659 driver:
10660 artifact: murmur/driver-anthropic
10661"#,
10662 )
10663 .unwrap();
10664 assert!(manifest.artifacts[0].prompt_payload);
10665 }
10666
10667 #[test]
10668 fn prompt_payload_false_on_skill_system_prompt_artifact_is_error() {
10669 let err = RuntimeManifest::from_yaml_str(
10670 r#"
10671name: cap
10672version: 0.0.1
10673artifacts:
10674 - name: local/my-skill
10675 version: 1.0.0
10676 runtime: skill
10677 prompt_payload: false
10678 - name: murmur/driver-anthropic
10679 version: 0.1.0
10680 runtime: driver
10681 gateway:
10682 endpoint: http://127.0.0.1:8080
10683 keyless: true
10684inference:
10685 model: test-model
10686 system_prompt_artifact: local/my-skill
10687 driver:
10688 artifact: murmur/driver-anthropic
10689"#,
10690 )
10691 .unwrap_err();
10692 let msg = err.to_string();
10693 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
10694 }
10695
10696 #[test]
10697 fn prompt_payload_defaults_from_role_when_absent() {
10698 let manifest = RuntimeManifest::from_yaml_str(
10699 r#"
10700name: cap
10701version: 0.0.1
10702artifacts:
10703 - name: local/my-skill
10704 version: 1.0.0
10705 runtime: skill
10706 - name: local/my-tool
10707 version: 1.0.0
10708 runtime: tool
10709"#,
10710 )
10711 .unwrap();
10712 assert!(
10713 manifest.artifacts[0].prompt_payload,
10714 "skill defaults to true"
10715 );
10716 assert!(
10717 !manifest.artifacts[1].prompt_payload,
10718 "tool defaults to false"
10719 );
10720 }
10721
10722 #[test]
10723 fn source_on_non_skill_runtime_is_error() {
10724 for runtime in ["tool", "driver", "hook"] {
10725 let yaml = format!(
10726 r#"
10727name: cap
10728version: 0.0.1
10729artifacts:
10730 - name: my-thing
10731 source: ./local/path
10732 runtime: {runtime}
10733"#
10734 );
10735 let err = RuntimeManifest::from_yaml_str(&yaml).unwrap_err();
10736 let msg = err.to_string();
10737 assert!(
10738 msg.contains("'local_source: true'"),
10739 "runtime {runtime}: error was: {msg}"
10740 );
10741 assert!(
10742 msg.contains("my-thing"),
10743 "runtime {runtime}: error was: {msg}"
10744 );
10745 }
10746 }
10747
10748 #[test]
10749 fn registry_skill_still_requires_version() {
10750 let err = RuntimeManifest::from_yaml_str(
10751 r#"
10752name: cap
10753version: 0.0.1
10754artifacts:
10755 - name: local/my-skill
10756 runtime: skill
10757"#,
10758 )
10759 .unwrap_err();
10760
10761 let msg = err.to_string();
10762 assert!(
10763 msg.contains("missing required field 'version'"),
10764 "error was: {msg}"
10765 );
10766 }
10767
10768 #[test]
10769 fn rejects_unknown_inference_transport() {
10770 let err = RuntimeManifest::from_yaml_str(
10771 r#"
10772name: cap
10773version: 0.0.1
10774artifacts: []
10775inference:
10776 transport: grpc
10777 model: test-model
10778 driver:
10779 artifact: murmur/driver-anthropic
10780"#,
10781 )
10782 .unwrap_err();
10783
10784 let msg = err.to_string();
10785 assert!(msg.contains("inference.transport"), "error was: {msg}");
10786 assert!(msg.contains("unknown value"), "error was: {msg}");
10787 assert!(msg.contains("grpc"), "error was: {msg}");
10788 }
10789
10790 #[test]
10793 fn process_transport_with_command_and_no_driver_is_refused() {
10794 let err = RuntimeManifest::from_yaml_str(
10795 r#"
10796name: cap
10797version: 0.0.1
10798artifacts: []
10799inference:
10800 transport: process
10801 command: some-cli
10802 model: some-model
10803 max_turns: 20
10804"#,
10805 )
10806 .unwrap_err();
10807
10808 let msg = err.to_string();
10809 assert!(
10810 msg.contains("inference.driver.artifact"),
10811 "error was: {msg}"
10812 );
10813 assert!(msg.contains("inference.command"), "error was: {msg}");
10814 assert!(
10815 msg.contains("murmur/driver-claude-code"),
10816 "error was: {msg}"
10817 );
10818 }
10819
10820 #[test]
10821 fn process_transport_with_neither_driver_nor_command_is_refused() {
10822 let err = RuntimeManifest::from_yaml_str(
10823 r#"
10824name: cap
10825version: 0.0.1
10826artifacts: []
10827inference:
10828 transport: process
10829 model: some-model
10830"#,
10831 )
10832 .unwrap_err();
10833
10834 let msg = err.to_string();
10835 assert!(
10836 msg.contains("inference.driver.artifact"),
10837 "error was: {msg}"
10838 );
10839 assert!(
10840 msg.contains("murmur/driver-claude-code"),
10841 "error was: {msg}"
10842 );
10843 }
10844
10845 #[test]
10846 fn process_transport_allows_absent_model() {
10847 let manifest =
10850 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
10851 .expect("process transport should parse without a model");
10852 let inference = manifest.inference.expect("inference present");
10853 assert_eq!(inference.transport, "process");
10854 assert!(inference.command.is_none());
10855 assert_eq!(
10856 inference.model, "",
10857 "absent model resolves to empty (harness default)"
10858 );
10859 }
10860
10861 fn process_driver_manifest(entry: &str, inference_extra: &str) -> String {
10862 format!(
10863 "name: cap\nversion: 0.0.1\nartifacts:\n{entry}\ninference:\n transport: process\n \
10864 driver:\n artifact: local/fixture-process-driver\n{inference_extra}"
10865 )
10866 }
10867
10868 const PROCESS_DRIVER_ENTRY: &str =
10869 " - name: local/fixture-process-driver\n version: 0.1.0\n runtime: driver";
10870
10871 #[test]
10872 fn process_transport_accepts_driver() {
10873 let manifest = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10874 PROCESS_DRIVER_ENTRY,
10875 " config:\n profile: work\n command: /usr/local/bin/fixture-cli\n",
10876 ))
10877 .unwrap();
10878
10879 let inference = manifest.inference.expect("inference should exist");
10880 assert_eq!(inference.transport, "process");
10881 let driver = inference.driver.expect("driver should be parsed");
10882 assert_eq!(driver.artifact, "local/fixture-process-driver");
10883 assert_eq!(driver.config.as_deref(), Some(r#"{"profile":"work"}"#));
10884 assert_eq!(
10885 inference.command.as_deref(),
10886 Some("/usr/local/bin/fixture-cli")
10887 );
10888 }
10889
10890 #[test]
10891 fn process_transport_accepts_driver_without_command() {
10892 let manifest =
10893 RuntimeManifest::from_yaml_str(&process_driver_manifest(PROCESS_DRIVER_ENTRY, ""))
10894 .unwrap();
10895
10896 let inference = manifest.inference.expect("inference should exist");
10897 assert_eq!(
10898 inference.driver.map(|d| d.artifact).as_deref(),
10899 Some("local/fixture-process-driver")
10900 );
10901 assert!(inference.command.is_none());
10902 assert_eq!(inference.model, "");
10903 }
10904
10905 #[test]
10906 fn process_driver_entry_must_be_declared() {
10907 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10908 " - name: local/other\n version: 0.1.0\n runtime: tool",
10909 "",
10910 ))
10911 .unwrap_err()
10912 .to_string();
10913 assert!(msg.contains("inference.driver.artifact"), "{msg}");
10914 assert!(
10915 msg.contains("artifact 'local/fixture-process-driver' is not declared in artifacts:"),
10916 "{msg}"
10917 );
10918 }
10919
10920 #[test]
10921 fn process_driver_entry_must_be_runtime_driver() {
10922 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(
10923 " - name: local/fixture-process-driver\n version: 0.1.0\n runtime: tool",
10924 "",
10925 ))
10926 .unwrap_err()
10927 .to_string();
10928 assert!(
10929 msg.contains("must be declared with runtime: driver"),
10930 "{msg}"
10931 );
10932 }
10933
10934 #[test]
10935 fn process_driver_entry_refuses_capabilities() {
10936 let entry =
10937 format!("{PROCESS_DRIVER_ENTRY}\n capabilities:\n env:\n allow: [HOME]");
10938 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
10939 .unwrap_err()
10940 .to_string();
10941 assert!(msg.contains("granted nothing"), "{msg}");
10942 assert!(msg.contains("inference.driver.config"), "{msg}");
10943 }
10944
10945 #[test]
10946 fn process_driver_entry_refuses_config() {
10947 let entry = format!("{PROCESS_DRIVER_ENTRY}\n config:\n profile: work");
10948 let msg = RuntimeManifest::from_yaml_str(&process_driver_manifest(&entry, ""))
10949 .unwrap_err()
10950 .to_string();
10951 assert!(msg.contains("granted nothing"), "{msg}");
10952 assert!(msg.contains("remove capabilities:/config:"), "{msg}");
10953 }
10954
10955 #[test]
10956 fn process_transport_rejects_endpoint() {
10957 let err = RuntimeManifest::from_yaml_str(
10958 r#"
10959name: cap
10960version: 0.0.1
10961artifacts: []
10962inference:
10963 transport: process
10964 command: claude
10965 model: claude-haiku-4-5-20251001
10966 endpoint: http://localhost:8080
10967"#,
10968 )
10969 .unwrap_err();
10970
10971 let msg = err.to_string();
10972 assert!(msg.contains("inference.endpoint"), "error was: {msg}");
10973 assert!(msg.contains("was removed"), "error was: {msg}");
10974 }
10975
10976 #[test]
10977 fn process_transport_rejects_api_key() {
10978 let key = ["sk-", "ant-", "secret"].concat();
10981 let err = RuntimeManifest::from_yaml_str(&format!(
10982 r#"
10983name: cap
10984version: 0.0.1
10985artifacts: []
10986inference:
10987 transport: process
10988 command: claude
10989 model: claude-haiku-4-5-20251001
10990 api_key: {key}
10991"#
10992 ))
10993 .unwrap_err();
10994
10995 let msg = err.to_string();
10996 assert!(msg.contains("inference.api_key"), "error was: {msg}");
10997 assert!(msg.contains("was removed"), "error was: {msg}");
10998 assert!(!msg.contains(&key), "error was: {msg}");
10999 }
11000
11001 #[test]
11002 fn process_transport_rejects_max_tokens() {
11003 let err = RuntimeManifest::from_yaml_str(
11006 r#"
11007name: cap
11008version: 0.0.1
11009artifacts: []
11010inference:
11011 transport: process
11012 command: claude
11013 model: claude-haiku-4-5-20251001
11014 max_tokens: 4096
11015"#,
11016 )
11017 .unwrap_err();
11018
11019 let msg = err.to_string();
11020 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
11021 assert!(
11022 msg.contains("not valid with transport: process"),
11023 "error was: {msg}"
11024 );
11025 }
11026
11027 #[test]
11031 fn process_transport_carries_max_session_tokens() {
11032 let manifest = RuntimeManifest::from_yaml_str(
11033 r#"
11034name: cap
11035version: 0.0.1
11036artifacts:
11037 - name: murmur/driver-claude-code
11038 version: 0.1.0
11039 runtime: driver
11040inference:
11041 transport: process
11042 driver:
11043 artifact: murmur/driver-claude-code
11044 model: claude-haiku-4-5-20251001
11045 max_session_tokens: 200000
11046"#,
11047 )
11048 .unwrap();
11049
11050 assert_eq!(
11051 manifest.inference.unwrap().max_session_tokens,
11052 Some(200_000)
11053 );
11054 }
11055
11056 #[test]
11057 fn process_transport_rejects_a_zero_max_session_tokens() {
11058 let err = RuntimeManifest::from_yaml_str(
11059 r#"
11060name: cap
11061version: 0.0.1
11062artifacts:
11063 - name: murmur/driver-claude-code
11064 version: 0.1.0
11065 runtime: driver
11066inference:
11067 transport: process
11068 driver:
11069 artifact: murmur/driver-claude-code
11070 model: claude-haiku-4-5-20251001
11071 max_session_tokens: 0
11072"#,
11073 )
11074 .unwrap_err();
11075
11076 let msg = err.to_string();
11077 assert!(
11078 msg.contains("inference.max_session_tokens"),
11079 "error was: {msg}"
11080 );
11081 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
11082 }
11083
11084 #[test]
11085 fn inference_max_session_tokens_round_trips() {
11086 let manifest = RuntimeManifest::from_yaml_str(
11087 r#"
11088name: cap
11089version: 0.0.1
11090artifacts:
11091 - name: murmur/driver-anthropic
11092 version: 0.1.0
11093 runtime: driver
11094 gateway:
11095 endpoint: https://api.anthropic.com
11096 api_key: test-key
11097inference:
11098 transport: http
11099 model: claude-opus-4-5
11100 max_session_tokens: 5000000000
11101 driver:
11102 artifact: murmur/driver-anthropic
11103"#,
11104 )
11105 .unwrap();
11106
11107 assert_eq!(
11108 manifest.inference.unwrap().max_session_tokens,
11109 Some(5_000_000_000)
11110 );
11111 }
11112
11113 #[test]
11114 fn inference_max_session_tokens_absent_is_none() {
11115 let manifest = RuntimeManifest::from_yaml_str(
11116 r#"
11117name: cap
11118version: 0.0.1
11119artifacts:
11120 - name: murmur/driver-anthropic
11121 version: 0.1.0
11122 runtime: driver
11123 gateway:
11124 endpoint: https://api.anthropic.com
11125 api_key: test-key
11126inference:
11127 transport: http
11128 model: claude-opus-4-5
11129 driver:
11130 artifact: murmur/driver-anthropic
11131"#,
11132 )
11133 .unwrap();
11134
11135 assert_eq!(manifest.inference.unwrap().max_session_tokens, None);
11136 }
11137
11138 #[test]
11139 fn inference_max_session_tokens_zero_is_rejected() {
11140 let err = RuntimeManifest::from_yaml_str(
11141 r#"
11142name: cap
11143version: 0.0.1
11144artifacts:
11145 - name: murmur/driver-anthropic
11146 version: 0.1.0
11147 runtime: driver
11148 gateway:
11149 endpoint: https://api.anthropic.com
11150 api_key: test-key
11151inference:
11152 transport: http
11153 model: claude-opus-4-5
11154 max_session_tokens: 0
11155 driver:
11156 artifact: murmur/driver-anthropic
11157"#,
11158 )
11159 .unwrap_err();
11160
11161 let msg = err.to_string();
11162 assert!(
11163 msg.contains("inference.max_session_tokens"),
11164 "error was: {msg}"
11165 );
11166 assert!(msg.contains("greater than 0"), "error was: {msg}");
11167 }
11168
11169 fn http_manifest_with_inference(extra: &str) -> String {
11171 format!(
11172 "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n \
11173 version: 0.1.0\n runtime: driver\n gateway:\n \
11174 endpoint: https://api.anthropic.com\n api_key: test-key\n\
11175 inference:\n transport: http\n model: claude-opus-4-5\n driver:\n \
11176 artifact: murmur/driver-anthropic\n{extra}"
11177 )
11178 }
11179
11180 #[test]
11181 fn inference_tool_refresh_absent_is_compaction() {
11182 let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference("")).unwrap();
11183 assert_eq!(
11184 manifest.inference.unwrap().tool_refresh,
11185 ToolRefresh::Compaction
11186 );
11187 assert_eq!(ToolRefresh::default(), ToolRefresh::Compaction);
11188 }
11189
11190 #[test]
11191 fn inference_tool_refresh_parses_both_values() {
11192 for (value, expected) in [
11193 ("compaction", ToolRefresh::Compaction),
11194 ("immediate", ToolRefresh::Immediate),
11195 ] {
11196 let manifest = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(&format!(
11197 " tool_refresh: {value}\n"
11198 )))
11199 .unwrap();
11200 assert_eq!(manifest.inference.unwrap().tool_refresh, expected);
11201 assert_eq!(expected.wire_name(), value);
11202 assert!(
11203 manifest.unknown_keys.is_empty(),
11204 "tool_refresh is a known key: {:?}",
11205 manifest.unknown_keys
11206 );
11207 }
11208 }
11209
11210 #[test]
11211 fn inference_tool_refresh_rejects_an_unknown_value() {
11212 let err = RuntimeManifest::from_yaml_str(&http_manifest_with_inference(
11213 " tool_refresh: sometimes\n",
11214 ))
11215 .unwrap_err();
11216 match err {
11217 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
11218 assert_eq!(field, "inference.tool_refresh");
11219 assert_eq!(
11220 message,
11221 "must be one of: compaction, immediate; got 'sometimes'"
11222 );
11223 }
11224 other => panic!("unexpected error: {other:?}"),
11225 }
11226 }
11227
11228 #[test]
11231 fn process_transport_rejects_tool_refresh() {
11232 for value in ["compaction", "immediate"] {
11233 let err = RuntimeManifest::from_yaml_str(&format!(
11234 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n \
11235 command: claude\n model: claude-haiku-4-5-20251001\n tool_refresh: {value}\n"
11236 ))
11237 .unwrap_err();
11238 match err {
11239 RuntimeManifestError::InvalidInferenceConfig { field, message } => {
11240 assert_eq!(field, "inference.tool_refresh");
11241 assert_eq!(message, "is not valid with transport: process");
11242 }
11243 other => panic!("unexpected error for {value}: {other:?}"),
11244 }
11245 }
11246 }
11247
11248 #[test]
11249 fn inference_max_tokens_round_trips() {
11250 let manifest = RuntimeManifest::from_yaml_str(
11251 r#"
11252name: cap
11253version: 0.0.1
11254artifacts:
11255 - name: murmur/driver-anthropic
11256 version: 0.1.0
11257 runtime: driver
11258 gateway:
11259 endpoint: https://api.anthropic.com
11260 api_key: test-key
11261inference:
11262 transport: http
11263 model: claude-opus-4-5
11264 max_tokens: 4096
11265 driver:
11266 artifact: murmur/driver-anthropic
11267"#,
11268 )
11269 .unwrap();
11270
11271 let inference = manifest.inference.unwrap();
11272 assert_eq!(inference.max_tokens, Some(4096));
11273 }
11274
11275 #[test]
11276 fn inference_max_tokens_absent_is_none() {
11277 let manifest = RuntimeManifest::from_yaml_str(
11280 r#"
11281name: cap
11282version: 0.0.1
11283artifacts:
11284 - name: murmur/driver-anthropic
11285 version: 0.1.0
11286 runtime: driver
11287 gateway:
11288 endpoint: https://api.anthropic.com
11289 api_key: test-key
11290inference:
11291 transport: http
11292 model: claude-opus-4-5
11293 driver:
11294 artifact: murmur/driver-anthropic
11295"#,
11296 )
11297 .unwrap();
11298
11299 assert_eq!(manifest.inference.unwrap().max_tokens, None);
11300 }
11301
11302 #[test]
11303 fn inference_max_tokens_zero_is_rejected() {
11304 let err = RuntimeManifest::from_yaml_str(
11305 r#"
11306name: cap
11307version: 0.0.1
11308artifacts:
11309 - name: murmur/driver-anthropic
11310 version: 0.1.0
11311 runtime: driver
11312 gateway:
11313 endpoint: https://api.anthropic.com
11314 api_key: test-key
11315inference:
11316 transport: http
11317 model: claude-opus-4-5
11318 max_tokens: 0
11319 driver:
11320 artifact: murmur/driver-anthropic
11321"#,
11322 )
11323 .unwrap_err();
11324
11325 let msg = err.to_string();
11326 assert!(msg.contains("inference.max_tokens"), "error was: {msg}");
11327 assert!(msg.contains("must be greater than 0"), "error was: {msg}");
11328 }
11329
11330 #[test]
11331 fn inference_max_tokens_large_value_is_accepted() {
11332 let manifest = RuntimeManifest::from_yaml_str(
11335 r#"
11336name: cap
11337version: 0.0.1
11338artifacts:
11339 - name: murmur/driver-anthropic
11340 version: 0.1.0
11341 runtime: driver
11342 gateway:
11343 endpoint: https://api.anthropic.com
11344 api_key: test-key
11345inference:
11346 transport: http
11347 model: claude-opus-4-5
11348 max_tokens: 999999
11349 driver:
11350 artifact: murmur/driver-anthropic
11351"#,
11352 )
11353 .unwrap();
11354
11355 assert_eq!(manifest.inference.unwrap().max_tokens, Some(999_999));
11356 }
11357
11358 #[test]
11359 fn inference_and_context_max_tokens_are_independent() {
11360 let manifest = RuntimeManifest::from_yaml_str(
11363 r#"
11364name: cap
11365version: 0.0.1
11366artifacts:
11367 - name: murmur/driver-anthropic
11368 version: 0.1.0
11369 runtime: driver
11370 gateway:
11371 endpoint: https://api.anthropic.com
11372 api_key: test-key
11373inference:
11374 transport: http
11375 model: claude-opus-4-5
11376 max_tokens: 4096
11377 driver:
11378 artifact: murmur/driver-anthropic
11379context:
11380 max_tokens: 200000
11381"#,
11382 )
11383 .unwrap();
11384
11385 assert_eq!(manifest.inference.unwrap().max_tokens, Some(4096));
11386 assert_eq!(manifest.context.unwrap().max_tokens, Some(200_000));
11387 }
11388
11389 #[test]
11390 fn hook_config_defaults_when_fields_absent() {
11391 let config = parse_hook_config_from_yaml("name: my-hook\nruntime: hook\n").unwrap();
11392 assert_eq!(config.binding, HookBinding::All);
11393 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
11394 assert_eq!(config.commit_policy, HookCommitPolicy::None);
11395 }
11396
11397 #[test]
11398 fn hook_config_parses_all_fields() {
11399 let yaml = "name: my-hook\nruntime: hook\nbinding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n";
11400 let config = parse_hook_config_from_yaml(yaml).unwrap();
11401 assert_eq!(config.binding, HookBinding::OnCompaction);
11402 assert_eq!(config.execution_mode, HookExecutionMode::Blocking);
11403 assert_eq!(config.commit_policy, HookCommitPolicy::ReplaceContext);
11404 }
11405
11406 #[test]
11407 fn hook_config_parses_on_task_start() {
11408 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-start\n";
11409 let config = parse_hook_config_from_yaml(yaml).unwrap();
11410 assert_eq!(config.binding, HookBinding::OnTaskStart);
11411 }
11412
11413 #[test]
11414 fn hook_config_parses_on_task_end() {
11415 let yaml = "name: my-hook\nruntime: hook\nbinding: on-task-end\n";
11416 let config = parse_hook_config_from_yaml(yaml).unwrap();
11417 assert_eq!(config.binding, HookBinding::OnTaskEnd);
11418 }
11419
11420 #[test]
11421 fn hook_config_unknown_binding_lists_task_events() {
11422 let yaml = "name: my-hook\nruntime: hook\nbinding: on-nonsense\n";
11423 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11424 assert!(err.contains("on-task-start"), "error was: {err}");
11425 assert!(err.contains("on-task-end"), "error was: {err}");
11426 }
11427
11428 #[test]
11429 fn hook_config_async_with_commit_is_rejected() {
11430 let yaml =
11431 "name: my-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: replace-context\n";
11432 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11433 assert!(err.contains("async-with-commit"), "error was: {err}");
11434 }
11435
11436 #[test]
11437 fn hook_config_on_stage_async_is_rejected() {
11438 let yaml = "name: my-hook\nruntime: hook\nbinding: on-stage\nexecution_mode: async\n";
11439 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11440 assert!(err.contains("on-stage"), "error was: {err}");
11441 assert!(err.contains("blocking"), "error was: {err}");
11442 }
11443
11444 #[test]
11445 fn hook_config_async_none_is_valid() {
11446 let yaml = "name: debug-hook\nruntime: hook\nexecution_mode: async\ncommit_policy: none\n";
11447 let config = parse_hook_config_from_yaml(yaml).unwrap();
11448 assert_eq!(config.execution_mode, HookExecutionMode::Async);
11449 assert_eq!(config.commit_policy, HookCommitPolicy::None);
11450 }
11451
11452 #[test]
11453 fn hook_config_parses_reopen_task_commit_policy() {
11454 let yaml = "name: gate\nruntime: hook\nbinding: on-task-end\ncommit_policy: reopen-task\n";
11455 let config = parse_hook_config_from_yaml(yaml).unwrap();
11456 assert_eq!(config.binding, HookBinding::OnTaskEnd);
11457 assert_eq!(config.commit_policy, HookCommitPolicy::ReopenTask);
11458 }
11459
11460 #[test]
11461 fn hook_config_unknown_commit_policy_lists_reopen_task() {
11462 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11463 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11464 assert!(err.contains("reopen-task"), "error was: {err}");
11465 }
11466
11467 #[test]
11468 fn hook_config_parses_deny_commit_policy_for_both_gated_bindings() {
11469 for binding in ["on-shell", "on-tool-call"] {
11470 let yaml =
11471 format!("name: policy\nruntime: hook\nbinding: {binding}\ncommit_policy: deny\n");
11472 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11473 assert_eq!(config.commit_policy, HookCommitPolicy::Deny);
11474 }
11475 assert_eq!(HookCommitPolicy::Deny.as_str(), "deny");
11476 }
11477
11478 #[test]
11481 fn hook_config_deny_without_a_binding_is_rejected() {
11482 let yaml = "name: policy\nruntime: hook\ncommit_policy: deny\n";
11483 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11484 assert!(
11485 err.contains("requires an explicit binding"),
11486 "error was: {err}"
11487 );
11488 assert!(err.contains("on-shell"), "error was: {err}");
11489 assert!(err.contains("on-tool-call"), "error was: {err}");
11490 }
11491
11492 #[test]
11494 fn hook_config_deny_on_an_ungated_binding_is_rejected() {
11495 let yaml = "name: policy\nruntime: hook\nbinding: on-compaction\ncommit_policy: deny\n";
11496 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11497 assert!(err.contains("is not valid for binding"), "error was: {err}");
11498 assert!(err.contains("replace-context"), "error was: {err}");
11499 }
11500
11501 #[test]
11503 fn hook_config_async_deny_is_rejected() {
11504 let yaml =
11505 "name: policy\nruntime: hook\nbinding: on-shell\nexecution_mode: async\ncommit_policy: deny\n";
11506 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11507 assert!(err.contains("async-with-commit"), "error was: {err}");
11508 assert!(err.contains("deny"), "error was: {err}");
11509 }
11510
11511 #[test]
11512 fn hook_config_unknown_commit_policy_lists_deny() {
11513 let yaml = "name: gate\nruntime: hook\ncommit_policy: on-nonsense\n";
11514 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11515 assert!(err.contains("deny"), "error was: {err}");
11516 }
11517
11518 #[test]
11521 fn hook_config_async_reopen_task_is_rejected() {
11522 let yaml = "name: gate\nruntime: hook\nexecution_mode: async\ncommit_policy: reopen-task\n";
11523 let err = parse_hook_config_from_yaml(yaml).unwrap_err();
11524 assert!(err.contains("async-with-commit"), "error was: {err}");
11525 assert!(err.contains("reopen-task"), "error was: {err}");
11526 }
11527
11528 #[test]
11534 fn hook_config_non_committing_binding_rejects_any_commit_policy() {
11535 for binding in ["on-session-start", "on-session-end"] {
11536 let yaml = format!(
11537 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: replace-context\n"
11538 );
11539 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11540 assert!(err.contains(binding), "error was: {err}");
11541 assert!(err.contains("replace-context"), "error was: {err}");
11542 assert!(
11543 err.contains("honors commit_policy 'none'"),
11544 "error was: {err}"
11545 );
11546 }
11547 }
11548
11549 #[test]
11552 fn hook_config_binding_rejects_a_different_bindings_commit_policy() {
11553 for (binding, declared, honored) in [
11554 ("on-stage", "reopen-task", "write-manifests"),
11555 ("on-compaction", "write-manifests", "replace-context"),
11556 ("on-task-end", "replace-context", "reopen-task"),
11557 ("on-task-start", "replace-context", "seed-context"),
11558 ("on-shell", "replace-context", "deny"),
11559 ("on-tool-call", "replace-context", "deny"),
11560 ] {
11561 let yaml = format!(
11562 "name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {declared}\n"
11563 );
11564 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11565 assert!(err.contains(binding), "error was: {err}");
11566 assert!(
11567 err.contains(&format!("commit_policy '{declared}' is not valid")),
11568 "error was: {err}"
11569 );
11570 assert!(
11571 err.contains(&format!("honors commit_policy '{honored}'")),
11572 "error was: {err}"
11573 );
11574 }
11575 }
11576
11577 #[test]
11580 fn hook_config_on_inference_rejects_every_non_none_commit_policy() {
11581 for declared in ["replace-context", "write-manifests", "reopen-task"] {
11582 let yaml = format!(
11583 "name: gate\nruntime: hook\nbinding: on-inference\ncommit_policy: {declared}\n"
11584 );
11585 let err = parse_hook_config_from_yaml(&yaml).unwrap_err();
11586 assert!(err.contains("on-inference"), "error was: {err}");
11587 assert!(err.contains(declared), "error was: {err}");
11588 assert!(
11589 err.contains("honors commit_policy 'none'"),
11590 "error was: {err}"
11591 );
11592 assert!(err.contains("artifact"), "error was: {err}");
11593 }
11594 }
11595
11596 #[test]
11599 fn hook_config_matching_binding_and_commit_policy_is_valid() {
11600 for (binding, policy, expected) in [
11601 (
11602 "on-stage",
11603 "write-manifests",
11604 HookCommitPolicy::WriteManifests,
11605 ),
11606 (
11607 "on-compaction",
11608 "replace-context",
11609 HookCommitPolicy::ReplaceContext,
11610 ),
11611 ("on-task-end", "reopen-task", HookCommitPolicy::ReopenTask),
11612 ("on-shell", "deny", HookCommitPolicy::Deny),
11613 ("on-tool-call", "deny", HookCommitPolicy::Deny),
11614 ] {
11615 let yaml =
11616 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: {policy}\n");
11617 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11618 assert_eq!(config.commit_policy, expected);
11619 }
11620 }
11621
11622 #[test]
11625 fn hook_config_commit_policy_none_is_valid_for_every_binding() {
11626 for binding in [
11627 "on-stage",
11628 "on-session-start",
11629 "on-task-start",
11630 "on-inference",
11631 "on-tool-call",
11632 "on-shell",
11633 "on-compaction",
11634 "on-task-end",
11635 "on-session-end",
11636 ] {
11637 let yaml =
11638 format!("name: gate\nruntime: hook\nbinding: {binding}\ncommit_policy: none\n");
11639 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11640 assert_eq!(config.commit_policy, HookCommitPolicy::None);
11641 }
11642 }
11643
11644 #[test]
11649 fn hook_config_omitted_binding_accepts_every_commit_policy() {
11650 for (policy, expected) in [
11651 ("replace-context", HookCommitPolicy::ReplaceContext),
11652 ("write-manifests", HookCommitPolicy::WriteManifests),
11653 ("reopen-task", HookCommitPolicy::ReopenTask),
11654 ("none", HookCommitPolicy::None),
11655 ] {
11656 let yaml = format!("name: gate\nruntime: hook\ncommit_policy: {policy}\n");
11657 let config = parse_hook_config_from_yaml(&yaml).unwrap();
11658 assert_eq!(config.binding, HookBinding::All);
11659 assert_eq!(config.commit_policy, expected);
11660 }
11661 }
11662
11663 #[test]
11668 fn hook_config_shipped_default_artifact_manifests_still_parse() {
11669 let shipped: [(&str, &str, HookConfig); 8] = [
11670 (
11671 "murmur-hook-compact",
11672 "binding: on-compaction\nexecution_mode: blocking\ncommit_policy: replace-context\n",
11673 HookConfig {
11674 binding: HookBinding::OnCompaction,
11675 execution_mode: HookExecutionMode::Blocking,
11676 commit_policy: HookCommitPolicy::ReplaceContext,
11677 },
11678 ),
11679 (
11680 "murmur-hook-shell-desc",
11681 "binding: on-stage\nexecution_mode: blocking\ncommit_policy: write-manifests\n",
11682 HookConfig {
11683 binding: HookBinding::OnStage,
11684 execution_mode: HookExecutionMode::Blocking,
11685 commit_policy: HookCommitPolicy::WriteManifests,
11686 },
11687 ),
11688 (
11689 "murmur-hook-regression-verifier",
11691 "execution_mode: blocking\ncommit_policy: reopen-task\n",
11692 HookConfig {
11693 binding: HookBinding::All,
11694 execution_mode: HookExecutionMode::Blocking,
11695 commit_policy: HookCommitPolicy::ReopenTask,
11696 },
11697 ),
11698 (
11699 "murmur-hook-memory",
11700 "binding: on-task-start\nexecution_mode: blocking\ncommit_policy: seed-context\n",
11701 HookConfig {
11702 binding: HookBinding::OnTaskStart,
11703 execution_mode: HookExecutionMode::Blocking,
11704 commit_policy: HookCommitPolicy::SeedContext,
11705 },
11706 ),
11707 (
11708 "murmur-hook-debug",
11709 "execution_mode: async\ncommit_policy: none\n",
11710 HookConfig {
11711 binding: HookBinding::All,
11712 execution_mode: HookExecutionMode::Async,
11713 commit_policy: HookCommitPolicy::None,
11714 },
11715 ),
11716 (
11717 "murmur-hook-diff-summary",
11718 "execution_mode: blocking\ncommit_policy: none\n",
11719 HookConfig {
11720 binding: HookBinding::All,
11721 execution_mode: HookExecutionMode::Blocking,
11722 commit_policy: HookCommitPolicy::None,
11723 },
11724 ),
11725 ("murmur-hook-eval", "", HookConfig::default()),
11726 ("murmur-hook-grafana", "", HookConfig::default()),
11727 ];
11728
11729 for (name, fields, expected) in shipped {
11730 let yaml = format!("name: {name}\nruntime: hook\n{fields}");
11731 let config = parse_hook_config_from_yaml(&yaml)
11732 .unwrap_or_else(|e| panic!("shipped hook {name} must still parse: {e}"));
11733 assert_eq!(config, expected, "shipped hook {name}");
11734 }
11735 }
11736
11737 #[test]
11741 fn commit_policy_for_binding_matches_the_declarable_pairs() {
11742 assert_eq!(
11743 commit_policy_for_binding(&HookBinding::OnStage),
11744 Some(HookCommitPolicy::WriteManifests)
11745 );
11746 assert_eq!(
11747 commit_policy_for_binding(&HookBinding::OnCompaction),
11748 Some(HookCommitPolicy::ReplaceContext)
11749 );
11750 assert_eq!(
11751 commit_policy_for_binding(&HookBinding::OnTaskEnd),
11752 Some(HookCommitPolicy::ReopenTask)
11753 );
11754 assert_eq!(
11755 commit_policy_for_binding(&HookBinding::OnTaskStart),
11756 Some(HookCommitPolicy::SeedContext)
11757 );
11758 assert_eq!(
11759 commit_policy_for_binding(&HookBinding::OnToolCall),
11760 Some(HookCommitPolicy::Deny)
11761 );
11762 assert_eq!(
11763 commit_policy_for_binding(&HookBinding::OnShell),
11764 Some(HookCommitPolicy::Deny)
11765 );
11766 for binding in [
11770 HookBinding::OnSessionStart,
11771 HookBinding::OnInference,
11772 HookBinding::OnSessionEnd,
11773 HookBinding::All,
11774 ] {
11775 assert_eq!(commit_policy_for_binding(&binding), None, "{binding:?}");
11776 }
11777 }
11778
11779 #[test]
11782 fn seed_context_commit_policy_is_declarable_only_on_on_task_start() {
11783 let config = parse_hook_config_from_yaml(
11784 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-task-start\n\
11785 commit_policy: seed-context\n",
11786 )
11787 .expect("on-task-start + seed-context is the declarable pairing");
11788 assert_eq!(config.binding, HookBinding::OnTaskStart);
11789 assert_eq!(config.commit_policy, HookCommitPolicy::SeedContext);
11790
11791 let error = parse_hook_config_from_yaml(
11792 "name: memory\nversion: 0.1.0\nruntime: hook\nbinding: on-compaction\n\
11793 commit_policy: seed-context\n",
11794 )
11795 .expect_err("on-compaction cannot commit a seed");
11796 assert!(error.contains("on-compaction"), "{error}");
11797 assert!(error.contains("seed-context"), "{error}");
11798 }
11799
11800 #[test]
11803 fn unknown_commit_policy_names_seed_context_among_the_accepted_values() {
11804 let error = parse_hook_config_from_yaml(
11805 "name: h\nversion: 0.1.0\nruntime: hook\ncommit_policy: seed-contxt\n",
11806 )
11807 .expect_err("a misspelled policy is rejected");
11808 assert!(error.contains("seed-context"), "{error}");
11809 }
11810
11811 fn context_of(yaml_block: &str) -> Option<ContextConfig> {
11814 RuntimeManifest::from_yaml_str(&format!(
11815 "name: cap\nversion: 0.1.0\nartifacts: []\n{yaml_block}"
11816 ))
11817 .expect("the manifest under test parses")
11818 .context
11819 }
11820
11821 #[test]
11822 fn seed_budget_and_overflow_margin_parse_from_the_context_block() {
11823 let context = context_of(
11824 "context:\n max_tokens: 200000\n seed_budget: 0.10\n seed_overflow_margin: 0.10\n",
11825 )
11826 .expect("a declared context block parses to Some");
11827 assert_eq!(context.max_tokens, Some(200_000));
11828 assert!((context.seed_budget - 0.10).abs() < f32::EPSILON);
11829 assert!((context.seed_overflow_margin - 0.10).abs() < f32::EPSILON);
11830 }
11831
11832 #[test]
11833 fn seed_budget_and_overflow_margin_default_when_omitted() {
11834 let context = context_of("context:\n max_tokens: 200000\n")
11835 .expect("a declared context block parses to Some");
11836 assert!((context.seed_budget - DEFAULT_SEED_BUDGET).abs() < f32::EPSILON);
11837 assert!((context.seed_overflow_margin - DEFAULT_SEED_OVERFLOW_MARGIN).abs() < f32::EPSILON);
11838 }
11839
11840 #[test]
11843 fn seed_budget_defaults_do_not_create_a_context_block() {
11844 assert_eq!(context_of(""), None);
11845 }
11846
11847 #[test]
11848 fn seed_budget_outside_zero_to_one_is_rejected_by_field_name() {
11849 for (block, field) in [
11850 ("context:\n seed_budget: 1.5\n", "context.seed_budget"),
11851 ("context:\n seed_budget: -0.1\n", "context.seed_budget"),
11852 (
11853 "context:\n seed_overflow_margin: 2.0\n",
11854 "context.seed_overflow_margin",
11855 ),
11856 ] {
11857 let error = RuntimeManifest::from_yaml_str(&format!(
11858 "name: cap\nversion: 0.1.0\nartifacts: []\n{block}"
11859 ))
11860 .expect_err("an out-of-range fraction is rejected");
11861 match error {
11862 RuntimeManifestError::InvalidInferenceConfig {
11863 field: got,
11864 ref message,
11865 } => {
11866 assert_eq!(got, field);
11867 assert!(message.contains("0.0 and 1.0"), "{message}");
11868 }
11869 other => panic!("expected InvalidInferenceConfig for {block:?}, got {other:?}"),
11870 }
11871 }
11872 }
11873
11874 #[test]
11875 fn parse_tool_implementation_defaults_to_wasm() {
11876 let impl_ = parse_tool_implementation_from_yaml("name: my-tool\nruntime: tool\n");
11877 assert_eq!(impl_, ArtifactImplementation::Wasm);
11878 }
11879
11880 #[test]
11881 fn parse_tool_implementation_native() {
11882 let impl_ = parse_tool_implementation_from_yaml(
11883 "name: my-tool\nruntime: tool\nimplementation: native\n",
11884 );
11885 assert_eq!(impl_, ArtifactImplementation::Native);
11886 }
11887
11888 #[test]
11889 fn deploy_block_mur_version_parses() {
11890 let manifest = RuntimeManifest::from_yaml_str(
11891 "name: cap\nversion: 0.1.0\nartifacts: []\ndeploy:\n mur_version: \"0.4.5\"\n",
11892 )
11893 .unwrap();
11894 assert_eq!(manifest.deploy_mur_version(), Some("0.4.5"));
11895 assert_eq!(
11896 manifest.deploy,
11897 Some(DeployConfig {
11898 mur_version: Some("0.4.5".to_string())
11899 })
11900 );
11901 }
11902
11903 #[test]
11904 fn deploy_block_absent_is_none() {
11905 let manifest =
11906 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\nartifacts: []\n").unwrap();
11907 assert_eq!(manifest.deploy, None);
11908 assert_eq!(manifest.deploy_mur_version(), None);
11909 }
11910
11911 #[test]
11913 fn deploy_block_blank_mur_version_is_none() {
11914 for deploy in [
11915 "deploy:\n mur_version: \"\"\n",
11916 "deploy:\n mur_version: \" \"\n",
11917 ] {
11918 let manifest = RuntimeManifest::from_yaml_str(&format!(
11919 "name: cap\nversion: 0.1.0\nartifacts: []\n{deploy}"
11920 ))
11921 .unwrap();
11922 assert_eq!(manifest.deploy_mur_version(), None, "{deploy:?}");
11923 }
11924 let manifest =
11925 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.1.0\ndeploy: {}\n").unwrap();
11926 assert_eq!(manifest.deploy, Some(DeployConfig::default()));
11927 assert_eq!(manifest.deploy_mur_version(), None);
11928 }
11929
11930 #[test]
11931 fn deploy_block_misspelled_key_is_reported_under_deploy() {
11932 assert_eq!(
11933 unknown_keys_of("name: cap\nversion: 0.1.0\ndeploy:\n mur_versoin: \"0.4.5\"\n"),
11934 vec![(
11935 "mur_versoin".to_string(),
11936 "deploy".to_string(),
11937 Some("mur_version".to_string())
11938 )]
11939 );
11940 }
11941
11942 #[test]
11943 fn a_top_level_mur_version_is_refused_naming_deploy_mur_version() {
11944 for pin in [
11945 "mur_version: \"0.4.5\"\n",
11946 "mur_version: 0.4.0\n",
11947 "mur_version: \"\"\n",
11948 "mur_version:\n",
11949 ] {
11950 let error = RuntimeManifest::from_yaml_str(&format!(
11951 "name: cap\nversion: 0.1.0\nartifacts: []\n{pin}"
11952 ))
11953 .expect_err(pin);
11954 assert!(
11955 matches!(error, RuntimeManifestError::TopLevelMurVersion),
11956 "{pin:?}: {error:?}"
11957 );
11958 assert!(error.to_string().contains("deploy.mur_version"), "{error}");
11959 }
11960 assert_eq!(
11961 RuntimeManifestError::TopLevelMurVersion.to_string(),
11962 "murmur.yaml: 'mur_version' is not a top-level key — the mur release a deploy \
11963 installs is pinned as 'deploy.mur_version'"
11964 );
11965 }
11966
11967 #[test]
11968 fn a_top_level_mur_version_is_refused_beside_a_deploy_block() {
11969 let yaml = "name: cap\nversion: 0.1.0\nmur_version: \"0.4.5\"\ndeploy:\n mur_version: \"0.4.5\"\n";
11970 assert!(matches!(
11971 RuntimeManifest::from_yaml_str(yaml),
11972 Err(RuntimeManifestError::TopLevelMurVersion)
11973 ));
11974 assert!(matches!(
11975 RuntimeManifest::from_yaml_str_without_secrets(yaml),
11976 Err(RuntimeManifestError::TopLevelMurVersion)
11977 ));
11978 }
11979
11980 #[test]
11981 fn inference_max_turns_absent_sets_no_cap() {
11982 let manifest = RuntimeManifest::from_yaml_str(
11983 "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",
11984 ).unwrap();
11985 assert_eq!(manifest.inference.unwrap().max_turns, None);
11986 }
11987
11988 #[test]
11989 fn inference_max_turns_explicit_value() {
11990 let manifest = RuntimeManifest::from_yaml_str(
11991 "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",
11992 ).unwrap();
11993 assert_eq!(manifest.inference.unwrap().max_turns, Some(20));
11994 }
11995
11996 #[test]
11997 fn inference_max_turns_zero_is_rejected() {
11998 let err = RuntimeManifest::from_yaml_str(
11999 "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",
12000 ).unwrap_err();
12001 let msg = err.to_string();
12002 assert!(msg.contains("inference.max_turns"), "error was: {msg}");
12003 assert!(msg.contains("greater than 0"), "error was: {msg}");
12004 }
12005
12006 #[test]
12008 fn lifecycle_shell_grace_secs_lowers_the_declared_value() {
12009 let manifest = RuntimeManifest::from_yaml_str(
12010 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 45\n",
12011 )
12012 .unwrap();
12013 let lifecycle = manifest.lifecycle.unwrap();
12014 assert_eq!(lifecycle.shell_grace_secs, Some(45));
12015 assert_eq!(
12017 lifecycle.task_acceptance,
12018 LifecycleConfig::default().task_acceptance
12019 );
12020 assert_eq!(lifecycle.after_task, LifecycleConfig::default().after_task);
12021 assert_eq!(
12022 lifecycle.queue_depth,
12023 LifecycleConfig::default().queue_depth
12024 );
12025 assert_eq!(lifecycle.input_timeout_secs, None);
12026 assert_eq!(
12027 lifecycle.conversation_mode,
12028 LifecycleConfig::default().conversation_mode
12029 );
12030 assert_eq!(
12031 lifecycle.max_task_reopens,
12032 LifecycleConfig::default().max_task_reopens
12033 );
12034 }
12035
12036 #[test]
12038 fn lifecycle_shell_grace_secs_is_unset_when_not_declared() {
12039 let manifest = RuntimeManifest::from_yaml_str(
12040 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12041 )
12042 .unwrap();
12043 let lifecycle = manifest.lifecycle.unwrap();
12044 assert_eq!(lifecycle.shell_grace_secs, None);
12045 assert_eq!(lifecycle.queue_depth, 3);
12046
12047 let without_block =
12048 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12049 assert_eq!(without_block.effective_lifecycle().shell_grace_secs, None);
12050 assert_eq!(LifecycleConfig::default().shell_grace_secs, None);
12051 }
12052
12053 #[test]
12056 fn lifecycle_delegation_deadline_secs_defaults_to_600() {
12057 let without_block =
12058 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12059 assert_eq!(
12060 without_block.effective_lifecycle().delegation_deadline_secs,
12061 600
12062 );
12063
12064 let without_key = RuntimeManifest::from_yaml_str(
12065 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12066 )
12067 .unwrap();
12068 let lifecycle = without_key.lifecycle.unwrap();
12069 assert_eq!(lifecycle.delegation_deadline_secs, 600);
12070 assert_eq!(lifecycle.queue_depth, 3);
12071 }
12072
12073 #[test]
12074 fn lifecycle_delegation_deadline_secs_takes_the_declared_value() {
12075 let manifest = RuntimeManifest::from_yaml_str(
12076 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
12077 delegation_deadline_secs: 30\n",
12078 )
12079 .unwrap();
12080 let lifecycle = manifest.lifecycle.unwrap();
12081 assert_eq!(lifecycle.delegation_deadline_secs, 30);
12082 assert_eq!(
12084 lifecycle.shell_grace_secs,
12085 LifecycleConfig::default().shell_grace_secs
12086 );
12087 assert_eq!(
12088 lifecycle.max_task_reopens,
12089 LifecycleConfig::default().max_task_reopens
12090 );
12091 }
12092
12093 #[test]
12095 fn lifecycle_delegation_deadline_secs_is_a_known_key() {
12096 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"delegation_deadline_secs"));
12097 let manifest = RuntimeManifest::from_yaml_str(
12098 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
12099 delegation_deadline_secs: 30\n delegation_deadline: 30\n",
12100 )
12101 .unwrap();
12102 let reported: Vec<(String, String)> = manifest
12103 .unknown_keys
12104 .iter()
12105 .map(|key| (key.key.clone(), key.block_path.clone()))
12106 .collect();
12107 assert_eq!(
12108 reported,
12109 vec![("delegation_deadline".to_string(), "lifecycle".to_string())],
12110 "the declared key is known and only the misspelt neighbour is reported"
12111 );
12112 assert_eq!(
12113 manifest.lifecycle.unwrap().delegation_deadline_secs,
12114 30,
12115 "the misspelt neighbour does not disturb the declared value"
12116 );
12117 }
12118
12119 #[test]
12121 fn lifecycle_deadline_notice_secs_defaults_to_none() {
12122 let without_block =
12123 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12124 assert_eq!(
12125 without_block.effective_lifecycle().deadline_notice_secs,
12126 None
12127 );
12128 let without_key = RuntimeManifest::from_yaml_str(
12129 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12130 )
12131 .unwrap();
12132 assert_eq!(without_key.lifecycle.unwrap().deadline_notice_secs, None);
12133 }
12134
12135 #[test]
12137 fn lifecycle_deadline_notice_secs_takes_zero_and_positive_values() {
12138 for (declared, expected) in [("0", 0), ("90", 90)] {
12139 let manifest = RuntimeManifest::from_yaml_str(&format!(
12140 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
12141 deadline_notice_secs: {declared}\n"
12142 ))
12143 .unwrap();
12144 assert!(manifest.unknown_keys.is_empty());
12145 assert_eq!(
12146 manifest.lifecycle.unwrap().deadline_notice_secs,
12147 Some(expected)
12148 );
12149 }
12150 }
12151
12152 #[test]
12153 fn lifecycle_deadline_notice_secs_refuses_a_negative_or_non_integer_value() {
12154 for declared in ["-5", "1.5", "soon"] {
12155 let refused = RuntimeManifest::from_yaml_str(&format!(
12156 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n \
12157 deadline_notice_secs: {declared}\n"
12158 ));
12159 assert!(refused.is_err(), "{declared} is refused: {refused:?}");
12160 }
12161 }
12162
12163 #[test]
12164 fn lifecycle_deadline_notice_secs_is_a_known_key() {
12165 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"deadline_notice_secs"));
12166 }
12167
12168 fn lifecycle_with_keep_working(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
12169 RuntimeManifest::from_yaml_str(&format!(
12170 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n keep_working:\n{block}"
12171 ))
12172 }
12173
12174 fn refused_keep_working_field(block: &str) -> (String, String) {
12175 match lifecycle_with_keep_working(block) {
12176 Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
12177 (field, message)
12178 }
12179 other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
12180 }
12181 }
12182
12183 #[test]
12184 fn lifecycle_keep_working_is_off_when_absent() {
12185 let without_block =
12186 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12187 assert_eq!(without_block.effective_lifecycle().keep_working, None);
12188 let without_key = RuntimeManifest::from_yaml_str(
12189 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12190 )
12191 .unwrap();
12192 assert_eq!(without_key.lifecycle.unwrap().keep_working, None);
12193 }
12194
12195 #[test]
12196 fn lifecycle_keep_working_takes_the_declared_message_and_limit() {
12197 let manifest =
12198 lifecycle_with_keep_working(" message: \"go on\"\n limit: 3\n").unwrap();
12199 assert!(
12200 manifest.unknown_keys.is_empty(),
12201 "{:?}",
12202 manifest.unknown_keys
12203 );
12204 assert_eq!(
12205 manifest.lifecycle.unwrap().keep_working,
12206 Some(KeepWorking {
12207 message: "go on".into(),
12208 limit: 3
12209 })
12210 );
12211 }
12212
12213 #[test]
12215 fn lifecycle_keep_working_keeps_the_message_as_written() {
12216 let manifest =
12217 lifecycle_with_keep_working(" message: \" go on \\n\"\n limit: 1\n").unwrap();
12218 assert_eq!(
12219 manifest.lifecycle.unwrap().keep_working.unwrap().message,
12220 " go on \n"
12221 );
12222 }
12223
12224 #[test]
12225 fn lifecycle_keep_working_refuses_a_missing_or_blank_message() {
12226 for block in [
12227 " limit: 3\n",
12228 " message: \"\"\n limit: 3\n",
12229 " message: \" \\n\\t \"\n limit: 3\n",
12230 ] {
12231 let (field, _) = refused_keep_working_field(block);
12232 assert_eq!(field, "lifecycle.keep_working.message", "{block:?}");
12233 }
12234 }
12235
12236 #[test]
12237 fn lifecycle_keep_working_refuses_a_missing_limit() {
12238 let (field, message) = refused_keep_working_field(" message: go on\n");
12239 assert_eq!(field, "lifecycle.keep_working.limit");
12240 assert_eq!(message, "is required");
12241 }
12242
12243 #[test]
12244 fn lifecycle_keep_working_refuses_a_zero_limit_and_says_how_to_turn_it_off() {
12245 let (field, message) = refused_keep_working_field(" message: go on\n limit: 0\n");
12246 assert_eq!(field, "lifecycle.keep_working.limit");
12247 assert!(
12248 message.contains("omit lifecycle.keep_working"),
12249 "the refusal says how to turn the option off: {message}"
12250 );
12251 }
12252
12253 #[test]
12254 fn lifecycle_keep_working_reports_an_unknown_key_inside_the_block() {
12255 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"keep_working"));
12256 let manifest =
12257 lifecycle_with_keep_working(" message: go on\n limit: 2\n lmit: 3\n").unwrap();
12258 let reported: Vec<(String, String)> = manifest
12259 .unknown_keys
12260 .iter()
12261 .map(|key| (key.key.clone(), key.block_path.clone()))
12262 .collect();
12263 assert_eq!(
12264 reported,
12265 vec![("lmit".to_string(), "lifecycle.keep_working".to_string())]
12266 );
12267 }
12268
12269 fn lifecycle_with_done_check(
12270 allow: &str,
12271 block: &str,
12272 ) -> Result<RuntimeManifest, RuntimeManifestError> {
12273 RuntimeManifest::from_yaml_str(&format!(
12274 "name: cap\nversion: 0.0.1\nartifacts: []\n{allow}lifecycle:\n done_check:\n{block}"
12275 ))
12276 }
12277
12278 const ALLOW_BASH: &str = "capabilities:\n shell:\n allow: [bash]\n";
12279
12280 fn refused_done_check_field(allow: &str, block: &str) -> (String, String) {
12281 match lifecycle_with_done_check(allow, block) {
12282 Err(RuntimeManifestError::InvalidLifecycleConfig { field, message }) => {
12283 (field, message)
12284 }
12285 other => panic!("expected InvalidLifecycleConfig for {block:?}, got {other:?}"),
12286 }
12287 }
12288
12289 #[test]
12290 fn lifecycle_done_check_is_off_when_absent() {
12291 let without_block =
12292 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12293 assert_eq!(without_block.effective_lifecycle().done_check, None);
12294 let without_key = RuntimeManifest::from_yaml_str(
12295 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n queue_depth: 3\n",
12296 )
12297 .unwrap();
12298 assert_eq!(without_key.lifecycle.unwrap().done_check, None);
12299 }
12300
12301 #[test]
12302 fn lifecycle_done_check_takes_the_command_and_limit_with_the_default_timeout() {
12303 let manifest = lifecycle_with_done_check(
12304 ALLOW_BASH,
12305 " command: \"bash -c 'true'\"\n limit: 2\n",
12306 )
12307 .unwrap();
12308 assert!(
12309 manifest.unknown_keys.is_empty(),
12310 "{:?}",
12311 manifest.unknown_keys
12312 );
12313 let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
12314 assert_eq!(
12315 done_check,
12316 DoneCheck {
12317 command: "bash -c 'true'".into(),
12318 limit: 2,
12319 timeout_secs: 600,
12320 }
12321 );
12322 assert_eq!(done_check.argv(), ["bash", "-c", "true"]);
12323 }
12324
12325 #[test]
12327 fn lifecycle_done_check_accepts_a_zero_limit_and_an_explicit_timeout() {
12328 let manifest = lifecycle_with_done_check(
12329 ALLOW_BASH,
12330 " command: bash -c 'exit 1'\n limit: 0\n timeout_secs: 5\n",
12331 )
12332 .unwrap();
12333 let done_check = manifest.lifecycle.unwrap().done_check.unwrap();
12334 assert_eq!((done_check.limit, done_check.timeout_secs), (0, 5));
12335 }
12336
12337 #[test]
12338 fn lifecycle_done_check_refuses_each_invalid_field() {
12339 for (allow, block, field, message) in [
12340 (ALLOW_BASH, " limit: 1\n", "command", "is required"),
12341 (
12342 ALLOW_BASH,
12343 " command: \" \"\n limit: 1\n",
12344 "command",
12345 "must not be blank",
12346 ),
12347 (
12348 ALLOW_BASH,
12349 " command: \"''\"\n limit: 1\n",
12350 "command",
12351 "must name a program",
12352 ),
12353 (
12354 ALLOW_BASH,
12355 " command: cargo test\n limit: 1\n",
12356 "command",
12357 "runs 'cargo', which is not in capabilities.shell.allow",
12358 ),
12359 (
12360 "",
12361 " command: bash -c true\n limit: 1\n",
12362 "command",
12363 "runs 'bash', which is not in capabilities.shell.allow",
12364 ),
12365 (
12366 ALLOW_BASH,
12367 " command: bash -c true\n",
12368 "limit",
12369 "is required",
12370 ),
12371 (
12372 ALLOW_BASH,
12373 " command: bash -c true\n limit: 1\n timeout_secs: 0\n",
12374 "timeout_secs",
12375 "must be at least 1",
12376 ),
12377 ] {
12378 assert_eq!(
12379 refused_done_check_field(allow, block),
12380 (format!("lifecycle.done_check.{field}"), message.to_string()),
12381 "{block:?}"
12382 );
12383 }
12384 }
12385
12386 #[test]
12387 fn lifecycle_done_check_reports_an_unknown_key_inside_the_block() {
12388 assert!(RawLifecycleConfig::KNOWN_KEYS.contains(&"done_check"));
12389 let manifest = lifecycle_with_done_check(
12390 ALLOW_BASH,
12391 " command: bash -c true\n limit: 2\n limt: 3\n",
12392 )
12393 .unwrap();
12394 let reported: Vec<(String, String)> = manifest
12395 .unknown_keys
12396 .iter()
12397 .map(|key| (key.key.clone(), key.block_path.clone()))
12398 .collect();
12399 assert_eq!(
12400 reported,
12401 vec![("limt".to_string(), "lifecycle.done_check".to_string())]
12402 );
12403 }
12404
12405 #[test]
12408 fn lifecycle_shell_grace_secs_zero_is_accepted() {
12409 let manifest = RuntimeManifest::from_yaml_str(
12410 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n shell_grace_secs: 0\n",
12411 )
12412 .unwrap();
12413 let lifecycle = manifest.lifecycle.unwrap();
12414 assert_eq!(lifecycle.shell_grace_secs, Some(0));
12415 assert_eq!(
12416 lifecycle.max_task_reopens,
12417 LifecycleConfig::default().max_task_reopens
12418 );
12419 }
12420
12421 #[test]
12422 fn lifecycle_max_task_reopens_defaults_to_1() {
12423 let manifest = RuntimeManifest::from_yaml_str(
12424 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n after_task: sleep\n",
12425 )
12426 .unwrap();
12427 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 1);
12428 }
12429
12430 #[test]
12432 fn lifecycle_max_task_reopens_defaults_to_1_without_lifecycle_block() {
12433 let manifest =
12434 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
12435 assert!(manifest.lifecycle.is_none());
12436 assert_eq!(manifest.effective_lifecycle().max_task_reopens, 1);
12437 }
12438
12439 #[test]
12440 fn lifecycle_max_task_reopens_explicit_value() {
12441 let manifest = RuntimeManifest::from_yaml_str(
12442 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 3\n",
12443 )
12444 .unwrap();
12445 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 3);
12446 }
12447
12448 #[test]
12450 fn lifecycle_max_task_reopens_zero_is_accepted() {
12451 let manifest = RuntimeManifest::from_yaml_str(
12452 "name: cap\nversion: 0.0.1\nartifacts: []\nlifecycle:\n max_task_reopens: 0\n",
12453 )
12454 .unwrap();
12455 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 0);
12456 }
12457
12458 #[test]
12460 fn lifecycle_max_task_reopens_with_process_transport() {
12461 let manifest = RuntimeManifest::from_yaml_str(
12462 "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/fixture-process-driver\n version: 0.1.0\n runtime: driver\ninference:\n transport: process\n driver:\n artifact: local/fixture-process-driver\nlifecycle:\n max_task_reopens: 2\n",
12463 ).unwrap();
12464 assert_eq!(manifest.lifecycle.unwrap().max_task_reopens, 2);
12465 }
12466
12467 #[test]
12468 fn inference_max_task_reopens_is_rejected_under_http_transport() {
12469 let err = RuntimeManifest::from_yaml_str(
12470 "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",
12471 ).unwrap_err();
12472 assert!(
12473 matches!(
12474 &err,
12475 RuntimeManifestError::InvalidInferenceConfig { field, .. }
12476 if field == "inference.max_task_reopens"
12477 ),
12478 "error was: {err:?}"
12479 );
12480 let msg = err.to_string();
12481 assert!(
12482 msg.contains("lifecycle.max_task_reopens"),
12483 "error was: {msg}"
12484 );
12485 }
12486
12487 #[test]
12488 fn inference_max_task_reopens_is_rejected_under_process_transport() {
12489 let err = RuntimeManifest::from_yaml_str(
12490 "name: cap\nversion: 0.0.1\nartifacts: []\ninference:\n transport: process\n command: claude\n max_task_reopens: 2\n",
12491 ).unwrap_err();
12492 assert!(
12493 matches!(
12494 &err,
12495 RuntimeManifestError::InvalidInferenceConfig { field, .. }
12496 if field == "inference.max_task_reopens"
12497 ),
12498 "error was: {err:?}"
12499 );
12500 let msg = err.to_string();
12501 assert!(
12502 msg.contains("lifecycle.max_task_reopens"),
12503 "error was: {msg}"
12504 );
12505 }
12506
12507 #[test]
12509 fn inference_max_task_reopens_is_rejected_even_alongside_the_lifecycle_key() {
12510 let err = RuntimeManifest::from_yaml_str(
12511 "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",
12512 ).unwrap_err();
12513 assert!(
12514 matches!(
12515 &err,
12516 RuntimeManifestError::InvalidInferenceConfig { field, .. }
12517 if field == "inference.max_task_reopens"
12518 ),
12519 "error was: {err:?}"
12520 );
12521 }
12522
12523 #[test]
12524 fn skill_is_llm_visible() {
12525 assert!(ArtifactRuntime::Skill.is_llm_visible());
12526 }
12527
12528 #[test]
12529 fn driver_and_hook_are_not_llm_visible() {
12530 assert!(!ArtifactRuntime::Driver.is_llm_visible());
12531 assert!(!ArtifactRuntime::Hook.is_llm_visible());
12532 }
12533
12534 #[test]
12535 fn parses_system_prompt_artifact() {
12536 let manifest = RuntimeManifest::from_yaml_str(
12537 r#"
12538name: cap
12539version: 0.0.1
12540artifacts:
12541 - name: local/my-skill
12542 version: 1.0.0
12543 runtime: skill
12544 - name: murmur/driver-anthropic
12545 version: 0.1.0
12546 runtime: driver
12547 gateway:
12548 endpoint: http://127.0.0.1:8080
12549 keyless: true
12550inference:
12551 model: test-model
12552 system_prompt_artifact: local/my-skill
12553 driver:
12554 artifact: murmur/driver-anthropic
12555"#,
12556 )
12557 .unwrap();
12558
12559 let inference = manifest.inference.unwrap();
12560 assert_eq!(
12561 inference.system_prompt_artifact,
12562 Some("local/my-skill".to_string())
12563 );
12564 assert!(inference.system_prompt.is_none());
12565 assert!(inference.system_prompt_file.is_none());
12566 }
12567
12568 #[test]
12569 fn rejects_system_prompt_artifact_with_inline_prompt() {
12570 let err = RuntimeManifest::from_yaml_str(
12571 r#"
12572name: cap
12573version: 0.0.1
12574artifacts:
12575 - name: local/my-skill
12576 version: 1.0.0
12577 runtime: skill
12578 - name: murmur/driver-anthropic
12579 version: 0.1.0
12580 runtime: driver
12581 gateway:
12582 endpoint: http://127.0.0.1:8080
12583 keyless: true
12584inference:
12585 model: test-model
12586 system_prompt: "inline"
12587 system_prompt_artifact: local/my-skill
12588 driver:
12589 artifact: murmur/driver-anthropic
12590"#,
12591 )
12592 .unwrap_err();
12593
12594 let msg = err.to_string();
12595 assert!(msg.contains("at most one"), "error was: {msg}");
12596 }
12597
12598 #[test]
12599 fn rejects_system_prompt_artifact_naming_non_skill() {
12600 let err = RuntimeManifest::from_yaml_str(
12601 r#"
12602name: cap
12603version: 0.0.1
12604artifacts:
12605 - name: local/my-tool
12606 version: 1.0.0
12607 runtime: tool
12608 - name: murmur/driver-anthropic
12609 version: 0.1.0
12610 runtime: driver
12611 gateway:
12612 endpoint: http://127.0.0.1:8080
12613 keyless: true
12614inference:
12615 model: test-model
12616 system_prompt_artifact: local/my-tool
12617 driver:
12618 artifact: murmur/driver-anthropic
12619"#,
12620 )
12621 .unwrap_err();
12622
12623 let msg = err.to_string();
12624 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12625 assert!(msg.contains("'prompt_payload: true'"), "error was: {msg}");
12626 }
12627
12628 #[test]
12629 fn rejects_system_prompt_artifact_naming_undeclared_artifact() {
12630 let err = RuntimeManifest::from_yaml_str(
12631 r#"
12632name: cap
12633version: 0.0.1
12634artifacts:
12635 - name: murmur/driver-anthropic
12636 version: 0.1.0
12637 runtime: driver
12638 gateway:
12639 endpoint: http://127.0.0.1:8080
12640 keyless: true
12641inference:
12642 model: test-model
12643 system_prompt_artifact: missing-skill
12644 driver:
12645 artifact: murmur/driver-anthropic
12646"#,
12647 )
12648 .unwrap_err();
12649
12650 let msg = err.to_string();
12651 assert!(msg.contains("system_prompt_artifact"), "error was: {msg}");
12652 assert!(msg.contains("not declared"), "error was: {msg}");
12653 }
12654
12655 #[test]
12656 fn rejects_all_three_system_prompt_sources() {
12657 let err = RuntimeManifest::from_yaml_str(
12658 r#"
12659name: cap
12660version: 0.0.1
12661artifacts:
12662 - name: local/my-skill
12663 version: 1.0.0
12664 runtime: skill
12665 - name: murmur/driver-anthropic
12666 version: 0.1.0
12667 runtime: driver
12668 gateway:
12669 endpoint: http://127.0.0.1:8080
12670 keyless: true
12671inference:
12672 model: test-model
12673 system_prompt: "inline"
12674 system_prompt_file: "conventions.md"
12675 system_prompt_artifact: local/my-skill
12676 driver:
12677 artifact: murmur/driver-anthropic
12678"#,
12679 )
12680 .unwrap_err();
12681
12682 let msg = err.to_string();
12683 assert!(msg.contains("at most one"), "error was: {msg}");
12684 }
12685
12686 #[test]
12689 fn hook_artifact_capabilities_parse_network_and_filesystem() {
12690 let manifest = RuntimeManifest::from_yaml_str(
12691 r#"
12692name: cap
12693version: 0.0.1
12694artifacts:
12695 - name: local/telemetry-hook
12696 version: 1.0.0
12697 runtime: hook
12698 capabilities:
12699 network:
12700 allow:
12701 - https://telemetry.example.com
12702 filesystem:
12703 scope: hook-state
12704"#,
12705 )
12706 .unwrap();
12707
12708 let caps = manifest.artifacts[0]
12709 .capabilities
12710 .as_ref()
12711 .expect("hook entry carries a capability grant");
12712 assert_eq!(
12713 caps.network.as_ref().unwrap().allow,
12714 vec!["https://telemetry.example.com".to_string()]
12715 );
12716 assert_eq!(
12717 caps.filesystem.as_ref().unwrap().scope,
12718 Some("hook-state".to_string())
12719 );
12720 }
12721
12722 #[test]
12725 fn hook_artifact_without_capabilities_is_none() {
12726 let manifest = RuntimeManifest::from_yaml_str(
12727 r#"
12728name: cap
12729version: 0.0.1
12730artifacts:
12731 - name: local/quiet-hook
12732 version: 1.0.0
12733 runtime: hook
12734"#,
12735 )
12736 .unwrap();
12737
12738 assert!(manifest.artifacts[0].capabilities.is_none());
12739 }
12740
12741 #[test]
12744 fn tool_and_driver_artifact_capabilities_parse() {
12745 for role in ["tool", "driver"] {
12746 let yaml = format!(
12747 r#"
12748name: cap
12749version: 0.0.1
12750artifacts:
12751 - name: local/scoped
12752 version: 1.0.0
12753 runtime: {role}
12754 capabilities:
12755 network:
12756 allow:
12757 - https://api.example.com
12758 filesystem:
12759 scope: cache
12760"#
12761 );
12762 let manifest = RuntimeManifest::from_yaml_str(&yaml)
12763 .unwrap_or_else(|err| panic!("runtime: {role} may carry a grant, got: {err}"));
12764
12765 let caps = manifest.artifacts[0]
12766 .capabilities
12767 .as_ref()
12768 .unwrap_or_else(|| panic!("runtime: {role} entry carries a capability grant"));
12769 assert_eq!(
12770 caps.network.as_ref().unwrap().allow,
12771 vec!["https://api.example.com".to_string()]
12772 );
12773 assert_eq!(
12774 caps.filesystem.as_ref().unwrap().scope,
12775 Some("cache".to_string())
12776 );
12777 }
12778 }
12779
12780 #[test]
12783 fn tool_artifact_without_capabilities_is_none() {
12784 let manifest = RuntimeManifest::from_yaml_str(
12785 r#"
12786name: cap
12787version: 0.0.1
12788artifacts:
12789 - name: local/plain-tool
12790 version: 1.0.0
12791 runtime: tool
12792"#,
12793 )
12794 .unwrap();
12795
12796 assert!(manifest.artifacts[0].capabilities.is_none());
12797 }
12798
12799 #[test]
12802 fn skill_artifact_capabilities_are_rejected() {
12803 let yaml = r#"
12804name: cap
12805version: 0.0.1
12806artifacts:
12807 - name: local/sneaky
12808 version: 1.0.0
12809 runtime: skill
12810 capabilities:
12811 network:
12812 allow:
12813 - https://evil.example.com
12814"#;
12815 let err = match RuntimeManifest::from_yaml_str(yaml) {
12816 Ok(_) => panic!("per-artifact capabilities must be rejected for runtime: skill"),
12817 Err(err) => err,
12818 };
12819 let msg = err.to_string();
12820 assert!(msg.contains("sneaky"), "error was: {msg}");
12821 assert!(
12822 msg.contains(
12823 "only recognized on 'runtime: hook', 'runtime: tool', and 'runtime: driver' \
12824 entries"
12825 ),
12826 "error was: {msg}"
12827 );
12828 assert!(msg.contains("runtime: skill"), "error was: {msg}");
12829 }
12830
12831 #[test]
12836 fn artifact_config_is_carried_verbatim() {
12837 let manifest = RuntimeManifest::from_yaml_str(
12838 r#"
12839name: cap
12840version: 0.0.1
12841artifacts:
12842 - name: murmur/tool-corpus
12843 version: 0.1.0
12844 runtime: tool
12845 config:
12846 read_recent: { default: 20, max: 100 }
12847"#,
12848 )
12849 .unwrap();
12850
12851 let config = manifest.artifacts[0]
12852 .config
12853 .as_ref()
12854 .expect("a declared block parses");
12855 assert_eq!(
12856 config["read_recent"]["max"],
12857 serde_norway::Value::Number(100.into())
12858 );
12859 }
12860
12861 #[test]
12863 fn an_undeclared_config_is_none() {
12864 let manifest = RuntimeManifest::from_yaml_str(
12865 r#"
12866name: cap
12867version: 0.0.1
12868artifacts:
12869 - name: local/git
12870 version: 1.0.0
12871 runtime: tool
12872"#,
12873 )
12874 .unwrap();
12875
12876 assert!(manifest.artifacts[0].config.is_none());
12877 }
12878
12879 #[test]
12883 fn an_empty_config_block_is_distinguishable_from_an_absent_one() {
12884 let manifest = RuntimeManifest::from_yaml_str(
12885 r#"
12886name: cap
12887version: 0.0.1
12888artifacts:
12889 - name: local/git
12890 version: 1.0.0
12891 runtime: tool
12892 config:
12893"#,
12894 )
12895 .unwrap();
12896
12897 assert_eq!(
12898 manifest.artifacts[0].config,
12899 Some(serde_norway::Value::Null),
12900 "an empty block must survive parsing as a declaration, not vanish into None"
12901 );
12902 }
12903
12904 #[test]
12907 fn skill_artifact_config_is_rejected() {
12908 let yaml = r#"
12909name: cap
12910version: 0.0.1
12911artifacts:
12912 - name: local/sneaky
12913 version: 1.0.0
12914 runtime: skill
12915 config:
12916 who: me
12917"#;
12918 let err = match RuntimeManifest::from_yaml_str(yaml) {
12919 Ok(_) => panic!("config: must be rejected for runtime: skill"),
12920 Err(err) => err,
12921 };
12922 assert!(matches!(
12923 err,
12924 RuntimeManifestError::InvalidArtifact { index: 0, .. }
12925 ));
12926 let msg = err.to_string();
12927 assert!(msg.contains("sneaky"), "error was: {msg}");
12928 assert!(msg.contains("runtime: skill"), "error was: {msg}");
12929 assert!(
12930 msg.contains("only on 'runtime: hook', 'runtime: tool', and 'runtime: driver' entries"),
12931 "error was: {msg}"
12932 );
12933 }
12934
12935 #[test]
12938 fn a_capsule_wide_config_block_is_rejected() {
12939 let yaml = r#"
12940name: cap
12941version: 0.0.1
12942config:
12943 who: capsule
12944artifacts:
12945 - name: local/git
12946 version: 1.0.0
12947 runtime: tool
12948"#;
12949 let err = match RuntimeManifest::from_yaml_str(yaml) {
12950 Ok(_) => panic!("a capsule-wide config block must be rejected"),
12951 Err(err) => err,
12952 };
12953 let msg = err.to_string();
12954 assert!(msg.contains("config"), "error was: {msg}");
12955 assert!(msg.contains("declared per artifact"), "error was: {msg}");
12956 }
12957
12958 #[test]
12962 fn hook_on_overflow_block_parses() {
12963 let manifest = RuntimeManifest::from_yaml_str(
12964 r#"
12965name: cap
12966version: 0.0.1
12967artifacts:
12968 - name: murmur/hook-grafana
12969 version: 1.0.0
12970 runtime: hook
12971 on_overflow: block
12972"#,
12973 )
12974 .unwrap();
12975
12976 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Block);
12977 }
12978
12979 #[test]
12982 fn hook_on_overflow_defaults_to_drop() {
12983 let manifest = RuntimeManifest::from_yaml_str(
12984 r#"
12985name: cap
12986version: 0.0.1
12987artifacts:
12988 - name: murmur/hook-grafana
12989 version: 1.0.0
12990 runtime: hook
12991"#,
12992 )
12993 .unwrap();
12994
12995 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
12996 assert_eq!(HookOverflowPolicy::default(), HookOverflowPolicy::Drop);
12997 }
12998
12999 #[test]
13002 fn on_overflow_on_a_non_hook_artifact_is_rejected() {
13003 let yaml = r#"
13004name: cap
13005version: 0.0.1
13006artifacts:
13007 - name: local/git
13008 version: 1.0.0
13009 runtime: tool
13010 on_overflow: block
13011"#;
13012 let err = match RuntimeManifest::from_yaml_str(yaml) {
13013 Ok(_) => panic!("on_overflow must be rejected outside runtime: hook"),
13014 Err(err) => err,
13015 };
13016 assert!(matches!(
13017 err,
13018 RuntimeManifestError::InvalidArtifact { index: 0, .. }
13019 ));
13020 let msg = err.to_string();
13021 assert!(msg.contains("git"), "error was: {msg}");
13022 assert!(
13023 msg.contains("only recognized on 'runtime: hook' entries"),
13024 "error was: {msg}"
13025 );
13026 }
13027
13028 #[test]
13030 fn unknown_on_overflow_value_is_rejected() {
13031 let yaml = r#"
13032name: cap
13033version: 0.0.1
13034artifacts:
13035 - name: murmur/hook-grafana
13036 version: 1.0.0
13037 runtime: hook
13038 on_overflow: spill
13039"#;
13040 let err = RuntimeManifest::from_yaml_str(yaml).expect_err("unknown policy is rejected");
13041 let msg = err.to_string();
13042 assert!(msg.contains("spill"), "error was: {msg}");
13043 assert!(msg.contains("expected: drop, block"), "error was: {msg}");
13044 }
13045
13046 #[test]
13049 fn on_overflow_is_accepted_on_any_hook_entry() {
13050 let manifest = RuntimeManifest::from_yaml_str(
13051 r#"
13052name: cap
13053version: 0.0.1
13054artifacts:
13055 - name: murmur/hook-eval
13056 version: 1.0.0
13057 runtime: hook
13058 on_overflow: drop
13059 capabilities:
13060 network:
13061 allow:
13062 - https://telemetry.example.com
13063"#,
13064 )
13065 .unwrap();
13066
13067 assert_eq!(manifest.artifacts[0].on_overflow, HookOverflowPolicy::Drop);
13068 assert!(manifest.artifacts[0].capabilities.is_some());
13069 }
13070
13071 #[test]
13074 fn capsule_wide_and_hook_capabilities_coexist() {
13075 let manifest = RuntimeManifest::from_yaml_str(
13076 r#"
13077name: cap
13078version: 0.0.1
13079artifacts:
13080 - name: local/telemetry-hook
13081 version: 1.0.0
13082 runtime: hook
13083 capabilities:
13084 network:
13085 allow:
13086 - https://telemetry.example.com
13087capabilities:
13088 network:
13089 allow:
13090 - https://api.anthropic.com
13091"#,
13092 )
13093 .unwrap();
13094
13095 assert_eq!(
13096 manifest.capabilities.unwrap().network.unwrap().allow,
13097 vec!["https://api.anthropic.com".to_string()]
13098 );
13099 assert_eq!(
13100 manifest.artifacts[0]
13101 .capabilities
13102 .as_ref()
13103 .unwrap()
13104 .network
13105 .as_ref()
13106 .unwrap()
13107 .allow,
13108 vec!["https://telemetry.example.com".to_string()]
13109 );
13110 }
13111
13112 #[test]
13116 fn hook_own_manifest_capabilities_are_not_read_into_config() {
13117 let config = parse_hook_config_from_yaml(
13118 r#"
13119name: telemetry-hook
13120version: 1.0.0
13121binding: on-inference
13122execution_mode: blocking
13123capabilities:
13124 network:
13125 allow:
13126 - https://evil.example.com
13127 filesystem:
13128 scope: /
13129"#,
13130 )
13131 .expect("a self-declared capabilities block is ignored, not an error");
13132
13133 assert_eq!(
13134 config,
13135 HookConfig {
13136 binding: HookBinding::OnInference,
13137 execution_mode: HookExecutionMode::Blocking,
13138 commit_policy: HookCommitPolicy::None,
13139 }
13140 );
13141 }
13142
13143 #[test]
13146 fn containment_classes_order_weakest_to_strongest() {
13147 assert!(ContainmentClass::Unconfined < ContainmentClass::Advisory);
13148 assert!(ContainmentClass::Advisory < ContainmentClass::Scoped);
13149 assert!(ContainmentClass::Scoped < ContainmentClass::Sealed);
13150 assert!(ContainmentClass::Advisory < ContainmentClass::Sealed);
13151 assert_eq!(
13152 ContainmentClass::ALL.iter().copied().max(),
13153 Some(ContainmentClass::Sealed)
13154 );
13155 }
13156
13157 #[test]
13158 fn containment_class_defaults_to_advisory() {
13159 assert_eq!(ContainmentClass::default(), ContainmentClass::Advisory);
13160 }
13161
13162 #[test]
13163 fn containment_class_round_trips_through_string() {
13164 for class in ContainmentClass::ALL {
13165 assert_eq!(
13166 class.to_string().parse::<ContainmentClass>(),
13167 Ok(class),
13168 "{class} must survive Display -> FromStr"
13169 );
13170 }
13171 assert_eq!(ContainmentClass::Advisory.as_str(), "advisory");
13172 assert_eq!(ContainmentClass::Scoped.as_str(), "scoped");
13173 assert_eq!(ContainmentClass::Sealed.as_str(), "sealed");
13174 assert_eq!(ContainmentClass::Unconfined.as_str(), "unconfined");
13175 assert_eq!(ContainmentClass::Unconfined.to_string(), "unconfined");
13176 assert_eq!(
13177 serde_json::to_string(&ContainmentClass::Unconfined).unwrap(),
13178 "\"unconfined\""
13179 );
13180 assert_eq!(
13181 serde_norway::from_str::<ContainmentClass>("unconfined").unwrap(),
13182 ContainmentClass::Unconfined
13183 );
13184 }
13185
13186 #[test]
13187 fn containment_declarable_classes_exclude_unconfined() {
13188 use ContainmentClass::{Advisory, Scoped, Sealed, Unconfined};
13189 assert_eq!(ContainmentClass::DECLARABLE, [Advisory, Scoped, Sealed]);
13190 assert_eq!(
13191 ContainmentClass::ALL,
13192 [Unconfined, Advisory, Scoped, Sealed]
13193 );
13194 let inputs: Vec<Option<ContainmentClass>> = std::iter::once(None)
13195 .chain(ContainmentClass::DECLARABLE.map(Some))
13196 .collect();
13197 for &workspace in &inputs {
13198 for &manifest in &inputs {
13199 for &cli in &inputs {
13200 assert_ne!(
13201 effective_containment_floor(workspace, manifest, cli),
13202 Unconfined
13203 );
13204 }
13205 }
13206 }
13207 }
13208
13209 #[test]
13210 fn containment_class_rejects_unknown_names() {
13211 let err = "paranoid".parse::<ContainmentClass>().unwrap_err();
13212 assert_eq!(err.value, "paranoid");
13213 assert_eq!(
13214 err.to_string(),
13215 "must be one of: advisory, scoped, sealed; got 'paranoid'"
13216 );
13217 assert!("Sealed".parse::<ContainmentClass>().is_err());
13219 }
13220
13221 #[test]
13222 fn containment_class_serializes_as_its_wire_name() {
13223 assert_eq!(
13224 serde_json::to_string(&ContainmentClass::Scoped).unwrap(),
13225 "\"scoped\""
13226 );
13227 assert_eq!(
13228 serde_norway::from_str::<ContainmentClass>("sealed").unwrap(),
13229 ContainmentClass::Sealed
13230 );
13231 }
13232
13233 #[test]
13234 fn effective_floor_is_advisory_when_nothing_is_declared() {
13235 assert_eq!(
13236 effective_containment_floor(None, None, None),
13237 ContainmentClass::Advisory
13238 );
13239 }
13240
13241 #[test]
13242 fn effective_floor_takes_the_strongest_across_every_presence_combination() {
13243 use ContainmentClass::{Advisory, Scoped, Sealed};
13244
13245 assert_eq!(
13247 effective_containment_floor(Some(Scoped), None, None),
13248 Scoped
13249 );
13250 assert_eq!(
13251 effective_containment_floor(None, Some(Scoped), None),
13252 Scoped
13253 );
13254 assert_eq!(
13255 effective_containment_floor(None, None, Some(Scoped)),
13256 Scoped
13257 );
13258 assert_eq!(
13259 effective_containment_floor(Some(Sealed), None, None),
13260 Sealed
13261 );
13262 assert_eq!(
13263 effective_containment_floor(None, Some(Sealed), None),
13264 Sealed
13265 );
13266 assert_eq!(
13267 effective_containment_floor(None, None, Some(Sealed)),
13268 Sealed
13269 );
13270
13271 assert_eq!(
13274 effective_containment_floor(Some(Advisory), Some(Sealed), None),
13275 Sealed
13276 );
13277 assert_eq!(
13278 effective_containment_floor(Some(Sealed), Some(Advisory), None),
13279 Sealed
13280 );
13281 assert_eq!(
13282 effective_containment_floor(Some(Advisory), None, Some(Scoped)),
13283 Scoped
13284 );
13285 assert_eq!(
13286 effective_containment_floor(Some(Scoped), None, Some(Advisory)),
13287 Scoped
13288 );
13289 assert_eq!(
13290 effective_containment_floor(None, Some(Advisory), Some(Scoped)),
13291 Scoped
13292 );
13293 assert_eq!(
13294 effective_containment_floor(None, Some(Scoped), Some(Advisory)),
13295 Scoped
13296 );
13297
13298 assert_eq!(
13300 effective_containment_floor(Some(Sealed), Some(Advisory), Some(Scoped)),
13301 Sealed
13302 );
13303 assert_eq!(
13304 effective_containment_floor(Some(Advisory), Some(Sealed), Some(Scoped)),
13305 Sealed
13306 );
13307 assert_eq!(
13308 effective_containment_floor(Some(Advisory), Some(Scoped), Some(Sealed)),
13309 Sealed
13310 );
13311 assert_eq!(
13312 effective_containment_floor(Some(Scoped), Some(Scoped), Some(Scoped)),
13313 Scoped
13314 );
13315 assert_eq!(
13316 effective_containment_floor(Some(Advisory), Some(Advisory), Some(Advisory)),
13317 Advisory
13318 );
13319 }
13320
13321 #[test]
13322 fn manifest_parses_each_declared_containment_class() {
13323 for class in ContainmentClass::DECLARABLE {
13324 let manifest = RuntimeManifest::from_yaml_str(&format!(
13325 r#"
13326name: cap
13327version: 0.0.1
13328capabilities:
13329 containment: {class}
13330"#
13331 ))
13332 .expect("a valid containment class parses");
13333
13334 assert_eq!(
13335 manifest.capabilities.unwrap().containment,
13336 Some(class),
13337 "capabilities.containment: {class}"
13338 );
13339 }
13340 }
13341
13342 #[test]
13343 fn manifest_without_containment_key_declares_nothing() {
13344 let manifest = RuntimeManifest::from_yaml_str(
13346 r#"
13347name: cap
13348version: 0.0.1
13349"#,
13350 )
13351 .unwrap();
13352 assert!(manifest.capabilities.is_none());
13353
13354 let manifest = RuntimeManifest::from_yaml_str(
13357 r#"
13358name: cap
13359version: 0.0.1
13360capabilities:
13361 network:
13362 allow:
13363 - https://api.example.com
13364"#,
13365 )
13366 .unwrap();
13367 assert_eq!(manifest.capabilities.unwrap().containment, None);
13368 }
13369
13370 #[test]
13371 fn manifest_rejects_an_unknown_containment_class() {
13372 let err = RuntimeManifest::from_yaml_str(
13373 r#"
13374name: cap
13375version: 0.0.1
13376capabilities:
13377 containment: paranoid
13378"#,
13379 )
13380 .expect_err("an unknown containment class is rejected at parse time");
13381
13382 match err {
13383 RuntimeManifestError::InvalidCapabilities { field, message } => {
13384 assert_eq!(field, "capabilities.containment");
13385 assert_eq!(
13386 message,
13387 "must be one of: advisory, scoped, sealed; got 'paranoid'"
13388 );
13389 }
13390 other => panic!("expected InvalidCapabilities, got {other:?}"),
13391 }
13392 }
13393
13394 #[test]
13395 fn manifest_refuses_an_unconfined_containment_class() {
13396 let err = RuntimeManifest::from_yaml_str(
13397 r#"
13398name: cap
13399version: 0.0.1
13400capabilities:
13401 containment: unconfined
13402"#,
13403 )
13404 .expect_err("a manifest cannot choose the unconfined stance");
13405
13406 match err {
13407 RuntimeManifestError::InvalidCapabilities { field, message } => {
13408 assert_eq!(field, "capabilities.containment");
13409 assert!(message.contains("mur run --unconfined"), "{message}");
13410 assert!(message.contains("~/.murmur/config.yaml"), "{message}");
13411 }
13412 other => panic!("expected InvalidCapabilities, got {other:?}"),
13413 }
13414 }
13415
13416 fn exports_manifest(block: &str) -> Result<RuntimeManifest, RuntimeManifestError> {
13419 RuntimeManifest::from_yaml_str(&format!("name: exporter\nversion: 0.0.1\n{block}"))
13420 }
13421
13422 fn export_error(block: &str) -> (String, String) {
13423 match exports_manifest(block).expect_err("this exports block must not parse") {
13424 RuntimeManifestError::InvalidExports { field, message } => (field, message),
13425 other => panic!("expected InvalidExports, got {other:?}"),
13426 }
13427 }
13428
13429 fn peer_fetch_error(block: &str) -> (String, String) {
13430 match exports_manifest(block).expect_err("this capabilities block must not parse") {
13431 RuntimeManifestError::InvalidCapabilities { field, message } => (field, message),
13432 other => panic!("expected InvalidCapabilities, got {other:?}"),
13433 }
13434 }
13435
13436 #[test]
13439 fn parse_duration_secs_accepts_bare_integers_and_single_letter_suffixes() {
13440 assert_eq!(parse_duration_secs("90").unwrap(), 90);
13441 assert_eq!(parse_duration_secs("30s").unwrap(), 30);
13442 assert_eq!(parse_duration_secs("15m").unwrap(), 900);
13443 assert_eq!(parse_duration_secs("1h").unwrap(), 3600);
13444 assert_eq!(parse_duration_secs("14d").unwrap(), 14 * 86_400);
13445 assert_eq!(parse_duration_secs(" 2h ").unwrap(), 7200);
13446 assert_eq!(parse_duration_secs("0").unwrap(), 0);
13447 }
13448
13449 #[test]
13452 fn parse_duration_secs_rejects_every_other_spelling() {
13453 for input in [
13454 "",
13455 " ",
13456 "5 minutes",
13457 "30S",
13458 "1H",
13459 "5min",
13460 "1.5h",
13461 "-1",
13462 "m",
13463 "h",
13464 "1h30m",
13465 "0x10",
13466 "30 s",
13467 ] {
13468 let error = parse_duration_secs(input)
13469 .expect_err(&format!("'{input}' must not parse as a duration"));
13470 assert!(
13471 error.contains(DURATION_ACCEPTED_FORM),
13472 "'{input}' must be refused with the accepted form; got: {error}"
13473 );
13474 }
13475 }
13476
13477 #[test]
13478 fn parse_duration_secs_refuses_to_overflow() {
13479 assert!(parse_duration_secs("18446744073709551615h")
13480 .unwrap_err()
13481 .contains("overflows"));
13482 }
13483
13484 #[test]
13485 fn peer_files_defaults_are_an_undeclared_ttl_and_ten_mebibytes() {
13486 let manifest = exports_manifest("exports:\n peer_files:\n root: out/\n")
13487 .expect("a minimal peer_files block parses");
13488 let peer = manifest.exports.unwrap().peer_files.unwrap();
13489 assert_eq!(peer.root, "out/");
13490 assert_eq!(peer.max_bytes, DEFAULT_PEER_FILES_MAX_BYTES);
13491 assert_eq!(peer.max_bytes, 10_485_760);
13492 assert_eq!(peer.max_ttl_secs, None);
13495 assert_eq!(peer.effective_max_ttl_secs(), DEFAULT_PEER_HANDLE_TTL_SECS);
13496 assert_eq!(peer.effective_max_ttl_secs(), 3600);
13497 }
13498
13499 #[test]
13500 fn peer_files_accepts_every_duration_spelling() {
13501 for (declared, expected) in [("30m", 1800u64), ("2h", 7200), ("45s", 45), ("90", 90)] {
13502 let manifest = exports_manifest(&format!(
13503 "exports:\n peer_files:\n root: out/\n max_ttl: {declared}\n"
13504 ))
13505 .expect("a declared max_ttl parses");
13506 let peer = manifest.exports.unwrap().peer_files.unwrap();
13507 assert_eq!(peer.max_ttl_secs, Some(expected), "max_ttl: {declared}");
13508 assert_eq!(peer.effective_max_ttl_secs(), expected);
13509 }
13510 }
13511
13512 #[test]
13515 fn the_two_export_blocks_are_independent() {
13516 let both = exports_manifest(
13517 "exports:\n files:\n root: out/\n mode: read-only\n \
13518 peer_files:\n root: out/handoff/\n",
13519 )
13520 .expect("both blocks parse together")
13521 .exports
13522 .unwrap();
13523 assert_eq!(both.files.unwrap().root, "out/");
13524 assert_eq!(both.peer_files.unwrap().root, "out/handoff/");
13525
13526 let only_files =
13527 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
13528 .unwrap()
13529 .exports
13530 .unwrap();
13531 assert!(only_files.peer_files.is_none());
13532
13533 let only_peer = exports_manifest("exports:\n peer_files:\n root: out/\n")
13534 .unwrap()
13535 .exports
13536 .unwrap();
13537 assert!(only_peer.files.is_none());
13538
13539 let neither = exports_manifest("exports: {}\n").unwrap().exports.unwrap();
13540 assert!(neither.files.is_none() && neither.peer_files.is_none());
13541 }
13542
13543 #[test]
13544 fn peer_files_root_must_be_relative_and_inside_the_workdir() {
13545 for root in ["../out", "/etc", "out/../../etc"] {
13546 let (field, message) =
13547 export_error(&format!("exports:\n peer_files:\n root: {root}\n"));
13548 assert_eq!(field, "exports.peer_files.root", "root: {root}");
13549 assert!(
13550 message.contains(EXPORT_ROOT_ACCEPTED_FORM),
13551 "root: {root}; got: {message}"
13552 );
13553 }
13554 }
13555
13556 #[test]
13557 fn peer_files_root_is_required() {
13558 let (field, message) = export_error("exports:\n peer_files: {}\n");
13559 assert_eq!(field, "exports.peer_files.root");
13560 assert!(message.contains("is required"));
13561 assert!(message.contains(EXPORT_ROOT_ACCEPTED_FORM));
13562 }
13563
13564 #[test]
13565 fn peer_files_max_ttl_states_its_accepted_form() {
13566 for declared in ["5 minutes", "30S", "5min", "1.5h", "abc"] {
13567 let (field, message) = export_error(&format!(
13568 "exports:\n peer_files:\n root: out/\n max_ttl: \"{declared}\"\n"
13569 ));
13570 assert_eq!(field, "exports.peer_files.max_ttl", "max_ttl: {declared}");
13571 assert!(
13572 message.contains(DURATION_ACCEPTED_FORM),
13573 "max_ttl: {declared}; got: {message}"
13574 );
13575 }
13576 }
13577
13578 #[test]
13581 fn peer_files_max_ttl_rejects_zero() {
13582 let (field, message) =
13583 export_error("exports:\n peer_files:\n root: out/\n max_ttl: 0\n");
13584 assert_eq!(field, "exports.peer_files.max_ttl");
13585 assert!(message.contains("greater than zero"));
13586 }
13587
13588 #[test]
13589 fn peer_files_max_bytes_states_its_accepted_form() {
13590 let (field, message) =
13591 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 10MB\n");
13592 assert_eq!(field, "exports.peer_files.max_bytes");
13593 assert!(message.contains(BYTE_SIZE_ACCEPTED_FORM), "got: {message}");
13594 }
13595
13596 #[test]
13597 fn peer_files_max_bytes_rejects_zero() {
13598 let (field, message) =
13599 export_error("exports:\n peer_files:\n root: out/\n max_bytes: 0\n");
13600 assert_eq!(field, "exports.peer_files.max_bytes");
13601 assert!(message.contains("greater than zero"));
13602 }
13603
13604 #[test]
13608 fn peer_tasks_consents_only_on_an_explicit_true() {
13609 let absent = exports_manifest("artifacts: []\n").expect("no exports block parses");
13610 assert!(absent.exports.is_none());
13611 assert!(!absent.accepts_peer_tasks());
13612
13613 let refused = exports_manifest("exports:\n peer_tasks:\n accept: false\n")
13614 .expect("an explicit refusal parses");
13615 assert_eq!(
13616 refused.exports.as_ref().unwrap().peer_tasks,
13617 Some(PeerTasksExport { accept: false })
13618 );
13619 assert!(!refused.accepts_peer_tasks());
13620 assert!(!refused.exports.as_ref().unwrap().accepts_peer_tasks());
13621
13622 let accepted = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
13623 .expect("consent parses");
13624 assert_eq!(
13625 accepted.exports.as_ref().unwrap().peer_tasks,
13626 Some(PeerTasksExport { accept: true })
13627 );
13628 assert!(accepted.accepts_peer_tasks());
13629 assert!(accepted.exports.as_ref().unwrap().accepts_peer_tasks());
13630 }
13631
13632 #[test]
13634 fn peer_tasks_alone_declares_no_file_plane() {
13635 let manifest = exports_manifest("exports:\n peer_tasks:\n accept: true\n")
13636 .expect("a peer_tasks-only exports block parses");
13637 let exports = manifest.exports.unwrap();
13638 assert_eq!(exports.files, None);
13639 assert_eq!(exports.peer_files, None);
13640 }
13641
13642 #[test]
13644 fn a_file_export_does_not_consent_to_peer_tasks() {
13645 let manifest =
13646 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
13647 .expect("a files export parses");
13648 assert_eq!(manifest.exports.as_ref().unwrap().peer_tasks, None);
13649 assert!(!manifest.accepts_peer_tasks());
13650 }
13651
13652 #[test]
13655 fn peer_tasks_without_accept_is_refused() {
13656 let (field, message) = export_error("exports:\n peer_tasks: {}\n");
13657 assert_eq!(field, "exports.peer_tasks.accept");
13658 assert_eq!(
13659 message,
13660 "must be set explicitly to true or false — consent to peer tasks is never inferred"
13661 );
13662 }
13663
13664 #[test]
13666 fn peer_tasks_of_the_wrong_type_is_refused_naming_the_field() {
13667 for (block, field) in [
13668 (
13669 "exports:\n peer_tasks:\n accept: \"yes\"\n",
13670 "exports.peer_tasks.accept",
13671 ),
13672 (
13673 "exports:\n peer_tasks:\n accept: 1\n",
13674 "exports.peer_tasks.accept",
13675 ),
13676 ("exports:\n peer_tasks: true\n", "exports.peer_tasks"),
13677 ("exports:\n peer_tasks: [peer-a]\n", "exports.peer_tasks"),
13678 ] {
13679 let message = exports_manifest(block)
13680 .expect_err(&format!("{block:?} must not parse"))
13681 .to_string();
13682 assert!(message.contains(field), "{block:?}: {message}");
13683 }
13684 }
13685
13686 #[test]
13689 fn an_unknown_peer_tasks_key_is_reported_by_path() {
13690 let manifest =
13691 exports_manifest("exports:\n peer_tasks:\n accept: false\n allow: [peer-a]\n")
13692 .expect("an unknown key is a warning, not a parse error");
13693 assert!(
13694 manifest
13695 .unknown_keys
13696 .iter()
13697 .any(|key| key.block_path == "exports.peer_tasks" && key.key == "allow"),
13698 "{:?}",
13699 manifest.unknown_keys
13700 );
13701 assert!(!manifest.accepts_peer_tasks());
13702 }
13703
13704 #[test]
13707 fn peer_fetch_allow_carries_its_declared_destinations() {
13708 let manifest = exports_manifest(
13709 "capabilities:\n peer_fetch:\n allow:\n - localhost:41234\n \
13710 - reporting.internal\n - https://gateway.example.com\n",
13711 )
13712 .expect("a peer_fetch block parses");
13713 assert_eq!(
13714 manifest.capabilities.unwrap().peer_fetch.unwrap().allow,
13715 vec![
13716 "localhost:41234".to_string(),
13717 "reporting.internal".to_string(),
13718 "https://gateway.example.com".to_string(),
13719 ]
13720 );
13721 }
13722
13723 #[test]
13726 fn peer_fetch_allow_must_not_be_empty() {
13727 let (field, message) = peer_fetch_error("capabilities:\n peer_fetch:\n allow: []\n");
13728 assert_eq!(field, "capabilities.peer_fetch.allow");
13729 assert_eq!(message, PEER_FETCH_ALLOW_ACCEPTED_FORM);
13730 }
13731
13732 #[test]
13733 fn peer_fetch_allow_refuses_an_entry_that_could_never_become_a_rule() {
13734 for entry in [
13735 "http://[not a host",
13736 "\"\"",
13737 "localhost:notaport",
13738 "http://example.com/path",
13739 "ftp://example.com",
13740 ] {
13741 let (field, message) = peer_fetch_error(&format!(
13742 "capabilities:\n peer_fetch:\n allow:\n - {entry}\n"
13743 ));
13744 assert_eq!(field, "capabilities.peer_fetch.allow", "entry: {entry}");
13745 assert!(
13746 message.contains(PEER_FETCH_ALLOW_ACCEPTED_FORM),
13747 "entry: {entry}; got: {message}"
13748 );
13749 }
13750 }
13751
13752 #[test]
13755 fn peer_fetch_does_not_widen_network_allow() {
13756 let capabilities = exports_manifest(
13757 "capabilities:\n network:\n allow:\n - api.example.com\n \
13758 peer_fetch:\n allow:\n - localhost:41234\n",
13759 )
13760 .unwrap()
13761 .capabilities
13762 .unwrap();
13763 assert_eq!(
13764 capabilities.network.unwrap().allow,
13765 vec!["api.example.com".to_string()]
13766 );
13767 assert_eq!(
13768 capabilities.peer_fetch.unwrap().allow,
13769 vec!["localhost:41234".to_string()]
13770 );
13771 }
13772
13773 #[test]
13774 fn an_absent_peer_fetch_block_is_absent() {
13775 assert!(exports_manifest("capabilities: {}\n")
13776 .unwrap()
13777 .capabilities
13778 .unwrap()
13779 .peer_fetch
13780 .is_none());
13781 }
13782
13783 #[test]
13784 fn parse_byte_size_accepts_bare_integers_and_binary_suffixes() {
13785 assert_eq!(parse_byte_size("0").unwrap(), 0);
13786 assert_eq!(parse_byte_size("4096").unwrap(), 4096);
13787 assert_eq!(parse_byte_size("1Ki").unwrap(), 1024);
13788 assert_eq!(parse_byte_size("10Mi").unwrap(), 10 * 1024 * 1024);
13789 assert_eq!(parse_byte_size("2Gi").unwrap(), 2 * 1024 * 1024 * 1024);
13790 assert_eq!(parse_byte_size(" 10Mi ").unwrap(), 10 * 1024 * 1024);
13791 }
13792
13793 #[test]
13796 fn parse_byte_size_rejects_every_other_spelling() {
13797 for input in [
13798 "", " ", "10MB", "10mi", "10KB", "10 Mi", "Mi", "-1", "1.5Mi", "10Ti", "0x10", "1e3",
13799 "1Ki1",
13800 ] {
13801 let error = parse_byte_size(input)
13802 .expect_err(&format!("'{input}' must not parse as a byte size"));
13803 assert!(
13804 error.contains(BYTE_SIZE_ACCEPTED_FORM),
13805 "'{input}' must be refused with the accepted form; got: {error}"
13806 );
13807 }
13808 }
13809
13810 #[test]
13811 fn parse_byte_size_refuses_to_overflow() {
13812 assert!(parse_byte_size("18446744073709551615Gi")
13813 .unwrap_err()
13814 .contains("overflows"));
13815 }
13816
13817 #[test]
13818 fn exports_files_defaults_max_bytes_to_ten_mebibytes() {
13819 let manifest =
13820 exports_manifest("exports:\n files:\n root: out/\n mode: read-only\n")
13821 .expect("a minimal exports block parses");
13822 let files = manifest.exports.unwrap().files.unwrap();
13823 assert_eq!(files.root, "out/");
13824 assert_eq!(files.mode, ExportMode::ReadOnly);
13825 assert_eq!(files.max_bytes, DEFAULT_EXPORT_MAX_BYTES);
13826 assert_eq!(files.max_bytes, 10_485_760);
13827 }
13828
13829 #[test]
13830 fn exports_files_accepts_both_byte_spellings() {
13831 for (declared, expected) in [("1Ki", 1024u64), ("4096", 4096)] {
13832 let manifest = exports_manifest(&format!(
13833 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: {declared}\n"
13834 ))
13835 .expect("a declared max_bytes parses");
13836 assert_eq!(
13837 manifest.exports.unwrap().files.unwrap().max_bytes,
13838 expected,
13839 "max_bytes: {declared}"
13840 );
13841 }
13842 }
13843
13844 #[test]
13847 fn an_exports_block_without_files_declares_no_plane() {
13848 let manifest = exports_manifest("exports: {}\n").expect("an empty exports block parses");
13849 assert!(manifest.exports.unwrap().files.is_none());
13850 }
13851
13852 #[test]
13853 fn an_absent_exports_block_is_absent() {
13854 let manifest = exports_manifest("").expect("a manifest without exports parses");
13855 assert!(manifest.exports.is_none());
13856 }
13857
13858 #[test]
13859 fn exports_files_root_must_be_relative_and_inside_the_workdir() {
13860 for root in ["../out", "/etc", "out/../../etc", "''"] {
13861 let (field, message) = export_error(&format!(
13862 "exports:\n files:\n root: {root}\n mode: read-only\n"
13863 ));
13864 assert_eq!(field, "exports.files.root", "root: {root}");
13865 assert!(
13866 message.contains("must be a relative path inside the workdir"),
13867 "root: {root}; got: {message}"
13868 );
13869 }
13870 }
13871
13872 #[test]
13873 fn exports_files_root_is_required() {
13874 let (field, message) = export_error("exports:\n files:\n mode: read-only\n");
13875 assert_eq!(field, "exports.files.root");
13876 assert_eq!(
13877 message,
13878 "is required and must be a relative path inside the workdir"
13879 );
13880 }
13881
13882 #[test]
13883 fn exports_files_mode_is_required_and_read_only() {
13884 let (field, message) = export_error("exports:\n files:\n root: out/\n");
13885 assert_eq!(field, "exports.files.mode");
13886 assert_eq!(message, "is required and must be 'read-only'");
13887
13888 let (field, message) =
13889 export_error("exports:\n files:\n root: out/\n mode: read-write\n");
13890 assert_eq!(field, "exports.files.mode");
13891 assert_eq!(message, "'read-write' must be 'read-only'");
13892 }
13893
13894 #[test]
13895 fn exports_files_max_bytes_states_its_accepted_form() {
13896 let (field, message) = export_error(
13897 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 10MB\n",
13898 );
13899 assert_eq!(field, "exports.files.max_bytes");
13900 assert_eq!(
13901 message,
13902 "'10MB' must be a byte count, optionally suffixed Ki/Mi/Gi"
13903 );
13904 }
13905
13906 #[test]
13909 fn exports_files_max_bytes_rejects_zero() {
13910 let (field, message) = export_error(
13911 "exports:\n files:\n root: out/\n mode: read-only\n max_bytes: 0\n",
13912 );
13913 assert_eq!(field, "exports.files.max_bytes");
13914 assert!(message.contains("must be greater than zero"), "{message}");
13915 }
13916
13917 #[test]
13919 fn exports_files_root_accepts_a_current_dir_prefix() {
13920 let manifest =
13921 exports_manifest("exports:\n files:\n root: ./out\n mode: read-only\n")
13922 .expect("./out is a legal root");
13923 assert_eq!(manifest.exports.unwrap().files.unwrap().root, "./out");
13924 }
13925
13926 fn unknown_keys_of(yaml: &str) -> Vec<(String, String, Option<String>)> {
13929 RuntimeManifest::from_yaml_str(yaml)
13930 .expect("fixture must parse")
13931 .unknown_keys
13932 .into_iter()
13933 .map(|key| (key.key, key.block_path, key.nearest_known))
13934 .collect()
13935 }
13936
13937 #[test]
13940 fn a_typo_in_a_capability_block_is_captured_with_its_path_and_suggestion() {
13941 assert_eq!(
13942 unknown_keys_of(
13943 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n read-only:\n - tests\n"
13944 ),
13945 vec![(
13946 "read-only".to_string(),
13947 "capabilities.filesystem".to_string(),
13948 Some("read_only".to_string())
13949 )]
13950 );
13951 }
13952
13953 #[test]
13957 fn a_manifest_of_recognized_keys_reports_nothing() {
13958 assert!(unknown_keys_of(
13959 r#"
13960name: cap
13961version: 0.1.0
13962deploy:
13963 mur_version: "0.2.0"
13964artifacts:
13965 - name: local/notes-tool
13966 version: 0.1.0
13967 runtime: tool
13968 capabilities:
13969 filesystem:
13970 scope: notes
13971 network:
13972 allow:
13973 - example.com:443
13974capabilities:
13975 filesystem:
13976 read_only:
13977 - tests
13978 - bench/fixtures
13979 workdir_exec: false
13980 shell:
13981 allow:
13982 - git
13983 - python3
13984 network:
13985 allow:
13986 - example.com:443
13987 unix_sockets: false
13988"#
13989 )
13990 .is_empty());
13991 }
13992
13993 #[test]
13996 fn a_per_artifact_capability_block_carries_its_index_in_the_path() {
13997 assert_eq!(
13998 unknown_keys_of(
13999 "name: cap\nversion: 0.1.0\nartifacts:\n - name: local/t\n version: 0.1.0\n \
14000 runtime: tool\n capabilities:\n shell:\n allow:\n - git\n \
14001 allwo:\n - git\n"
14002 ),
14003 vec![(
14004 "allwo".to_string(),
14005 "artifacts[0].capabilities.shell".to_string(),
14006 Some("allow".to_string())
14007 )]
14008 );
14009 }
14010
14011 #[test]
14014 fn an_unrelated_top_level_key_has_an_empty_path_and_no_suggestion() {
14015 assert_eq!(
14016 unknown_keys_of("name: cap\nversion: 0.1.0\nquantum_teleport: true\n"),
14017 vec![("quantum_teleport".to_string(), String::new(), None)]
14018 );
14019 }
14020
14021 #[test]
14026 fn the_build_side_keys_of_a_publishable_capsule_report_nothing() {
14027 assert!(unknown_keys_of(
14028 "name: cap\nversion: 0.1.0\nruntime: capsule\nexecution: static\n"
14029 )
14030 .is_empty());
14031 }
14032
14033 #[test]
14035 fn every_build_side_key_is_recognized() {
14036 assert!(unknown_keys_of(
14037 "name: cap\nversion: 0.1.0\nruntime: tool\nimplementation: wasm\nexecution: \
14038 wasm\nrequires_files:\n - tool.wasm\n - assets/logo.png\n"
14039 )
14040 .is_empty());
14041 }
14042
14043 #[test]
14046 fn a_genuinely_unknown_key_beside_the_build_side_keys_still_reports() {
14047 assert_eq!(
14048 unknown_keys_of(
14049 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: wasm\nexecution: \
14050 static\nrequires_files:\n - capsule.wasm\nquantum_teleport: true\n"
14051 ),
14052 vec![("quantum_teleport".to_string(), String::new(), None)]
14053 );
14054 }
14055
14056 #[test]
14060 fn the_build_side_keys_change_nothing_the_run_path_reads() {
14061 let body = "artifacts:\n - name: local/notes-tool\n version: 0.1.0\n runtime: tool\n\
14062 \x20 - name: murmur/driver-anthropic\n version: 0.1.0\n runtime: \
14063 driver\n gateway:\n endpoint: http://127.0.0.1:8080\n keyless: true\n\
14064 capabilities:\n filesystem:\n read_only:\n - tests\n\
14065 inference:\n transport: http\n model: test-model\n driver:\n \
14066 artifact: murmur/driver-anthropic\n";
14067 let plain = RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{body}"))
14068 .expect("fixture must parse");
14069 let with_build_keys = RuntimeManifest::from_yaml_str(&format!(
14070 "name: cap\nversion: 0.1.0\nruntime: capsule\nimplementation: 42\nexecution:\n \
14071 any: shape\nrequires_files: not-a-list\n{body}"
14072 ))
14073 .expect("a build-side key of any shape never refuses a manifest");
14074
14075 assert!(with_build_keys.unknown_keys.is_empty());
14076 assert!(
14077 !plain.artifacts.is_empty()
14078 && plain.capabilities.is_some()
14079 && plain.inference.is_some(),
14080 "the fixture must populate the blocks the comparison below rests on, or it \
14081 compares absence against absence"
14082 );
14083 assert_eq!(with_build_keys.name, plain.name);
14084 assert_eq!(with_build_keys.version, plain.version);
14085 assert_eq!(with_build_keys.artifacts, plain.artifacts);
14086 assert_eq!(with_build_keys.capabilities, plain.capabilities);
14087 assert_eq!(with_build_keys.inference, plain.inference);
14088 }
14089
14090 #[test]
14093 fn capturing_a_key_does_not_disturb_the_rest_of_its_block() {
14094 let manifest = RuntimeManifest::from_yaml_str(
14095 "name: cap\nversion: 0.1.0\ncapabilities:\n filesystem:\n scope: notes\n \
14096 read-only:\n - tests\n",
14097 )
14098 .expect("an unrecognized key never refuses a manifest");
14099 assert_eq!(
14100 manifest
14101 .capabilities
14102 .as_ref()
14103 .and_then(|caps| caps.filesystem.as_ref())
14104 .and_then(|fs| fs.scope.as_deref()),
14105 Some("notes")
14106 );
14107 assert_eq!(manifest.unknown_keys.len(), 1);
14108 }
14109
14110 #[test]
14112 fn nested_blocks_outside_capabilities_are_walked_too() {
14113 let keys = unknown_keys_of(
14114 "name: cap\nversion: 0.1.0\ncontext:\n max_tokns: 100\nexports:\n files:\n \
14115 root: out\n mode: read-only\n max_byts: 10\n",
14116 );
14117 assert_eq!(
14118 keys,
14119 vec![
14120 (
14121 "max_tokns".to_string(),
14122 "context".to_string(),
14123 Some("max_tokens".to_string())
14124 ),
14125 (
14126 "max_byts".to_string(),
14127 "exports.files".to_string(),
14128 Some("max_bytes".to_string())
14129 ),
14130 ]
14131 );
14132 }
14133
14134 #[test]
14141 fn every_raw_struct_captures_unknown_keys_and_declares_its_own_field_names() {
14142 let source = include_str!("runtime_manifest.rs");
14143 let lines: Vec<&str> = source.lines().collect();
14144
14145 let mut checked = 0usize;
14146 for (start, line) in lines.iter().enumerate() {
14147 let Some(name) = line
14148 .strip_prefix("struct Raw")
14149 .and_then(|rest| rest.strip_suffix(" {"))
14150 else {
14151 continue;
14152 };
14153 let name = format!("Raw{name}");
14154 let end = (start + 1..lines.len())
14155 .find(|index| lines[*index] == "}")
14156 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
14157 let body = &lines[start + 1..end];
14158
14159 assert!(
14160 body.contains(&" #[serde(flatten)]")
14161 && body.contains(&" unknown: UnknownKeys,"),
14162 "{name} does not carry the `#[serde(flatten)] unknown: UnknownKeys` overflow map, \
14163 so a key it does not recognize would be dropped instead of reported"
14164 );
14165
14166 let declared = serde_field_names(body);
14167 let known = known_keys_of(source, &name);
14168 assert_eq!(
14169 declared, known,
14170 "{name}'s KNOWN_KEYS does not match its serde field names; the W-SEC-019 \
14171 suggester would propose the wrong set for that block"
14172 );
14173 checked += 1;
14174 }
14175 assert!(
14176 checked >= 30,
14177 "expected every Raw* struct to be scanned, found only {checked}"
14178 );
14179 }
14180
14181 #[test]
14188 fn every_raw_struct_descends_into_the_blocks_it_owns() {
14189 let source = include_str!("runtime_manifest.rs");
14190 let lines: Vec<&str> = source.lines().collect();
14191
14192 let mut structs = 0usize;
14193 let mut children = 0usize;
14194 for (start, line) in lines.iter().enumerate() {
14195 let Some(name) = line
14196 .strip_prefix("struct Raw")
14197 .and_then(|rest| rest.strip_suffix(" {"))
14198 else {
14199 continue;
14200 };
14201 let name = format!("Raw{name}");
14202 let end = (start + 1..lines.len())
14203 .find(|index| lines[*index] == "}")
14204 .unwrap_or_else(|| panic!("{name} has no closing brace at column 0"));
14205 let owned = block_typed_fields(&lines[start + 1..end]);
14206 structs += 1;
14207
14208 if owned.is_empty() {
14209 continue;
14210 }
14211 let block = raw_block_impl_of(source, &name);
14212 assert!(
14213 block.contains("fn walk_children"),
14214 "{name} owns the nested block{} {}, but its `impl RawBlock` declares no \
14215 `walk_children`, so every key written inside {} would go unreported",
14216 if owned.len() == 1 { "" } else { "s" },
14217 owned.join(", "),
14218 if owned.len() == 1 { "it" } else { "them" }
14219 );
14220 for field in &owned {
14221 assert!(
14222 mentions_field(&block, field),
14223 "{name}'s `walk_children` never touches `self.{field}`, so every key written \
14224 inside that block would go unreported — descend into it with \
14225 `collect_block(.., &child_path(path, \"{field}\"), out)`"
14226 );
14227 children += 1;
14228 }
14229 }
14230 assert!(
14231 structs >= 30,
14232 "expected every Raw* struct to be scanned, found only {structs}"
14233 );
14234 assert!(
14235 children >= 31,
14236 "expected every nested block field to be scanned, found only {children}"
14237 );
14238 }
14239
14240 fn mentions_field(impl_body: &str, field: &str) -> bool {
14244 let needle = format!("self.{field}");
14245 impl_body.match_indices(&needle).any(|(at, _)| {
14246 impl_body[at + needle.len()..]
14247 .chars()
14248 .next()
14249 .is_none_or(|next| !next.is_alphanumeric() && next != '_')
14250 })
14251 }
14252
14253 fn block_typed_fields(body: &[&str]) -> Vec<String> {
14257 let mut fields = Vec::new();
14258 for line in body {
14259 let trimmed = line.trim();
14260 if trimmed.starts_with("//") || trimmed.starts_with("#[") {
14261 continue;
14262 }
14263 let Some((field, declared)) = line
14264 .strip_prefix(" ")
14265 .and_then(|rest| rest.split_once(':'))
14266 else {
14267 continue;
14268 };
14269 if field.is_empty()
14270 || !field
14271 .chars()
14272 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
14273 {
14274 continue;
14275 }
14276 if declared.contains("Raw") {
14277 fields.push(field.to_string());
14278 }
14279 }
14280 fields
14281 }
14282
14283 fn raw_block_impl_of(source: &str, name: &str) -> String {
14286 let header = format!("impl RawBlock for {name} {{");
14287 let body = source
14288 .split_once(&header)
14289 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
14290 .1;
14291 body.split_once("\n}\n")
14292 .unwrap_or_else(|| panic!("{name}'s RawBlock impl has no closing brace at column 0"))
14293 .0
14294 .to_string()
14295 }
14296
14297 fn raw_unknown_keys(yaml: &str) -> Vec<(String, String)> {
14304 let raw: RawRuntimeManifest = serde_norway::from_str(yaml).expect("fixture must parse");
14305 collect_unknown_keys(&raw)
14306 .into_iter()
14307 .map(|key| (key.key, key.block_path))
14308 .collect()
14309 }
14310
14311 #[test]
14312 fn spawn_workdir_snapshot_is_a_known_key_beside_a_sibling_probe() {
14313 let keys = raw_unknown_keys(
14314 "name: cap\nversion: 0.1.0\ncapabilities:\n spawn:\n allow: [local/r]\n \
14315 workdir_snapshot: [local/r]\n probe_spawn: 1\n",
14316 );
14317 assert_eq!(
14318 keys,
14319 vec![("probe_spawn".to_string(), "capabilities.spawn".to_string())]
14320 );
14321 }
14322
14323 #[test]
14327 fn every_block_reports_its_own_unknown_key_at_its_own_path() {
14328 let keys = raw_unknown_keys(
14329 r#"
14330name: every-block
14331version: 0.1.0
14332probe_root: 1
14333artifacts:
14334 - name: local/t
14335 version: 0.1.0
14336 runtime: tool
14337 probe_artifact: 1
14338 capabilities:
14339 probe_artifact_capabilities: 1
14340 shell:
14341 probe_artifact_shell: 1
14342capabilities:
14343 probe_capabilities: 1
14344 network:
14345 probe_network: 1
14346 peer_fetch:
14347 probe_peer_fetch: 1
14348 filesystem:
14349 probe_filesystem: 1
14350 shell:
14351 probe_shell: 1
14352 interpreter_runtime:
14353 - binary: python3
14354 probe_interpreter_runtime: 1
14355 dirs:
14356 - path: /usr/lib/python3
14357 probe_interpreter_runtime_dir: 1
14358 staged_runtime:
14359 - binary: rg
14360 probe_staged_runtime: 1
14361 spawn:
14362 probe_spawn: 1
14363 env:
14364 probe_env: 1
14365 limits:
14366 probe_limits: 1
14367 resources:
14368 probe_resources: 1
14369 state:
14370 probe_state: 1
14371 task_io:
14372 probe_task_io: 1
14373 conversation:
14374 probe_conversation: 1
14375 install:
14376 probe_install: 1
14377inference:
14378 probe_inference: 1
14379 driver:
14380 probe_driver: 1
14381 compaction:
14382 probe_compaction: 1
14383context:
14384 probe_context: 1
14385observability:
14386 probe_observability: 1
14387 eval:
14388 probe_eval: 1
14389 scorers:
14390 - type: exact_match
14391 probe_scorer: 1
14392trace:
14393 probe_trace: 1
14394network:
14395 probe_network_config: 1
14396lifecycle:
14397 probe_lifecycle: 1
14398exports:
14399 probe_exports: 1
14400 files:
14401 probe_files: 1
14402 peer_files:
14403 probe_peer_files: 1
14404control:
14405 probe_control: 1
14406"#,
14407 );
14408
14409 let expected: Vec<(String, String)> = [
14410 ("probe_root", ""),
14411 ("probe_artifact", "artifacts[0]"),
14412 ("probe_artifact_capabilities", "artifacts[0].capabilities"),
14413 ("probe_artifact_shell", "artifacts[0].capabilities.shell"),
14414 ("probe_capabilities", "capabilities"),
14415 ("probe_network", "capabilities.network"),
14416 ("probe_peer_fetch", "capabilities.peer_fetch"),
14417 ("probe_filesystem", "capabilities.filesystem"),
14418 ("probe_shell", "capabilities.shell"),
14419 (
14420 "probe_interpreter_runtime",
14421 "capabilities.shell.interpreter_runtime[0]",
14422 ),
14423 (
14424 "probe_interpreter_runtime_dir",
14425 "capabilities.shell.interpreter_runtime[0].dirs[0]",
14426 ),
14427 (
14428 "probe_staged_runtime",
14429 "capabilities.shell.staged_runtime[0]",
14430 ),
14431 ("probe_spawn", "capabilities.spawn"),
14432 ("probe_env", "capabilities.env"),
14433 ("probe_limits", "capabilities.limits"),
14434 ("probe_resources", "capabilities.resources"),
14435 ("probe_state", "capabilities.state"),
14436 ("probe_task_io", "capabilities.task_io"),
14437 ("probe_conversation", "capabilities.conversation"),
14438 ("probe_install", "capabilities.install"),
14439 ("probe_inference", "inference"),
14440 ("probe_driver", "inference.driver"),
14441 ("probe_compaction", "inference.compaction"),
14442 ("probe_context", "context"),
14443 ("probe_observability", "observability"),
14444 ("probe_eval", "observability.eval"),
14445 ("probe_scorer", "observability.eval.scorers[0]"),
14446 ("probe_trace", "trace"),
14447 ("probe_network_config", "network"),
14448 ("probe_lifecycle", "lifecycle"),
14449 ("probe_exports", "exports"),
14450 ("probe_files", "exports.files"),
14451 ("probe_peer_files", "exports.peer_files"),
14452 ("probe_control", "control"),
14453 ]
14454 .into_iter()
14455 .map(|(key, path)| (key.to_string(), path.to_string()))
14456 .collect();
14457
14458 assert_eq!(keys, expected);
14459 }
14460
14461 fn serde_field_names(body: &[&str]) -> Vec<String> {
14465 let mut names = Vec::new();
14466 let mut rename: Option<String> = None;
14467 for line in body {
14468 let trimmed = line.trim();
14469 if trimmed.starts_with("#[") {
14470 if let Some(rest) = trimmed.split("rename = \"").nth(1) {
14471 if !trimmed.contains("rename_all") {
14472 rename = rest.split('"').next().map(str::to_string);
14473 }
14474 }
14475 continue;
14476 }
14477 if trimmed.starts_with("//") {
14478 continue;
14479 }
14480 let Some(field) = line
14481 .strip_prefix(" ")
14482 .and_then(|rest| rest.split(':').next())
14483 else {
14484 continue;
14485 };
14486 if field.is_empty()
14487 || !field
14488 .chars()
14489 .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit())
14490 {
14491 continue;
14492 }
14493 if field == "unknown" {
14494 rename = None;
14495 continue;
14496 }
14497 names.push(rename.take().unwrap_or_else(|| field.to_string()));
14498 }
14499 names
14500 }
14501
14502 fn known_keys_of(source: &str, name: &str) -> Vec<String> {
14506 let header = format!("impl RawBlock for {name} {{");
14507 let body = source
14508 .split_once(&header)
14509 .unwrap_or_else(|| panic!("{name} has no `impl RawBlock` block"))
14510 .1;
14511 let array = body
14513 .split_once("KNOWN_KEYS: &'static [&'static str] =")
14514 .unwrap_or_else(|| panic!("{name}'s RawBlock impl declares no KNOWN_KEYS"))
14515 .1
14516 .trim_start()
14517 .strip_prefix("&[")
14518 .unwrap_or_else(|| panic!("{name}'s KNOWN_KEYS is not an array literal"))
14519 .split_once("];")
14520 .expect("KNOWN_KEYS array is unterminated")
14521 .0;
14522 array
14523 .split('"')
14524 .skip(1)
14525 .step_by(2)
14526 .map(str::to_string)
14527 .collect()
14528 }
14529}
14530
14531#[cfg(test)]
14532mod control_tests {
14533 use super::*;
14534
14535 const TOOL_ENTRY: &str = " - name: local/card-api\n version: 0.1.0\n runtime: tool\n gateway:\n endpoint: https://cards.example.com\n api_key: ${CARD_TOKEN}\n";
14536
14537 fn agent(transport: &str, control: &str) -> String {
14540 let driver = if transport == "http" {
14541 " - name: local/drv\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.example.com\n api_key: ${DRIVER_KEY}\n"
14542 } else {
14543 " - name: local/drv\n version: 0.1.0\n runtime: driver\n"
14544 };
14545 format!(
14546 "name: cap\nversion: 0.0.1\nartifacts:\n{driver}{TOOL_ENTRY}inference:\n transport: {transport}\n model: m\n driver:\n artifact: local/drv\n{control}"
14547 )
14548 }
14549
14550 fn refusal(yaml: &str) -> (String, String) {
14551 match RuntimeManifest::from_yaml_str(yaml) {
14552 Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14553 other => panic!("expected InvalidControl, got {other:?}"),
14554 }
14555 }
14556
14557 #[test]
14558 fn control_absent_empty_and_empty_lists_load_as_none() {
14559 for control in [
14560 "",
14561 "control: {}\n",
14562 "control:\n",
14563 "control:\n settings: []\n secrets: []\n",
14564 ] {
14565 let manifest = RuntimeManifest::from_yaml_str(&agent("http", control))
14566 .unwrap_or_else(|err| panic!("{control:?}: {err}"));
14567 assert_eq!(manifest.control, None, "{control:?}");
14568 }
14569 }
14570
14571 #[test]
14572 fn control_parses_settings_and_secrets_in_manifest_order() {
14573 let yaml = agent(
14574 "http",
14575 "control:\n settings: [inference.max_tokens]\n secrets: [CARD_TOKEN]\n",
14576 );
14577 for manifest in [
14578 RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14579 RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14580 ] {
14581 let control = manifest.control.unwrap();
14582 assert_eq!(
14583 control.settings,
14584 vec![ControllableSetting::InferenceMaxTokens]
14585 );
14586 assert_eq!(control.secrets, vec!["CARD_TOKEN".to_string()]);
14587 assert!(control.declares_secret("CARD_TOKEN"));
14588 assert!(!control.declares_secret("DRIVER_KEY"));
14589 }
14590 }
14591
14592 #[test]
14593 fn control_setting_wire_names_round_trip() {
14594 for setting in ControllableSetting::ALL {
14595 assert_eq!(
14596 ControllableSetting::from_wire_name(setting.wire_name()),
14597 Some(*setting)
14598 );
14599 }
14600 assert_eq!(ControllableSetting::from_wire_name("inference.model"), None);
14601 }
14602
14603 #[test]
14606 fn control_manifest_refusals_name_the_field() {
14607 let cases: &[(&str, &str, &str, &str)] = &[
14608 (
14609 "http",
14610 "control:\n settings: [inference.model]\n",
14611 "control.settings",
14612 "'inference.model' is not a controllable setting; the controllable settings are: inference.max_tokens",
14613 ),
14614 (
14615 "process",
14616 "control:\n settings: [inference.max_tokens]\n",
14617 "control.settings",
14618 "'inference.max_tokens' is controllable only under inference.transport: http",
14619 ),
14620 (
14621 "http",
14622 "control:\n settings: [inference.max_tokens, inference.max_tokens]\n",
14623 "control.settings",
14624 "'inference.max_tokens' is listed more than once",
14625 ),
14626 (
14627 "http",
14628 "control:\n secrets: [CARD_TOKEN, CARD_TOKEN]\n",
14629 "control.secrets",
14630 "'CARD_TOKEN' is listed more than once",
14631 ),
14632 (
14633 "http",
14634 "control:\n secrets: [card-token]\n",
14635 "control.secrets",
14636 "'card-token' is not a credential name",
14637 ),
14638 (
14639 "http",
14640 "control:\n secrets: [OTHER_TOKEN]\n",
14641 "control.secrets",
14642 "'OTHER_TOKEN' is not referenced by any artifact's gateway.api_key as ${OTHER_TOKEN}",
14643 ),
14644 (
14645 "http",
14646 "control:\n secrets: [DRIVER_KEY]\n",
14647 "control.secrets",
14648 "'DRIVER_KEY' keys the configured inference driver's gateway (artifacts.local/drv.gateway.api_key)",
14649 ),
14650 (
14651 "http",
14652 "control:\n settings: inference.max_tokens\n",
14653 "control.settings",
14654 "must be a list of names",
14655 ),
14656 (
14657 "http",
14658 "control:\n secrets: [1]\n",
14659 "control.secrets",
14660 "must be a list of names",
14661 ),
14662 ];
14663 for (transport, control, field, message) in cases {
14664 let (got_field, got_message) = refusal(&agent(transport, control));
14665 assert_eq!(&got_field, field, "{control:?}");
14666 assert!(
14667 got_message.starts_with(message),
14668 "{control:?}: {got_message}"
14669 );
14670 let rendered = RuntimeManifest::from_yaml_str(&agent(transport, control))
14671 .unwrap_err()
14672 .to_string();
14673 assert!(
14674 rendered.contains(&format!("invalid control config for '{field}'")),
14675 "{rendered}"
14676 );
14677 }
14678 }
14679
14680 #[test]
14681 fn control_on_a_capsule_with_no_inference_is_refused() {
14682 let yaml = format!(
14683 "name: cap\nversion: 0.0.1\nartifacts:\n{TOOL_ENTRY}control:\n secrets: [CARD_TOKEN]\n"
14684 );
14685 let (field, message) = refusal(&yaml);
14686 assert_eq!(field, "control");
14687 assert!(message.contains("no inference: block"), "{message}");
14688 }
14689
14690 #[test]
14691 fn control_unknown_key_is_reported_once_at_its_block() {
14692 let manifest = RuntimeManifest::from_yaml_str(&agent(
14693 "http",
14694 "control:\n settings: [inference.max_tokens]\n agent_may_call: true\n",
14695 ))
14696 .unwrap();
14697 let keys: Vec<(&str, &str)> = manifest
14698 .unknown_keys
14699 .iter()
14700 .map(|key| (key.key.as_str(), key.block_path.as_str()))
14701 .collect();
14702 assert_eq!(keys, vec![("agent_may_call", "control")]);
14703 assert_eq!(
14704 manifest.control.unwrap().settings,
14705 vec![ControllableSetting::InferenceMaxTokens]
14706 );
14707 }
14708}
14709
14710#[cfg(test)]
14711mod driver_choice_tests {
14712 use super::*;
14713
14714 const DRV: &str = " - name: local/drv\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://api.example.com\n api_key: ${DRIVER_KEY}\n";
14715 const ALT: &str = " - name: local/alt\n version: 0.1.0\n runtime: driver\n gateway:\n endpoint: https://alt.example.com\n api_key: ${ALT_KEY}\n";
14716 const ALT_NO_GATEWAY: &str = " - name: local/alt\n version: 0.1.0\n runtime: driver\n";
14717 const TOOL: &str = " - name: local/card-api\n version: 0.1.0\n runtime: tool\n";
14718 const GPT: &str =
14719 " - name: gpt\n model: gpt-5\n driver:\n artifact: local/alt\n";
14720 const HAIKU: &str =
14721 " - name: haiku\n model: haiku\n driver:\n artifact: local/drv\n";
14722 const SWITCHABLE: &str = "control:\n settings: [inference.driver]\n";
14723
14724 fn capsule(artifacts: &str, alternates: &str, control: &str) -> String {
14727 let alternates = if alternates.is_empty() {
14728 String::new()
14729 } else {
14730 format!(" alternates:\n{alternates}")
14731 };
14732 format!(
14733 "name: cap\nversion: 0.0.1\nartifacts:\n{DRV}{artifacts}inference:\n transport: http\n model: m\n driver:\n artifact: local/drv\n{alternates}{control}"
14734 )
14735 }
14736
14737 fn refusal(yaml: &str) -> (String, String) {
14738 match RuntimeManifest::from_yaml_str(yaml) {
14739 Err(RuntimeManifestError::InvalidInferenceConfig { field, message })
14740 | Err(RuntimeManifestError::InvalidControl { field, message }) => (field, message),
14741 other => panic!("expected an inference or control refusal, got {other:?}"),
14742 }
14743 }
14744
14745 #[test]
14746 fn driver_choice_alternates_parse_in_manifest_order() {
14747 let yaml = capsule(ALT, &format!("{GPT}{HAIKU}"), SWITCHABLE);
14748 for manifest in [
14749 RuntimeManifest::from_yaml_str(&yaml).unwrap(),
14750 RuntimeManifest::from_yaml_str_without_secrets(&yaml).unwrap(),
14751 ] {
14752 let inference = manifest.inference.unwrap();
14753 assert_eq!(
14754 inference.alternates,
14755 vec![
14756 InferenceAlternate {
14757 name: "gpt".to_string(),
14758 model: "gpt-5".to_string(),
14759 driver: "local/alt".to_string(),
14760 },
14761 InferenceAlternate {
14762 name: "haiku".to_string(),
14763 model: "haiku".to_string(),
14764 driver: "local/drv".to_string(),
14765 },
14766 ]
14767 );
14768 let control = manifest.control.unwrap();
14769 assert_eq!(control.settings, vec![ControllableSetting::InferenceDriver]);
14770 assert!(control.agent_settings.is_empty());
14771 }
14772 }
14773
14774 #[test]
14775 fn driver_choice_absent_alternates_are_empty() {
14776 let manifest = RuntimeManifest::from_yaml_str(&capsule("", "", "")).unwrap();
14777 assert!(manifest.inference.unwrap().alternates.is_empty());
14778 }
14779
14780 #[test]
14781 fn driver_choice_agent_settings_alone_grant_the_agent_and_no_controller() {
14782 let manifest = RuntimeManifest::from_yaml_str(&capsule(
14783 ALT,
14784 GPT,
14785 "control:\n agent_settings: [inference.driver]\n",
14786 ))
14787 .unwrap();
14788 let control = manifest.control.unwrap();
14789 assert!(control.settings.is_empty());
14790 assert_eq!(
14791 control.agent_settings,
14792 vec![ControllableSetting::InferenceDriver]
14793 );
14794 assert!(control.agent_may_set(ControllableSetting::InferenceDriver));
14795 assert!(!control.controller_may_set(ControllableSetting::InferenceDriver));
14796 assert!(control.secrets.is_empty());
14797 }
14798
14799 #[test]
14800 fn driver_choice_wire_name_round_trips() {
14801 assert_eq!(
14802 ControllableSetting::from_wire_name("inference.driver"),
14803 Some(ControllableSetting::InferenceDriver)
14804 );
14805 assert_eq!(
14806 ControllableSetting::InferenceDriver.wire_name(),
14807 "inference.driver"
14808 );
14809 assert_eq!(
14810 ControllableSetting::AGENT_SETTABLE,
14811 &[ControllableSetting::InferenceDriver]
14812 );
14813 }
14814
14815 #[test]
14817 fn driver_choice_gateway_is_accepted_on_an_alternates_driver_only() {
14818 RuntimeManifest::from_yaml_str(&capsule(ALT, GPT, SWITCHABLE))
14819 .expect("an alternate's driver carries its own gateway");
14820 match RuntimeManifest::from_yaml_str(&capsule(ALT, "", "")) {
14821 Err(RuntimeManifestError::InvalidArtifact { message, .. }) => {
14822 assert!(message.contains("inference.alternates"), "{message}")
14823 }
14824 other => panic!("expected InvalidArtifact, got {other:?}"),
14825 }
14826 }
14827
14828 #[test]
14830 fn driver_choice_alternate_on_the_primarys_driver_needs_no_gateway_of_its_own() {
14831 let manifest = RuntimeManifest::from_yaml_str(&capsule("", HAIKU, SWITCHABLE)).unwrap();
14832 assert_eq!(
14833 manifest.inference.unwrap().alternates[0].driver,
14834 "local/drv"
14835 );
14836 }
14837
14838 #[test]
14839 fn driver_choice_secret_keying_an_alternates_gateway_is_accepted() {
14840 let manifest = RuntimeManifest::from_yaml_str(&capsule(
14841 ALT,
14842 GPT,
14843 "control:\n settings: [inference.driver]\n secrets: [ALT_KEY]\n",
14844 ))
14845 .unwrap();
14846 assert!(manifest.control.unwrap().declares_secret("ALT_KEY"));
14847 let (field, message) = refusal(&capsule(
14848 ALT,
14849 GPT,
14850 "control:\n settings: [inference.driver]\n secrets: [DRIVER_KEY]\n",
14851 ));
14852 assert_eq!(field, "control.secrets");
14853 assert!(
14854 message.contains("keys the configured inference driver's gateway"),
14855 "{message}"
14856 );
14857 }
14858
14859 #[test]
14860 fn driver_choice_unknown_key_in_an_alternate_is_reported_at_its_block() {
14861 let alternate = " - name: gpt\n model: gpt-5\n temperature: 1\n driver:\n artifact: local/alt\n";
14862 let manifest =
14863 RuntimeManifest::from_yaml_str(&capsule(ALT, alternate, SWITCHABLE)).unwrap();
14864 let keys: Vec<(&str, &str)> = manifest
14865 .unknown_keys
14866 .iter()
14867 .map(|key| (key.key.as_str(), key.block_path.as_str()))
14868 .collect();
14869 assert_eq!(keys, vec![("temperature", "inference.alternates[0]")]);
14870 }
14871
14872 #[test]
14875 fn driver_choice_manifest_refusals_name_the_field() {
14876 let alternate = |name: &str, model: &str, driver: &str| {
14877 format!(" - name: {name}\n model: {model}\n driver:\n artifact: {driver}\n")
14878 };
14879 let process = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/drv\n version: 0.1.0\n runtime: driver\ninference:\n transport: process\n driver:\n artifact: local/drv\n";
14880 let cases: Vec<(String, &str, &str)> = vec![
14881 (
14882 format!("{process} alternates:\n{GPT}"),
14883 "inference.alternates",
14884 "is not valid with transport: process",
14885 ),
14886 (
14887 format!("{process}control:\n settings: [inference.driver]\n"),
14888 "control.settings",
14889 "'inference.driver' is controllable only under inference.transport: http",
14890 ),
14891 (
14892 capsule(ALT, &alternate("primary", "gpt-5", "local/alt"), SWITCHABLE),
14893 "inference.alternates[0].name",
14894 "'primary' is reserved",
14895 ),
14896 (
14897 capsule(
14898 ALT,
14899 &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt", "gpt-4", "local/alt")),
14900 SWITCHABLE,
14901 ),
14902 "inference.alternates[1].name",
14903 "'gpt' names more than one alternate",
14904 ),
14905 (
14906 capsule(ALT, &alternate("GPT", "gpt-5", "local/alt"), SWITCHABLE),
14907 "inference.alternates[0].name",
14908 "'GPT' is not a driver choice name",
14909 ),
14910 (
14911 capsule(ALT, &alternate("5gpt", "gpt-5", "local/alt"), SWITCHABLE),
14912 "inference.alternates[0].name",
14913 "'5gpt' is not a driver choice name",
14914 ),
14915 (
14916 capsule(ALT, &alternate(&format!("g{}", "x".repeat(63)), "gpt-5", "local/alt"), SWITCHABLE),
14917 "inference.alternates[0].name",
14918 "is not a driver choice name",
14919 ),
14920 (
14921 capsule(ALT, " - name: gpt\n model: \"\"\n driver:\n artifact: local/alt\n", SWITCHABLE),
14922 "inference.alternates[0].model",
14923 "missing required field",
14924 ),
14925 (
14926 capsule(ALT, " - name: gpt\n model: gpt-5\n", SWITCHABLE),
14927 "inference.alternates[0].driver.artifact",
14928 "missing required field",
14929 ),
14930 (
14931 capsule("", &alternate("gpt", "gpt-5", "local/nowhere"), SWITCHABLE),
14932 "inference.alternates[0].driver.artifact",
14933 "artifact 'local/nowhere' is not declared in artifacts:",
14934 ),
14935 (
14936 capsule(TOOL, &alternate("gpt", "gpt-5", "local/card-api"), SWITCHABLE),
14937 "inference.alternates[0].driver.artifact",
14938 "artifact 'local/card-api' is declared with runtime: tool",
14939 ),
14940 (
14941 capsule(ALT_NO_GATEWAY, &alternate("gpt", "gpt-5", "local/alt"), SWITCHABLE),
14942 "inference.alternates[0].driver.artifact",
14943 "artifact 'local/alt' declares no gateway.endpoint",
14944 ),
14945 (
14946 capsule(
14947 ALT,
14948 " - name: gpt\n model: gpt-5\n driver:\n artifact: local/alt\n config:\n thinking: enabled\n",
14949 SWITCHABLE,
14950 ),
14951 "inference.alternates[0].driver.config",
14952 "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",
14953 ),
14954 (
14955 capsule("", &alternate("same", "m", "local/drv"), SWITCHABLE),
14956 "inference.alternates[0]",
14957 "'same' is driver 'local/drv' with model 'm', which is the primary choice",
14958 ),
14959 (
14960 capsule(
14961 ALT,
14962 &format!("{}{}", alternate("gpt", "gpt-5", "local/alt"), alternate("gpt-too", "gpt-5", "local/alt")),
14963 SWITCHABLE,
14964 ),
14965 "inference.alternates[1]",
14966 "'gpt-too' is driver 'local/alt' with model 'gpt-5', which alternate 'gpt' already is",
14967 ),
14968 (
14969 capsule(ALT, GPT, ""),
14970 "inference.alternates",
14971 "declares alternates that nothing could ever select",
14972 ),
14973 (
14974 capsule(ALT, GPT, "control:\n settings: [inference.max_tokens]\n"),
14975 "inference.alternates",
14976 "declares alternates that nothing could ever select",
14977 ),
14978 (
14979 capsule("", "", SWITCHABLE),
14980 "control.settings",
14981 "'inference.driver' selects among inference.alternates, and this capsule declares none",
14982 ),
14983 (
14984 capsule("", "", "control:\n agent_settings: [inference.driver]\n"),
14985 "control.agent_settings",
14986 "'inference.driver' selects among inference.alternates, and this capsule declares none",
14987 ),
14988 (
14989 capsule(ALT, GPT, "control:\n agent_settings: [inference.max_tokens]\n"),
14990 "control.agent_settings",
14991 "'inference.max_tokens' is not a setting the agent may change; control.agent_settings accepts only: inference.driver",
14992 ),
14993 (
14994 capsule(
14995 ALT,
14996 GPT,
14997 "control:\n agent_settings: [inference.driver, inference.driver]\n",
14998 ),
14999 "control.agent_settings",
15000 "'inference.driver' is listed more than once",
15001 ),
15002 (
15003 capsule(ALT, GPT, "control:\n agent_settings: inference.driver\n"),
15004 "control.agent_settings",
15005 "must be a list of names",
15006 ),
15007 ];
15008 for (yaml, field, message) in &cases {
15009 let (got_field, got_message) = refusal(yaml);
15010 assert_eq!(&got_field, field, "{yaml}");
15011 assert!(got_message.contains(message), "{yaml}\n{got_message}");
15012 let rendered = RuntimeManifest::from_yaml_str(yaml)
15013 .unwrap_err()
15014 .to_string();
15015 assert!(rendered.contains(&format!("'{field}'")), "{rendered}");
15016 }
15017 }
15018}
15019
15020#[cfg(test)]
15021mod network_authentication_tests {
15022 use super::*;
15023
15024 fn network_authentication_of(
15025 block: &str,
15026 ) -> Result<Option<NetworkAuthentication>, RuntimeManifestError> {
15027 RuntimeManifest::from_yaml_str(&format!("name: cap\nversion: 0.1.0\n{block}"))
15028 .map(|manifest| manifest.network.and_then(|network| network.authentication))
15029 }
15030
15031 fn network_authentication_refusal(block: &str) -> (String, String) {
15032 match network_authentication_of(block) {
15033 Err(RuntimeManifestError::InvalidNetworkConfig { field, message }) => (field, message),
15034 other => panic!("expected InvalidNetworkConfig, got {other:?}"),
15035 }
15036 }
15037
15038 #[test]
15039 fn network_authentication_with_credentials_parses_sorted_by_name() {
15040 let auth = network_authentication_of(
15041 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15042 watcher:\n scopes: [GetTask, murmur.WatchSession]\n \
15043 reader:\n scopes: [resources/files]\n",
15044 )
15045 .unwrap()
15046 .expect("declared");
15047 assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
15048 assert_eq!(
15049 auth.credentials,
15050 vec![
15051 DoorCredential {
15052 name: "reader".to_string(),
15053 scopes: vec!["resources/files".to_string()],
15054 },
15055 DoorCredential {
15056 name: "watcher".to_string(),
15057 scopes: vec!["GetTask".to_string(), "murmur.WatchSession".to_string()],
15059 },
15060 ]
15061 );
15062 }
15063
15064 #[test]
15065 fn network_authentication_scheme_alone_parses() {
15066 let auth = network_authentication_of("network:\n authentication:\n scheme: bearer\n")
15067 .unwrap()
15068 .expect("declared");
15069 assert_eq!(auth.scheme, AuthenticationScheme::Bearer);
15070 assert!(auth.credentials.is_empty());
15071 }
15072
15073 #[test]
15074 fn network_authentication_absent_is_a_public_door() {
15075 assert_eq!(network_authentication_of("").unwrap(), None);
15076 assert_eq!(
15077 network_authentication_of("network:\n internal_port: 8080\n").unwrap(),
15078 None
15079 );
15080 let manifest = RuntimeManifest::from_yaml_str(
15081 "name: cap\nversion: 0.1.0\nnetwork:\n internal_port: 8080\n",
15082 )
15083 .unwrap();
15084 assert_eq!(
15085 manifest.network,
15086 Some(NetworkConfig {
15087 internal_port: Some(8080),
15088 authentication: None,
15089 })
15090 );
15091 }
15092
15093 #[test]
15094 fn network_authentication_without_a_scheme_is_refused() {
15095 let (field, message) = network_authentication_refusal(
15096 "network:\n authentication:\n credentials:\n w:\n scopes: [GetTask]\n",
15097 );
15098 assert_eq!(field, "network.authentication.scheme");
15099 assert!(message.contains("required"), "{message}");
15100 }
15101
15102 #[test]
15103 fn network_authentication_with_another_scheme_is_refused_naming_bearer() {
15104 for scheme in ["mtls", "Bearer", "basic"] {
15105 let (field, message) = network_authentication_refusal(&format!(
15106 "network:\n authentication:\n scheme: {scheme}\n"
15107 ));
15108 assert_eq!(field, "network.authentication.scheme");
15109 assert!(message.contains("bearer"), "{message}");
15110 assert!(message.contains(scheme), "{message}");
15111 }
15112 }
15113
15114 #[test]
15115 fn network_authentication_refuses_a_bad_or_reserved_credential_name() {
15116 let long = format!("a{}", "b".repeat(32));
15117 for name in [
15118 "operator",
15119 "Watcher",
15120 "1st",
15121 "_x",
15122 "-x",
15123 "wat.cher",
15124 long.as_str(),
15125 ] {
15126 let (field, message) = network_authentication_refusal(&format!(
15127 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15128 '{name}':\n scopes: [GetTask]\n"
15129 ));
15130 assert_eq!(field, "network.authentication.credentials", "{name}");
15131 assert!(message.contains(name), "{message}");
15132 }
15133 let longest = format!("a{}", "b".repeat(31));
15134 for name in ["w", "a-b_c9", longest.as_str()] {
15135 network_authentication_of(&format!(
15136 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15137 {name}:\n scopes: [GetTask]\n"
15138 ))
15139 .unwrap_or_else(|error| panic!("{name} must be accepted: {error}"));
15140 }
15141 }
15142
15143 #[test]
15144 fn network_authentication_refuses_missing_empty_unknown_or_duplicated_scopes() {
15145 let credential = |body: &str| {
15146 format!(
15147 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15148 watcher:\n{body}"
15149 )
15150 };
15151 let field = "network.authentication.credentials.watcher.scopes";
15152
15153 let (got, message) = network_authentication_refusal(&credential(" {}\n"));
15154 assert_eq!(got, field);
15155 assert!(message.contains("required"), "{message}");
15156
15157 let (got, message) = network_authentication_refusal(&credential(" scopes: []\n"));
15158 assert_eq!(got, field);
15159 assert!(message.contains("no scope"), "{message}");
15160
15161 let (got, message) =
15162 network_authentication_refusal(&credential(" scopes: [tasks/list]\n"));
15163 assert_eq!(got, field);
15164 assert!(message.contains("tasks/list"), "{message}");
15165 assert!(message.contains("resources/files"), "{message}");
15166
15167 let (got, message) =
15168 network_authentication_refusal(&credential(" scopes: [GetExtendedAgentCard]\n"));
15169 assert_eq!(got, field);
15170 assert!(message.contains("GetExtendedAgentCard"), "{message}");
15171
15172 let (got, message) = network_authentication_refusal(&credential(
15173 " scopes: [GetTask, murmur.WatchSession, GetTask]\n",
15174 ));
15175 assert_eq!(got, field);
15176 assert!(message.contains("more than once"), "{message}");
15177 }
15178
15179 #[test]
15180 fn network_authentication_refuses_a_retired_scope_naming_its_replacement() {
15181 let field = "network.authentication.credentials.watcher.scopes";
15182 for (retired, replacement) in [
15183 ("message/send", "SendMessage"),
15184 ("message/stream", "SendStreamingMessage"),
15185 ("tasks/get", "GetTask"),
15186 ("tasks/cancel", "CancelTask"),
15187 ("agent/getAuthenticatedExtendedCard", "GetExtendedAgentCard"),
15188 ("stream/watch", "murmur.WatchSession"),
15189 ("session/stop", "murmur.StopSession"),
15190 ] {
15191 let error = network_authentication_of(&format!(
15192 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15193 watcher:\n scopes: [murmur.WatchSession, {retired}]\n"
15194 ))
15195 .unwrap_err();
15196 let rendered = error.to_string();
15197 match error {
15198 RuntimeManifestError::RetiredDoorScope {
15199 field: got_field,
15200 scope,
15201 replacement: got_replacement,
15202 } => {
15203 assert_eq!(got_field, field);
15204 assert_eq!(scope, retired);
15205 assert_eq!(got_replacement, replacement);
15206 }
15207 other => panic!("expected RetiredDoorScope for {retired}, got {other:?}"),
15208 }
15209 assert!(
15210 rendered.contains(&format!(
15211 "'{retired}' is a retired name of a door method, and the door answers only \
15212 the names its card lists"
15213 )),
15214 "{rendered}"
15215 );
15216 assert!(rendered.contains(field), "{rendered}");
15217 }
15218 }
15219
15220 #[test]
15221 fn network_authentication_accepts_the_v1_scopes() {
15222 let auth = network_authentication_of(
15223 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15224 watcher:\n scopes: [GetTask, murmur.WatchSession, murmur.StopSession]\n",
15225 )
15226 .unwrap()
15227 .unwrap();
15228 assert_eq!(
15229 auth.credentials[0].scopes,
15230 ["GetTask", "murmur.WatchSession", "murmur.StopSession"]
15231 );
15232 }
15233
15234 #[test]
15237 fn door_scope_list_tasks_and_subscribe_to_task_load_on_a_credential() {
15238 let auth = network_authentication_of(
15239 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15240 alice:\n scopes: [SendMessage, ListTasks, SubscribeToTask]\n",
15241 )
15242 .unwrap()
15243 .unwrap();
15244 assert_eq!(
15245 auth.credentials[0].scopes,
15246 ["SendMessage", "ListTasks", "SubscribeToTask"]
15247 );
15248 assert_eq!(
15249 DOOR_SCOPES,
15250 [
15251 "SendMessage",
15252 "SendStreamingMessage",
15253 "murmur.WatchSession",
15254 "GetTask",
15255 "ListTasks",
15256 "CancelTask",
15257 "SubscribeToTask",
15258 "murmur.StopSession",
15259 "resources/files",
15260 ]
15261 );
15262 }
15263
15264 #[test]
15265 fn network_authentication_accepts_every_door_scope() {
15266 let auth = network_authentication_of(&format!(
15267 "network:\n authentication:\n scheme: bearer\n credentials:\n \
15268 everything:\n scopes: [{}]\n",
15269 DOOR_SCOPES.join(", ")
15270 ))
15271 .unwrap()
15272 .unwrap();
15273 assert_eq!(auth.credentials[0].scopes, DOOR_SCOPES);
15274 assert_eq!(OPERATOR_CREDENTIAL, "operator");
15275 }
15276
15277 #[test]
15278 fn network_authentication_is_refused_beside_spawn_allow() {
15279 let (field, message) = network_authentication_refusal(
15280 "network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
15281 allow: [local/worker]\n",
15282 );
15283 assert_eq!(field, "network.authentication");
15284 assert!(message.contains("capabilities.spawn.allow"), "{message}");
15285 assert!(message.contains("token"), "{message}");
15286 let error = RuntimeManifest::from_yaml_str(
15287 "name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n\
15288 capabilities:\n spawn:\n allow: [local/worker]\n",
15289 )
15290 .unwrap_err()
15291 .to_string();
15292 assert!(error.contains("network.authentication"), "{error}");
15293 assert!(error.contains("capabilities.spawn.allow"), "{error}");
15294
15295 network_authentication_of(
15297 "network:\n authentication:\n scheme: bearer\ncapabilities:\n spawn:\n \
15298 allow: []\n",
15299 )
15300 .unwrap();
15301 }
15302
15303 #[test]
15304 fn network_authentication_unknown_keys_are_reported_at_their_full_path() {
15305 let keys: Vec<(String, String, Option<String>)> = RuntimeManifest::from_yaml_str(
15306 "name: cap\nversion: 0.1.0\nnetwork:\n authentication:\n scheme: bearer\n \
15307 schemes: x\n credentials:\n watcher:\n scopes: [GetTask]\n \
15308 scope: [GetTask]\n",
15309 )
15310 .unwrap()
15311 .unknown_keys
15312 .into_iter()
15313 .map(|key| (key.key, key.block_path, key.nearest_known))
15314 .collect();
15315 assert_eq!(
15316 keys,
15317 vec![
15318 (
15319 "schemes".to_string(),
15320 "network.authentication".to_string(),
15321 Some("scheme".to_string())
15322 ),
15323 (
15324 "scope".to_string(),
15325 "network.authentication.credentials.watcher".to_string(),
15326 Some("scopes".to_string())
15327 ),
15328 ]
15329 );
15330 }
15331}
15332
15333#[cfg(test)]
15334mod registry_block_tests {
15335 use super::*;
15336
15337 const REGISTRY_BLOCK: &str = "registry:\n default: local\n";
15338
15339 #[test]
15340 fn registry_block_is_refused_by_both_entry_points() {
15341 let input = format!("name: cap\nversion: 0.0.1\nartifacts: []\n{REGISTRY_BLOCK}");
15342 for result in [
15343 RuntimeManifest::from_yaml_str(&input),
15344 RuntimeManifest::from_yaml_str_without_secrets(&input),
15345 ] {
15346 let err = result.unwrap_err();
15347 assert!(
15348 matches!(err, RuntimeManifestError::RegistryBlock),
15349 "{err:?}"
15350 );
15351 assert_eq!(err.to_string(), REGISTRY_BLOCK_MESSAGE);
15352 }
15353 }
15354
15355 #[test]
15356 fn registry_block_is_refused_before_any_other_field() {
15357 let input = format!(
15360 "version: 0.0.1\nlifecycle:\n task_acceptance: queue\n queue_depth: 0\n\
15361 {REGISTRY_BLOCK}"
15362 );
15363 let err = RuntimeManifest::from_yaml_str(&input).unwrap_err();
15364 assert!(
15365 matches!(err, RuntimeManifestError::RegistryBlock),
15366 "{err:?}"
15367 );
15368 }
15369
15370 #[test]
15371 fn registry_block_refusal_applies_whatever_the_block_holds() {
15372 for block in ["registry: local\n", "registry: {}\n", "registry: []\n"] {
15373 let input = format!("name: cap\nversion: 0.0.1\n{block}");
15374 assert!(
15375 matches!(
15376 RuntimeManifest::from_yaml_str(&input),
15377 Err(RuntimeManifestError::RegistryBlock)
15378 ),
15379 "{block}"
15380 );
15381 }
15382 }
15383
15384 #[test]
15385 fn a_manifest_without_a_registry_block_parses_unchanged() {
15386 let manifest =
15387 RuntimeManifest::from_yaml_str("name: cap\nversion: 0.0.1\nartifacts: []\n").unwrap();
15388 assert_eq!(manifest.name, "cap");
15389 assert_eq!(manifest.version, "0.0.1");
15390 }
15391}
15392
15393#[cfg(test)]
15394mod scoped_name_tests {
15395 use super::*;
15396
15397 fn name_refusal(yaml: &str) -> (String, String, Option<String>) {
15398 match RuntimeManifest::from_yaml_str(yaml) {
15399 Err(RuntimeManifestError::InvalidArtifactName {
15400 field,
15401 message,
15402 hint,
15403 }) => (field, message, hint),
15404 other => panic!("expected InvalidArtifactName, got {other:?}"),
15405 }
15406 }
15407
15408 #[test]
15409 fn scoped_name_a_bare_artifact_entry_is_refused_with_the_scoped_forms() {
15410 let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n - name: tool-editor\n \
15411 version: 0.1.0\n runtime: tool\n";
15412 let (field, message, hint) = name_refusal(yaml);
15413 assert_eq!(field, "artifacts[0].name");
15414 assert_eq!(
15415 message,
15416 "'tool-editor' is not a full artifact name: an artifact is named <scope>/<name>"
15417 );
15418 assert_eq!(
15419 hint.as_deref(),
15420 Some(
15421 "name it with its scope: 'murmur/tool-editor' for a Murmur default artifact, \
15422 '<owner>/tool-editor' for one published from github.com/<owner>, or \
15423 'local/tool-editor' for one installed from a .mur.zip file"
15424 )
15425 );
15426 let rendered = RuntimeManifest::from_yaml_str(yaml)
15427 .unwrap_err()
15428 .to_string();
15429 assert!(
15430 rendered.starts_with("murmur.yaml: invalid artifact name in 'artifacts[0].name': "),
15431 "{rendered}"
15432 );
15433 }
15434
15435 #[test]
15436 fn scoped_name_a_source_entry_keeps_its_bare_name() {
15437 let bare = "name: cap\nversion: 0.0.1\nartifacts:\n - name: style\n \
15438 source: ./skills/style\n runtime: skill\n";
15439 RuntimeManifest::from_yaml_str(bare).expect("a source: entry is named by its bare name");
15440
15441 let (field, message, hint) = name_refusal(&bare.replace("name: style", "name: ana/style"));
15442 assert_eq!(field, "artifacts[0].name");
15443 assert!(
15444 message
15445 .starts_with("'ana/style' declares 'source:', so it is named by its bare name; "),
15446 "{message}"
15447 );
15448 assert_eq!(hint.as_deref(), Some("drop the scope: name it 'style'"));
15449 }
15450
15451 #[test]
15452 fn scoped_name_spawn_allow_refuses_a_bare_entry_and_a_shared_bare_part() {
15453 let with_allow = |allow: &str| {
15454 format!("name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n allow: [{allow}]\n")
15455 };
15456 let manifest = RuntimeManifest::from_yaml_str(&with_allow("local/worker, ana/reviewer"))
15457 .expect("full names in two scopes with distinct bare parts parse");
15458 assert_eq!(
15459 manifest.capabilities.unwrap().spawn.unwrap().allow,
15460 vec!["local/worker".to_string(), "ana/reviewer".to_string()]
15461 );
15462
15463 let (field, message, hint) = name_refusal(&with_allow("worker"));
15464 assert_eq!(field, "capabilities.spawn.allow[0]");
15465 assert!(
15466 message.contains("'worker' is not a full artifact name"),
15467 "{message}"
15468 );
15469 assert!(hint.unwrap().contains("local/worker"));
15470
15471 let (field, message, hint) = name_refusal(&with_allow("local/worker, ana/worker"));
15472 assert_eq!(field, "capabilities.spawn.allow[1]");
15473 assert_eq!(
15474 message,
15475 "'local/worker' and 'ana/worker' share the name 'worker', which is how delegate-task \
15476 offers a capsule to the model"
15477 );
15478 assert_eq!(
15479 hint.as_deref(),
15480 Some("keep one of 'local/worker' and 'ana/worker' in capabilities.spawn.allow")
15481 );
15482 }
15483
15484 #[test]
15485 fn spawn_workdir_snapshot_lowers_beside_allow_and_is_empty_when_absent() {
15486 let yaml = "name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n \
15487 allow: [local/reviewer, local/worker]\n \
15488 workdir_snapshot: [local/reviewer]\n";
15489 let spawn = RuntimeManifest::from_yaml_str(yaml)
15490 .expect("a snapshot entry that is also in allow parses")
15491 .capabilities
15492 .unwrap()
15493 .spawn
15494 .unwrap();
15495 assert_eq!(
15496 spawn,
15497 SpawnCapabilities {
15498 allow: vec!["local/reviewer".to_string(), "local/worker".to_string()],
15499 workdir_snapshot: vec!["local/reviewer".to_string()],
15500 }
15501 );
15502
15503 let absent = "name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n \
15504 allow: [local/reviewer]\n";
15505 let spawn = RuntimeManifest::from_yaml_str(absent)
15506 .unwrap()
15507 .capabilities
15508 .unwrap()
15509 .spawn
15510 .unwrap();
15511 assert!(spawn.workdir_snapshot.is_empty());
15512 }
15513
15514 #[test]
15515 fn spawn_workdir_snapshot_refuses_an_entry_outside_allow_a_bare_name_and_a_duplicate() {
15516 let with = |snapshot: &str| {
15517 format!(
15518 "name: cap\nversion: 0.0.1\ncapabilities:\n spawn:\n \
15519 allow: [local/reviewer, local/worker]\n workdir_snapshot: [{snapshot}]\n"
15520 )
15521 };
15522
15523 let (field, message, hint) = name_refusal(&with("local/reviewer, local/other"));
15524 assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
15525 assert_eq!(
15526 message,
15527 "'local/other' is not in capabilities.spawn.allow, so this capsule cannot delegate \
15528 to it"
15529 );
15530 assert!(
15531 hint.as_deref()
15532 .is_some_and(|hint| hint.contains("capabilities.spawn.allow")),
15533 "{hint:?}"
15534 );
15535
15536 let (field, message, _) = name_refusal(&with("reviewer"));
15537 assert_eq!(field, "capabilities.spawn.workdir_snapshot[0]");
15538 assert!(
15539 message.contains("'reviewer' is not a full artifact name"),
15540 "{message}"
15541 );
15542
15543 let (field, message, hint) = name_refusal(&with("local/reviewer, local/reviewer"));
15544 assert_eq!(field, "capabilities.spawn.workdir_snapshot[1]");
15545 assert_eq!(message, "'local/reviewer' is listed more than once");
15546 assert_eq!(
15547 hint.as_deref(),
15548 Some("keep one 'local/reviewer' in capabilities.spawn.workdir_snapshot")
15549 );
15550 }
15551
15552 #[test]
15553 fn scoped_name_a_reference_by_bare_part_names_the_declared_entry() {
15554 let yaml = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/driver-anthropic\n \
15555 version: 0.1.0\n runtime: driver\n gateway:\n \
15556 endpoint: https://api.anthropic.com\n api_key: k\ninference:\n \
15557 transport: http\n model: m\n driver:\n artifact: driver-anthropic\n";
15558 let message = RuntimeManifest::from_yaml_str(yaml)
15559 .unwrap_err()
15560 .to_string();
15561 assert!(
15562 message.contains(
15563 "artifact 'driver-anthropic' is not declared in artifacts: (the declared \
15564 'murmur/driver-anthropic' has that name; refer to it as \
15565 'murmur/driver-anthropic')"
15566 ),
15567 "{message}"
15568 );
15569 }
15570}
15571
15572#[cfg(test)]
15573mod inference_retry_tests {
15574 use super::*;
15575
15576 const HTTP: &str = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/drv\n \
15577 version: 0.1.0\n runtime: driver\n gateway:\n endpoint: \
15578 http://127.0.0.1:8080\n api_key: sk-test\ninference:\n transport: \
15579 http\n model: m\n driver:\n artifact: local/drv\n";
15580 const PROCESS: &str = "name: cap\nversion: 0.0.1\nartifacts:\n - name: local/drv\n \
15581 version: 0.1.0\n runtime: driver\ninference:\n transport: \
15582 process\n driver:\n artifact: local/drv\n";
15583
15584 fn retry(yaml: &str) -> InferenceRetry {
15585 RuntimeManifest::from_yaml_str(yaml)
15586 .unwrap()
15587 .inference
15588 .unwrap()
15589 .retry
15590 }
15591
15592 fn refusal(yaml: &str) -> (String, String) {
15593 match RuntimeManifest::from_yaml_str(yaml) {
15594 Err(RuntimeManifestError::InvalidInferenceConfig { field, message }) => {
15595 (field, message)
15596 }
15597 other => panic!("expected InvalidInferenceConfig, got {other:?}"),
15598 }
15599 }
15600
15601 #[test]
15602 fn inference_retry_defaults_when_absent_and_per_key_when_partial() {
15603 assert_eq!(retry(HTTP), InferenceRetry::default());
15604 assert_eq!(
15605 InferenceRetry::default(),
15606 InferenceRetry {
15607 max_retries: 6,
15608 initial_delay_ms: 1_000,
15609 max_delay_ms: 60_000,
15610 }
15611 );
15612 assert_eq!(
15613 retry(&format!("{HTTP} retry:\n max_retries: 2\n")),
15614 InferenceRetry {
15615 max_retries: 2,
15616 ..InferenceRetry::default()
15617 }
15618 );
15619 assert_eq!(
15620 retry(&format!(
15621 "{HTTP} retry:\n initial_delay_ms: 10\n max_delay_ms: 2000\n"
15622 )),
15623 InferenceRetry {
15624 max_retries: 6,
15625 initial_delay_ms: 10,
15626 max_delay_ms: 2000,
15627 }
15628 );
15629 assert_eq!(
15630 retry(&format!(
15631 "{HTTP} retry:\n max_retries: 0\n initial_delay_ms: 60000\n \
15632 max_delay_ms: 3600000\n"
15633 )),
15634 InferenceRetry {
15635 max_retries: 0,
15636 initial_delay_ms: 60_000,
15637 max_delay_ms: 3_600_000,
15638 }
15639 );
15640 assert_eq!(retry(PROCESS), InferenceRetry::default());
15641 }
15642
15643 #[test]
15644 fn inference_retry_bounds_are_refused_naming_the_field() {
15645 for (block, field) in [
15646 ("max_retries: 21", "inference.retry.max_retries"),
15647 ("initial_delay_ms: 0", "inference.retry.initial_delay_ms"),
15648 (
15649 "initial_delay_ms: 60001",
15650 "inference.retry.initial_delay_ms",
15651 ),
15652 ("max_delay_ms: 0", "inference.retry.max_delay_ms"),
15653 ("max_delay_ms: 3600001", "inference.retry.max_delay_ms"),
15654 (
15655 "initial_delay_ms: 5000\n max_delay_ms: 1000",
15656 "inference.retry.max_delay_ms",
15657 ),
15658 ] {
15659 let (got, message) = refusal(&format!("{HTTP} retry:\n {block}\n"));
15660 assert_eq!(got, field, "{block}: {message}");
15661 }
15662 let (_, message) = refusal(&format!(
15663 "{HTTP} retry:\n initial_delay_ms: 5000\n max_delay_ms: 1000\n"
15664 ));
15665 assert!(message.contains("initial_delay_ms"), "{message}");
15666 }
15667
15668 #[test]
15669 fn inference_retry_is_refused_under_transport_process() {
15670 let (field, message) = refusal(&format!("{PROCESS} retry:\n max_retries: 2\n"));
15671 assert_eq!(field, "inference.retry");
15672 assert_eq!(
15673 message,
15674 "is not valid with transport: process; the harness retries its own provider calls"
15675 );
15676 }
15677
15678 #[test]
15679 fn inference_retry_unknown_key_is_reported_at_its_block() {
15680 let manifest = RuntimeManifest::from_yaml_str(&format!(
15681 "{HTTP} retry:\n max_retries: 2\n jitter: full\n"
15682 ))
15683 .unwrap();
15684 let keys: Vec<(&str, &str)> = manifest
15685 .unknown_keys
15686 .iter()
15687 .map(|key| (key.key.as_str(), key.block_path.as_str()))
15688 .collect();
15689 assert_eq!(keys, vec![("jitter", "inference.retry")]);
15690 }
15691}
15692
15693#[cfg(test)]
15694mod host_paths_tests {
15695 use super::*;
15696
15697 const ENTRY: &str = " - path: ~/.cargo\n access: read-write\n";
15698
15699 fn refused_field(input: &str) -> String {
15701 let fields: Vec<String> = [
15702 RuntimeManifest::from_yaml_str(input),
15703 RuntimeManifest::from_yaml_str_without_secrets(input),
15704 ]
15705 .into_iter()
15706 .map(|result| match result {
15707 Err(RuntimeManifestError::HostPathsDeclared { field }) => field,
15708 other => panic!("expected HostPathsDeclared, got {other:?}"),
15709 })
15710 .collect();
15711 assert_eq!(
15712 fields[0], fields[1],
15713 "both entry points refuse the same field"
15714 );
15715 fields[0].clone()
15716 }
15717
15718 #[test]
15719 fn host_paths_is_refused_wherever_a_manifest_writes_it() {
15720 let top = format!("name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths:\n{ENTRY}");
15721 assert_eq!(refused_field(&top), "host_paths");
15722
15723 let capabilities = format!(
15724 "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n host_paths:\n {}",
15725 ENTRY.replace('\n', "\n ").trim_end()
15726 );
15727 assert_eq!(refused_field(&capabilities), "capabilities.host_paths");
15728
15729 let filesystem = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n \
15730 filesystem:\n host_paths: []\n";
15731 assert_eq!(
15732 refused_field(filesystem),
15733 "capabilities.filesystem.host_paths"
15734 );
15735
15736 let artifact = "name: cap\nversion: 0.0.1\nartifacts:\n - name: murmur/tool-a\n \
15737 version: 0.1.0\n runtime: tool\n - name: murmur/tool-b\n \
15738 version: 0.1.0\n runtime: tool\n capabilities:\n \
15739 host_paths: []\n";
15740 assert_eq!(
15741 refused_field(artifact),
15742 "artifacts[1].capabilities.host_paths"
15743 );
15744 }
15745
15746 #[test]
15747 fn the_refusal_names_the_field_and_is_never_an_unknown_key_warning() {
15748 let input = "name: cap\nversion: 0.0.1\nartifacts: []\nhost_paths: []\n";
15749 let error = RuntimeManifest::from_yaml_str(input).unwrap_err();
15750 assert!(error.to_string().contains("'host_paths'"), "{error}");
15751 assert!(error.to_string().contains("murmur.yaml"), "{error}");
15752 }
15753
15754 #[test]
15755 fn a_manifest_without_host_paths_parses_unchanged() {
15756 let input = "name: cap\nversion: 0.0.1\nartifacts: []\ncapabilities:\n filesystem:\n \
15757 workdir_exec: false\n";
15758 assert!(RuntimeManifest::from_yaml_str(input).is_ok());
15759 }
15760}